mcpField
C
clean-architecture

clean-architecture

github.com/codewithmukesh開発7933.2k 回インストール12 週間前に更新
リポジトリ

clean-architecture について

C# と .NET の開発者向けに、Clean Architecture のレイヤー境界やユースケースの構成、薄い API エンドポイントの設計を解説します。実装パターンに加え、アンチパターンと設計判断の指針も扱います。

主な機能

  1. 01依存関係の逆転とレイヤー境界
  2. 02ドメイン層でのビジネスルール定義
  3. 03ユースケース単位のコマンドとクエリ
  4. 04インターフェースを介したインフラ実装
  5. 05エンドポイントグループの自動検出
  6. 06アンチパターンと設計判断の指針

活用シーン

  1. 01中規模以上のドメインシステムの構成設計
  2. 02ビジネスルールをデータベースや HTTP から分離
  3. 03ユースケース単位のコマンド・クエリ処理の実装

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/codewithmukesh/dotnet-claude-kit --skill clean-architecture

インストール後、Claude Code で/clean-architectureを入力して読み込まれたか確認します。

詳細

クリーンアーキテクチャ

中核となる原則

  1. 依存性逆転が基盤です — すべての依存関係は内側を向きます。Domain にはプロジェクト参照がありません。Application が参照するのは Domain のみです。Infrastructure は Application と Domain を参照します。Api はすべてを参照しますが、依存するのは抽象化のみです。コンパイラーはプロジェクト参照を通じてこれを強制します。
  2. ルールは Domain が所有します — ビジネスロジックは、エンティティメソッド、ドメインサービス、または仕様として Domain レイヤーに配置します。Domain レイヤーはデータベース、HTTP、フレームワークについて一切知りません。純粋な C# と .NET のプリミティブのみを扱います。
  3. ユースケースが作業単位です — 各ユースケース(コマンドまたはクエリ)は、Application レイヤー内の単一のクラスです。ドメインオブジェクトを調整し、抽象化を通じて永続化し、結果を返します。20 個のメソッドを持つ「サービス」クラスは作りません。
  4. Infrastructure はプラグインです — EF Core、外部 API、メール送信機能、ファイルストレージはすべて Infrastructure に配置し、Application または Domain で定義されたインターフェースを実装します。ビジネスロジックに触れずに実装を差し替えられます。
  5. API レイヤーは薄く保ちます — エンドポイントは HTTP からユースケースへ、ユースケースから HTTP レスポンスへと変換します。エンドポイントにビジネスロジックを記述しません。

パターン

プロジェクト構成
src/
  MyApp.Domain/
    Entities/
      Order.cs                    # Entity with behavior
      OrderItem.cs
    Enums/
      OrderStatus.cs
    Exceptions/
      DomainException.cs          # Base domain exception
    Interfaces/
      IOrderRepository.cs         # Only if query needs go beyond DbSet
    Common/
      Entity.cs                   # Base entity with Id
      Result.cs                   # Result pattern type

  MyApp.Application/
    Common/
      Behaviors/
        ValidationBehavior.cs     # Mediator pipeline behavior
      Interfaces/
        IAppDbContext.cs           # DbContext abstraction (preferred over repository)
    Orders/
      Commands/
        CreateOrder/
          CreateOrderCommand.cs
          CreateOrderHandler.cs
          CreateOrderValidator.cs
      Queries/
        GetOrder/
          GetOrderQuery.cs
          GetOrderHandler.cs
          OrderDto.cs

  MyApp.Infrastructure/
    Persistence/
      AppDbContext.cs              # Implements IAppDbContext
      Configurations/
        OrderConfiguration.cs
      Migrations/
    Services/
      EmailSender.cs               # Implements IEmailSender from Application
    DependencyInjection.cs         # AddInfrastructure extension

  MyApp.Api/
    Endpoints/
      OrderEndpoints.cs            # Thin, maps HTTP ↔ use cases
    Program.cs
DbContext の抽象化(リポジトリより推奨)

Application で最小限のインターフェースを定義し、Infrastructure で実装します。

// Application/Common/Interfaces/IAppDbContext.cs
public interface IAppDbContext
{
    DbSet<Order> Orders { get; }
    DbSet<Product> Products { get; }
    Task<int> SaveChangesAsync(CancellationToken ct = default);
}

// Infrastructure/Persistence/AppDbContext.cs
public class AppDbContext(DbContextOptions<AppDbContext> options)
    : DbContext(options), IAppDbContext
{
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<Product> Products => Set<Product>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
    }
}

IRepository ではなく IAppDbContext を使う理由は何でしょうか。EF Core の DbSet はそれ自体がリポジトリです。その上に別の抽象化を加えると、ほとんどの場合、価値を生まずに間接性が増すだけです。

