clean-architecture について
C# と .NET の開発者向けに、Clean Architecture のレイヤー境界やユースケースの構成、薄い API エンドポイントの設計を解説します。実装パターンに加え、アンチパターンと設計判断の指針も扱います。
主な機能
- 01依存関係の逆転とレイヤー境界
- 02ドメイン層でのビジネスルール定義
- 03ユースケース単位のコマンドとクエリ
- 04インターフェースを介したインフラ実装
- 05エンドポイントグループの自動検出
- 06アンチパターンと設計判断の指針
活用シーン
- 01中規模以上のドメインシステムの構成設計
- 02ビジネスルールをデータベースや HTTP から分離
- 03ユースケース単位のコマンド・クエリ処理の実装
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ codewithmukesh/ dotnet-claude-kit --skill clean-architectureインストール後、Claude Code で/clean-architectureを入力して読み込まれたか確認します。
詳細
クリーンアーキテクチャ
中核となる原則
- 依存性逆転が基盤です — すべての依存関係は内側を向きます。Domain にはプロジェクト参照がありません。Application が参照するのは Domain のみです。Infrastructure は Application と Domain を参照します。Api はすべてを参照しますが、依存するのは抽象化のみです。コンパイラーはプロジェクト参照を通じてこれを強制します。
- ルールは Domain が所有します — ビジネスロジックは、エンティティメソッド、ドメインサービス、または仕様として Domain レイヤーに配置します。Domain レイヤーはデータベース、HTTP、フレームワークについて一切知りません。純粋な C# と .NET のプリミティブのみを扱います。
- ユースケースが作業単位です — 各ユースケース(コマンドまたはクエリ)は、Application レイヤー内の単一のクラスです。ドメインオブジェクトを調整し、抽象化を通じて永続化し、結果を返します。20 個のメソッドを持つ「サービス」クラスは作りません。
- Infrastructure はプラグインです — EF Core、外部 API、メール送信機能、ファイルストレージはすべて Infrastructure に配置し、Application または Domain で定義されたインターフェースを実装します。ビジネスロジックに触れずに実装を差し替えられます。
- 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/ に属さない共通のドメインロジックを必要とし始めた場合 |
コメント
Markdown 対応