vertical-slice について
機能ごとにコードをまとめ、スライス間の結合を抑える.NET開発者向けのVertical Slice Architectureスキルです。ハンドラーの選択肢、モジュール境界、共通処理の扱いを説明します。
主な機能
- 01機能単位でコードを構成
- 02単純な機能を1ファイルに集約
- 033種類のハンドラーパターンを比較
- 04必要に応じてモジュール境界を追加
- 05共通処理を一元化
活用シーン
- 01CRUD APIの機能構造を設計
- 02拡大するアプリをモジュールに分割
- 03Mediator、Wolverine、素のハンドラーから選択
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ codewithmukesh/ dotnet-claude-kit --skill vertical-sliceインストール後、Claude Code で/vertical-sliceを入力して読み込まれたか確認します。
詳細
Vertical Slice Architecture(VSA)
コア原則
- レイヤーではなく機能ごとに整理する — 各機能は、エンドポイント、ハンドラー、リクエスト/レスポンス型、バリデーションを含む独立した垂直スライスです。Controllers/、Services/、Repositories/ の各フォルダー間を行き来する必要はありません。
- 機能間の結合を最小限に抑える — 機能同士が直接参照し合わないようにします。共有する関心事は
Common/またはShared/ディレクトリに配置します。 - 機能ごとに1ファイルでも問題ありません — シンプルな CRUD エンドポイントを、レイヤーに分散した5つのファイルに分ける必要はありません。まずはすべてを1つのファイルにまとめ、複雑さが増した場合にのみ切り出します。
- ハンドラーを作業単位にする — 各ハンドラーは1つの処理を行います。20個のメソッドを持つ巨大なサービスは作りません。
パターン
機能フォルダーの構成
src/
MyApp.Api/
Features/
Orders/
CreateOrder.cs # Request, Handler, Response, Endpoint — all in one file
GetOrder.cs
ListOrders.cs
CancelOrder.cs
Shared/
OrderMapper.cs # Shared within the Orders feature only
Products/
CreateProduct.cs
GetProduct.cs
Common/
Behaviors/
ValidationBehavior.cs # Cross-cutting Mediator pipeline behavior
Persistence/
AppDbContext.cs
Extensions/
ServiceCollectionExtensions.cs
Program.cs
パターンA:メディエーターハンドラー(デフォルトとして推奨)
ソース生成型メディエーターです。MIT ライセンスで、リフレクションを使用せず、Native AOT に対応しています。パイプライン動作とともに IRequest<T> / IRequestHandler<TRequest, TResponse> を使用します。MediatR とほぼ同じ API ですが、より高速で無料です。パッケージ:Mediator.Abstractions + Mediator.SourceGenerator。
// Features/Orders/CreateOrder.cs
public static class CreateOrder
{
public record Command(string CustomerId, List<OrderItemDto> Items) : IRequest<Result<OrderResponse>>;
public record OrderItemDto(string ProductId, int Quantity);
public record OrderResponse(Guid Id, decimal Total, DateTime CreatedAt);
public class Validator : AbstractValidator<Command>
{
public Validator()
{
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);
});
}
}
internal sealed class Handler(AppDbContext db, TimeProvider clock) : IRequestHandler<Command, Result<OrderResponse>>
{
public async ValueTask<Result<OrderResponse>> Handle(Command request, CancellationToken ct)
{
var order = Order.Create(request.CustomerId, request.Items, clock.GetUtcNow());
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return Result.Success(new OrderResponse(order.Id, order.Total, order.CreatedAt));
}
}
}
// Registration in Program.cs or module DI
builder.Services.AddMediator();
// Features/Orders/OrderEndpoints.cs — auto-discovered via IEndpointGroup
public sealed class OrderEndpoints : IEndpointGroup
{
public void Map(IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/orders").WithTags("Orders");
group.MapPost("/", async (CreateOrder.Command command, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(command, ct);
return result.IsSuccess
? TypedResults.Created($"/api/orders/{result.Value.Id}", result.Value)
: result.ToProblemDetails();
})
.WithName("CreateOrder").Produces<CreateOrder.OrderResponse>(201)
.ProducesValidationProblem()
.AddEndpointFilter<ValidationFilter<CreateOrder.Command>>();
}
}
パターンB:Wolverine ハンドラー
規約ベースで、実装するインターフェイスはありません。Wolverine はメソッドのシグネチャからハンドラーを検出します。
// Features/Orders/CreateOrder.cs
public static class CreateOrder
{
public record Command(string CustomerId, List<OrderItemDto> Items);
public record OrderItemDto(string ProductId, int Quantity);
public record OrderResponse(Guid Id, decimal Total, DateTime CreatedAt);
// Wolverine discovers this by convention (static Handle method)
public static async Task<Result<OrderResponse>> Handle(
Command command,
AppDbContext db,
TimeProvider clock,
CancellationToken ct)
{
var order = Order.Create(command.CustomerId, command.Items, clock.GetUtcNow());
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return Result.Success(new OrderResponse(order.Id, order.Total, order.CreatedAt));
}
}
パターンC:ハンドラークラスを直接使用する(ライブラリなし)
外部依存関係を使わず、ハンドラークラスを直接使用します。小規模なプロジェクトや、完全な制御を求めるチームに適しています。
// Features/Orders/CreateOrder.cs
public static class CreateOrder
{
public record Command(string CustomerId, List<OrderItemDto> Items);
public record OrderItemDto(string ProductId, int Quantity);
public record OrderResponse(Guid Id, decimal Total, DateTime CreatedAt);
internal class Handler(AppDbContext db, TimeProvider clock)
{
public async Task<Result<OrderResponse>> ExecuteAsync(Command command, CancellationToken ct)
{
var order = Order.Create(command.CustomerId, command.Items, clock.GetUtcNow());
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return Result.Success(new OrderResponse(order.Id, order.Total, order.CreatedAt));
}
}
}
// Endpoint wiring — Result maps to HTTP response
group.MapPost("/", async (CreateOrder.Command command, CreateOrder.Handler handler, CancellationToken ct) =>
{
var result = await handler.ExecuteAsync(command, ct);
return result.IsSuccess
? TypedResults.Created($"/api/orders/{result.Value.Id}", result.Value)
: result.ToProblemDetails();
});
モジュール境界の追加(任意)
単一のプロジェクトを超えて成長する大規模なアプリケーションでは、モジュール境界を導入します。各モジュールは独自の機能と DbContext を持つ、独立したクラスライブラリです。
src/
MyApp.Api/ # Host — wires modules together
Program.cs
Modules/
ModuleExtensions.cs # app.MapOrderModule(), app.MapCatalogModule()
MyApp.Orders/ # Module — own features, own DbContext
Features/
CreateOrder.cs
Persistence/
OrdersDbContext.cs
OrdersModule.cs # IServiceCollection + IEndpointRouteBuilder extensions
MyApp.Catalog/ # Module
Features/
CreateProduct.cs
Persistence/
CatalogDbContext.cs
CatalogModule.cs
モジュール間の通信方法:
- 統合イベント(推奨) — 非同期で、Wolverine または MassTransit を介して疎結合にします
- 共有コントラクト — DTO/インターフェイスを含む
MyApp.Contractsプロジェクト(使用は必要最小限にします)
共有する関心事
横断的な関心事は、機能フォルダーの外に配置します。
// Common/Behaviors/ValidationBehavior.cs (Mediator pipeline)
public sealed class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IMessage
{
public async ValueTask<TResponse> Handle(
TRequest request,
MessageHandlerDelegate<TRequest, TResponse> next,
CancellationToken ct)
{
var context = new ValidationContext<TRequest>(request);
var failures = validators
.Select(v => v.Validate(context))
.SelectMany(r => r.Errors)
.Where(f => f is not null)
.ToList();
if (failures.Count > 0)
throw new ValidationException(failures);
return await next(request, ct);
}
}
アンチパターン
スライス内にレイヤー化された抽象化を作らない
// BAD — a feature folder with its own service layer and repository
Features/
Orders/
CreateOrder.cs
IOrderService.cs # unnecessary abstraction
OrderService.cs # unnecessary abstraction
IOrderRepository.cs # unnecessary abstraction
OrderRepository.cs # unnecessary abstraction
// GOOD — handler talks directly to DbContext
Features/
Orders/
CreateOrder.cs # handler uses AppDbContext directly
機能同士を直接相互参照させない
// BAD — CreateOrder directly calls GetProduct handler
var product = await _getProductHandler.Handle(new GetProduct.Query(productId));
// GOOD — query the database directly or use a shared read model
var product = await db.Products.FindAsync(productId, ct);
すべてを1つの巨大な機能ファイルに詰め込まない
// BAD — 500-line file with CRUD + business logic + mapping
public static class Orders
{
// Create, Read, Update, Delete, Cancel, Refund, Export...
}
// GOOD — one file per operation
Features/Orders/CreateOrder.cs
Features/Orders/GetOrder.cs
Features/Orders/CancelOrder.cs
判断ガイド
| シナリオ | 推奨事項 |
|---|---|
| 新規プロジェクト(デフォルト) | パターンA — メディエーター(ソース生成型、MIT、軽量) |
| 1つのライブラリでメディエーターとメッセージングが必要 | パターンB — Wolverine(イベント/キューにも対応) |
| 完全な制御を求め、依存関係を避けたい | パターンC — ハンドラークラスを直接使用 |
| ライセンスがある既存の MediatR コードベース | ライセンスがあれば MediatR を継続して使用し、なければ Mediator に移行します(API はほぼ同じです) |
| モノリスが複雑化している | モジュール境界を追加し、各モジュール内で VSA を維持します |
| シンプルな CRUD 機能 | 1ファイル:リクエスト + ハンドラー + エンドポイント |
| 複雑な機能(サガ、イベント) | 機能フォルダー内に複数のファイルを配置し、引き続き同じ場所にまとめます |
| 機能間でロジックを共有する | Common/ に切り出します。別の機能には配置しません |
コメント
Markdown 対応