ユースケースハンドラー(コマンド)
// Application/Orders/Commands/CreateOrder/CreateOrderCommand.cs
public record CreateOrderCommand(
    string CustomerId,
    List<OrderItemDto> Items) : IRequest<Result<Guid>>;

public record OrderItemDto(string ProductId, int Quantity, decimal UnitPrice);

// Application/Orders/Commands/CreateOrder/CreateOrderHandler.cs — uses Mediator (source-generated, MIT)
internal sealed class CreateOrderHandler(
    IAppDbContext db,
    TimeProvider clock) : IRequestHandler<CreateOrderCommand, Result<Guid>>
{
    public async ValueTask<Result<Guid>> Handle(CreateOrderCommand request, CancellationToken ct)
    {
        var order = Order.Create(
            request.CustomerId,
            request.Items.Select(i => new OrderItem(i.ProductId, i.Quantity, i.UnitPrice)),
            clock.GetUtcNow());

        db.Orders.Add(order);
        await db.SaveChangesAsync(ct);

        return Result.Success(order.Id);
    }
}

// Application/Orders/Commands/CreateOrder/CreateOrderValidator.cs
public class CreateOrderValidator : AbstractValidator<CreateOrderCommand>
{
    public CreateOrderValidator()
    {
        RuleFor(x => x.CustomerId).NotEmpty();
        RuleFor(x => x.Items).NotEmpty();
        RuleForEach(x => x.Items).ChildRules(item =>
        {
            item.RuleFor(x => x.ProductId).NotEmpty();
            item.RuleFor(x => x.Quantity).GreaterThan(0);
            item.RuleFor(x => x.UnitPrice).GreaterThan(0);
        });
    }
}
ユースケースハンドラー(クエリ)
// Application/Orders/Queries/GetOrder/GetOrderQuery.cs
public record GetOrderQuery(Guid OrderId) : IRequest<Result<OrderDto>>;

public record OrderDto(Guid Id, string CustomerId, decimal Total, string Status, DateTimeOffset CreatedAt);

// Application/Orders/Queries/GetOrder/GetOrderHandler.cs
internal sealed class GetOrderHandler(IAppDbContext db) : IRequestHandler<GetOrderQuery, Result<OrderDto>>
{
    public async ValueTask<Result<OrderDto>> Handle(GetOrderQuery request, CancellationToken ct)
    {
        var order = await db.Orders
            .Where(o => o.Id == request.OrderId)
            .Select(o => new OrderDto(o.Id, o.CustomerId, o.Total, o.Status.ToString(), o.CreatedAt))
            .FirstOrDefaultAsync(ct);

        return order is not null
            ? Result.Success(order)
            : Result.Failure<OrderDto>("Order not found");
    }
}
ビヘイビアを備えたドメインエンティティ
// Domain/Entities/Order.cs
public class Order : Entity
{
    private readonly List<OrderItem> _items = [];

    private Order() { } // EF Core

    public string CustomerId { get; private set; } = null!;
    public OrderStatus Status { get; private set; }
    public decimal Total { get; private set; }
    public DateTimeOffset CreatedAt { get; private set; }
    public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();

    public static Order Create(string customerId, IEnumerable<OrderItem> items, DateTimeOffset now)
    {
        var order = new Order
        {
            Id = Guid.CreateVersion7(),
            CustomerId = customerId,
            Status = OrderStatus.Pending,
            CreatedAt = now
        };

        foreach (var item in items)
            order.AddItem(item);

        return order;
    }

    public void AddItem(OrderItem item)
    {
        _items.Add(item);
        Total = _items.Sum(i => i.Quantity * i.UnitPrice);
    }

    public Result Cancel()
    {
        if (Status is not OrderStatus.Pending)
            return Result.Failure("Only pending orders can be cancelled");

        Status = OrderStatus.Cancelled;
        return Result.Success();
    }
}
薄いエンドポイントの配線(IEndpointGroup による自動検出)

すべてのエンドポイントグループは IEndpointGroup を実装し、app.MapEndpoints() によって自動検出されます。新しいエンドポイントを追加するときに Program.cs を変更する必要はありません。IEndpointGroup インターフェースと EndpointExtensions の設定の詳細については、minimal-api スキルを参照してください。

// Api/Endpoints/OrderEndpoints.cs
public sealed class OrderEndpoints : IEndpointGroup
{
    public void Map(IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/orders").WithTags("Orders");

        group.MapPost("/", CreateOrder)
            .WithName("CreateOrder");

        group.MapGet("/{id:guid}", GetOrder)
            .WithName("GetOrder");

        group.MapGet("/", ListOrders)
            .WithName("ListOrders");
    }

    private static async Task<IResult> CreateOrder(
        CreateOrderCommand command, ISender sender, CancellationToken ct)
    {
        var result = await sender.Send(command, ct);
        return result.IsSuccess
            ? TypedResults.Created($"/api/orders/{result.Value}", result.Value)
            : result.ToProblemDetails();
    }

    private static async Task<IResult> GetOrder(
        Guid id, ISender sender, CancellationToken ct)
    {
        var result = await sender.Send(new GetOrderQuery(id), ct);
        return result.IsSuccess
            ? TypedResults.Ok(result.Value)
            : TypedResults.NotFound();
    }

    private static async Task<IResult> ListOrders(
        [AsParameters] ListOrdersQuery query, ISender sender, CancellationToken ct)
    {
        var result = await sender.Send(query, ct);
        return TypedResults.Ok(result);
    }
}
Infrastructure の DI 登録
// Infrastructure/DependencyInjection.cs
public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services,
        IConfiguration config)
    {
        services.AddDbContext<AppDbContext>(options =>
            options.UseNpgsql(config.GetConnectionString("DefaultConnection")));

        services.AddScoped<IAppDbContext>(sp => sp.GetRequiredService<AppDbContext>());

        return services;
    }
}

アンチパターン

貧血ドメインモデル
// BAD — entity is just a data bag, all logic in handler
public class Order
{
    public Guid Id { get; set; }
    public string CustomerId { get; set; } = null!;
    public decimal Total { get; set; }
    public List<OrderItem> Items { get; set; } = [];
}

// Handler sets everything directly
order.Total = order.Items.Sum(i => i.Quantity * i.UnitPrice);
order.Status = OrderStatus.Pending;

// GOOD — entity encapsulates its own rules (see Domain Entity pattern above)
var order = Order.Create(customerId, items, clock.GetUtcNow());
Domain レイヤーでの DbContext の使用
// BAD — Domain references EF Core
// Domain/Services/OrderService.cs
public class OrderService(AppDbContext db) { } // Domain depends on Infrastructure!

// GOOD — Domain defines interfaces, Infrastructure implements
// Domain/Interfaces/IOrderRepository.cs (only if you need query abstraction beyond DbSet)
// Application/Common/Interfaces/IAppDbContext.cs (preferred)
肥大化したエンドポイント
// BAD — business logic in the endpoint
app.MapPost("/orders", async (CreateOrderRequest req, AppDbContext db) =>
{
    var order = new Order { CustomerId = req.CustomerId };
    foreach (var item in req.Items)
    {
        order.Items.Add(new OrderItem { ProductId = item.ProductId, Quantity = item.Quantity });
    }
    order.Total = order.Items.Sum(i => i.Quantity * i.UnitPrice);
    db.Orders.Add(order);
    await db.SaveChangesAsync();
    return TypedResults.Created($"/orders/{order.Id}", order);
});

// GOOD — endpoint delegates to a use case
app.MapPost("/orders", async (CreateOrderCommand command, ISender sender, CancellationToken ct) =>
{
    var result = await sender.Send(command, ct);
    return result.IsSuccess
        ? TypedResults.Created($"/orders/{result.Value}", result.Value)
        : result.ToProblemDetails();
});
すべてのエンティティにリポジトリを作成する
// BAD — repository per entity duplicates DbSet functionality
public interface IOrderRepository { Task<Order?> GetByIdAsync(Guid id); }
public interface IProductRepository { Task<Product?> GetByIdAsync(Guid id); }
public interface ICustomerRepository { Task<Customer?> GetByIdAsync(Guid id); }

// GOOD — use IAppDbContext with DbSet<T> directly
// Only create a repository interface when you have complex query logic
// that you want to test in isolation or reuse across multiple use cases

判断ガイド

シナリオ推奨事項
VSA より CA を選ぶタイミングドメインが中程度以上に複雑で、システムを長期運用し、チームがレイヤー構成に慣れている場合
Domain レイヤーを追加するタイミングビジネスルールが複数のエンティティグループにまたがる不変条件を含む場合
IAppDbContext とリポジトリの比較IAppDbContext を優先します。複雑で再利用可能なクエリに限り、リポジトリを追加します
CA で Mediator とハンドラーを直接使う場合の比較パイプライン動作(検証、ログ記録)には Mediator、シンプルさを優先する場合はハンドラーを直接使います
Domain イベントを追加するタイミング副作用(通知、監査)をメインフローから切り離す必要がある場合
VSA から CA へ移行するタイミングハンドラーが Common/ に属さない共通のドメインロジックを必要とし始めた場合

コメント