dotnet-architecture-patterns について
.NET 開発者向けに、アプリケーション設計のパターンをまとめたスキルです。垂直スライスや Minimal API の構成、検証、キャッシュ、冪等性やトランザクショナルアウトボックスを扱います。
主な機能
- 01垂直スライスによる構成
- 02大規模な Minimal API の整理
- 03リクエストパイプラインとエンドポイントフィルター
- 04検証と Problem Details によるエラー処理
- 05出力・分散・ハイブリッドキャッシュ
- 06冪等性キーとトランザクショナルアウトボックス
活用シーン
- 01大規模な .NET アプリを機能単位で整理
- 02Minimal API のリクエスト処理を構成
- 03再試行される書き込みの重複処理を防止
- 04データベース更新後にドメインイベントを確実に発行
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-architecture-patternsインストール後、Claude Code で/dotnet-architecture-patternsを入力して読み込まれたか確認します。
詳細
dotnet-architecture-patterns
.NET アプリケーション向けの最新アーキテクチャパターンです。大規模な Minimal API の実践的な構成方法、垂直スライスアーキテクチャ、リクエストパイプラインの構成、検証戦略、キャッシュ、エラー処理、冪等性/アウトボックスパターンを扱います。
対象外: DI コンテナーの仕組みと async/await パターンについては、[skill:dotnet-csharp-dependency-injection] と [skill:dotnet-csharp-async-patterns] を参照してください。プロジェクトのスキャフォールディングとファイル構成については、[skill:dotnet-scaffold-project] を参照してください。テスト戦略については、判断の指針は [skill:dotnet-testing-strategy] を、WebApplicationFactory パターンは [skill:dotnet-integration-testing] を参照してください。
相互参照:[skill:dotnet-csharp-dependency-injection] ではサービスの登録とライフタイム、[skill:dotnet-csharp-async-patterns] では非同期パイプラインパターン、[skill:dotnet-csharp-configuration] では構成における Options パターン、[skill:dotnet-solid-principles] ではクラスとインターフェースの設計を支える SOLID/DRY 設計原則を扱っています。
垂直スライスアーキテクチャ
技術レイヤー(コントローラー、サービス、リポジトリ)ではなく、機能(垂直スライス)ごとにコードを構成します。各スライスは、エンドポイント、ハンドラー、検証、データアクセスを所有します。
ディレクトリ構成
Features/
Orders/
CreateOrder/
CreateOrderEndpoint.cs
CreateOrderHandler.cs
CreateOrderRequest.cs
CreateOrderValidator.cs
GetOrder/
GetOrderEndpoint.cs
GetOrderHandler.cs
ListOrders/
ListOrdersEndpoint.cs
ListOrdersHandler.cs
Products/
GetProduct/
...
垂直スライスの利点
- 疎結合:ある機能を変更しても、共有レイヤー全体に影響が波及しません
- 見つけやすさ:機能に関するものがすべて一か所にまとまっています
- 独立したテストのしやすさ:各スライスには明確な入力/出力の契約があります
- チームの拡張性:マージコンフリクトを避けながら、複数の開発者が異なる機能に取り組めます
スライスの構成
各スライスには通常、次のものが含まれます。
- リクエスト/レスポンス DTO -- 契約
- バリデーター -- 入力検証ルール
- ハンドラー -- ビジネスロジック
- エンドポイント -- HTTP マッピング(ルート、メソッド、ステータスコード)
// Features/Orders/CreateOrder/CreateOrderRequest.cs
public sealed record CreateOrderRequest(
string CustomerId,
List<OrderLineRequest> Lines);
public sealed record OrderLineRequest(
string ProductId,
int Quantity);
// Features/Orders/CreateOrder/CreateOrderResponse.cs
public sealed record CreateOrderResponse(
string OrderId,
decimal Total,
DateTimeOffset CreatedAt);
大規模な Minimal API の構成
ルートグループパターン
MapGroup を使って関連するエンドポイントをまとめ、共通のフィルターを適用します。
// Program.cs
var app = builder.Build();
app.MapGroup("/api/orders")
.WithTags("Orders")
.MapOrderEndpoints();
app.MapGroup("/api/products")
.WithTags("Products")
.MapProductEndpoints();
app.Run();
// Features/Orders/OrderEndpoints.cs
public static class OrderEndpoints
{
public static RouteGroupBuilder MapOrderEndpoints(this RouteGroupBuilder group)
{
group.MapPost("/", CreateOrderEndpoint.Handle)
.WithName("CreateOrder")
.Produces<CreateOrderResponse>(StatusCodes.Status201Created)
.ProducesValidationProblem();
group.MapGet("/{id}", GetOrderEndpoint.Handle)
.WithName("GetOrder")
.Produces<OrderResponse>()
.ProducesProblem(StatusCodes.Status404NotFound);
group.MapGet("/", ListOrdersEndpoint.Handle)
.WithName("ListOrders")
.Produces<PagedResult<OrderSummary>>();
return group;
}
}
エンドポイントクラス
各エンドポイントを、単一の Handle メソッドを持つ独自の静的クラスにします。
public static class CreateOrderEndpoint
{
public static async Task<IResult> Handle(
CreateOrderRequest request,
IValidator<CreateOrderRequest> validator,
IOrderService orderService,
CancellationToken ct)
{
var validation = await validator.ValidateAsync(request, ct);
if (!validation.IsValid)
{
return Results.ValidationProblem(validation.ToDictionary());
}
var order = await orderService.CreateAsync(request, ct);
return Results.Created($"/api/orders/{order.OrderId}", order);
}
}
リクエストパイプラインの構成
エンドポイントフィルター(エンドポイント向けミドルウェア)
特定のルートに適用範囲を限定した横断的関心事には、エンドポイントフィルターを使用します。
// Validation filter applied to a route group
public sealed class ValidationFilter<TRequest> : IEndpointFilter
where TRequest : class
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var request = context.Arguments.OfType<TRequest>().FirstOrDefault();
if (request is null)
{
return Results.BadRequest();
}
var validator = context.HttpContext.RequestServices
.GetService<IValidator<TRequest>>();
if (validator is not null)
{
var result = await validator.ValidateAsync(request);
if (!result.IsValid)
{
return Results.ValidationProblem(result.ToDictionary());
}
}
return await next(context);
}
}
// Usage
group.MapPost("/", CreateOrderEndpoint.Handle)
.AddEndpointFilter<ValidationFilter<CreateOrderRequest>>();
パイプラインの順序
標準的なミドルウェアパイプラインでは、順序が重要です。
app.UseExceptionHandler(); // 1. Global error handling
app.UseStatusCodePages(); // 2. Status code formatting
app.UseRateLimiter(); // 3. Rate limiting
app.UseAuthentication(); // 4. Authentication
app.UseAuthorization(); // 5. Authorization
// Endpoint routing happens here
エラー処理
Problem Details(RFC 9457)
一貫性のあるエラーレスポンスには、組み込みの Problem Details サポートを使用します。
// Program.cs
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Extensions["traceId"] =
context.HttpContext.TraceIdentifier;
};
});
app.UseExceptionHandler();
app.UseStatusCodePages();
ビジネスロジック向け Result パターン
想定されるビジネス上の失敗については、例外をスローする代わりに、ハンドラーから結果型を返します。
public abstract record Result<T>
{
public sealed record Success(T Value) : Result<T>;
public sealed record NotFound(string Message) : Result<T>;
public sealed record ValidationFailed(IDictionary<string, string[]> Errors) : Result<T>;
public sealed record Conflict(string Message) : Result<T>;
}
// In the handler
public async Task<Result<Order>> CreateAsync(
CreateOrderRequest request,
CancellationToken ct)
{
var customer = await _db.Customers.FindAsync([request.CustomerId], ct);
if (customer is null)
{
return new Result<Order>.NotFound($"Customer {request.CustomerId} not found");
}
// ... create order
return new Result<Order>.Success(order);
}
// In the endpoint -- map result to HTTP response
return result switch
{
Result<Order>.Success s => Results.Created($"/api/orders/{s.Value.Id}", s.Value),
Result<Order>.NotFound n => Results.Problem(n.Message, statusCode: 404),
Result<Order>.ValidationFailed v => Results.ValidationProblem(v.Errors),
Result<Order>.Conflict c => Results.Problem(c.Message, statusCode: 409),
_ => Results.Problem("Unexpected error", statusCode: 500)
};
検証戦略
複雑さに応じて検証方法を選択します。既定では組み込みの仕組みを優先し、宣言型属性では対応しきれない複雑なビジネスルールには FluentValidation を使用します。フレームワークの詳しいガイダンスについては、[skill:dotnet-input-validation] を参照してください。アーキテクチャにおける検証の責務を定める SOLID 原則については、[skill:dotnet-solid-principles] を参照してください。
組み込み:Data Annotations + MiniValidation(既定)
単純なプロパティレベルの制約には、まず Data Annotations を使用します。MVC モデルバインディングのオーバーヘッドなしで Minimal API の軽量な検証を行うには、MiniValidation を使用します。.NET 10 以降のプロジェクトでは、組み込みの AddValidation() ソースジェネレーターパイプラインを優先してください([skill:dotnet-input-validation] を参照)。
public sealed record CreateProductRequest(
[Required, MaxLength(200)] string Name,
[Range(0.01, double.MaxValue)] decimal Price);
// In endpoint
if (!MiniValidator.TryValidate(request, out var errors))
{
return Results.ValidationProblem(errors);
}
FluentValidation(複雑なルール向けのオプション)
検証ルールがアノテーションでは対応しきれない場合(プロパティ間のルール、条件付きロジック、データベースに依存するチェックなど)は、FluentValidation を使用します。アセンブリスキャンで登録し、エンドポイントフィルターまたは手動検証を通じて適用します。
// Register validators by assembly scanning
builder.Services.AddValidatorsFromAssemblyContaining<Program>(ServiceLifetime.Scoped);
// Validator implementation
public sealed class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderValidator()
{
RuleFor(x => x.CustomerId)
.NotEmpty()
.MaximumLength(50);
RuleFor(x => x.Lines)
.NotEmpty()
.WithMessage("Order must have at least one line item");
RuleForEach(x => x.Lines)
.ChildRules(line =>
{
line.RuleFor(l => l.ProductId).NotEmpty();
line.RuleFor(l => l.Quantity).GreaterThan(0);
});
}
}
キャッシュ戦略
出力キャッシュ(HTTP レスポンスキャッシュ)
builder.Services.AddOutputCache(options =>
{
options.AddBasePolicy(p => p.NoCache());
options.AddPolicy("ProductList", p =>
p.Expire(TimeSpan.FromMinutes(5))
.Tag("products"));
options.AddPolicy("ProductDetail", p =>
p.Expire(TimeSpan.FromMinutes(10))
.SetVaryByRouteValue("id")
.Tag("products"));
});
app.UseOutputCache();
// Apply to endpoints
group.MapGet("/", ListProductsEndpoint.Handle)
.CacheOutput("ProductList");
group.MapGet("/{id}", GetProductEndpoint.Handle)
.CacheOutput("ProductDetail");
// Invalidate by tag
app.MapPost("/api/products", async (
IOutputCacheStore cache,
/* ... */) =>
{
// ... create product
await cache.EvictByTagAsync("products", ct);
return Results.Created(/* ... */);
});
分散キャッシュ(アプリケーションレベル)
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration
.GetConnectionString("Redis");
});
// Usage with IDistributedCache
public sealed class ProductService(
IDistributedCache cache,
AppDbContext db)
{
public async Task<Product?> GetByIdAsync(
string id, CancellationToken ct = default)
{
var cacheKey = $"product:{id}";
var cached = await cache.GetStringAsync(cacheKey, ct);
if (cached is not null)
{
return JsonSerializer.Deserialize<Product>(cached);
}
var product = await db.Products.FindAsync([id], ct);
if (product is not null)
{
await cache.SetStringAsync(
cacheKey,
JsonSerializer.Serialize(product),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
},
ct);
}
return product;
}
}
HybridCache(.NET 9 以降)
HybridCache は、スタンピード保護機能を備えた L1(メモリ内)キャッシュと L2(分散)キャッシュを組み合わせます。
builder.Services.AddHybridCache(options =>
{
options.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10),
LocalCacheExpiration = TimeSpan.FromMinutes(2)
};
});
// Usage -- stampede-safe, two-tier
public sealed class ProductService(HybridCache cache, AppDbContext db)
{
public async Task<Product?> GetByIdAsync(
string id, CancellationToken ct = default)
{
return await cache.GetOrCreateAsync(
$"product:{id}",
async cancel => await db.Products.FindAsync([id], cancel),
cancellationToken: ct);
}
}
冪等性とアウトボックスパターン
冪等性キー
再試行されたリクエストの重複処理を防ぎます。堅牢な冪等性の実装には、次の要件があります。
- エンドポイント間の衝突を防ぐため、ルートとユーザー/テナントごとにキーをスコープ設定する
- 実行前にキーをアトミックに確保し、同時に発生した重複リクエストを拒否する
- 安全な再送のため、(
IResultの参照ではなく)具体的なレスポンスエンベロープを保存する
データベースを使用した冪等性(推奨)
一意制約のあるデータベース行を使用して、アトミックな確保後の実行を実現します。
// Idempotency record stored alongside domain data
public sealed class IdempotencyRecord
{
public required string Key { get; init; } // Scoped key
public required string RequestRoute { get; init; }
public required string? UserId { get; init; }
public int StatusCode { get; set; }
public string? ResponseBody { get; set; } // Serialized JSON
public string? ContentType { get; set; }
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
public bool IsCompleted { get; set; }
}
public sealed class IdempotencyFilter(AppDbContext db) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var httpContext = context.HttpContext;
if (!httpContext.Request.Headers.TryGetValue(
"Idempotency-Key", out var keyValues))
{
return await next(context);
}
var clientKey = keyValues.ToString();
if (string.IsNullOrWhiteSpace(clientKey) || clientKey.Length > 256)
{
return Results.Problem("Invalid Idempotency-Key", statusCode: 400);
}
// Scope key by route + user to prevent cross-endpoint/cross-tenant collisions
var route = $"{httpContext.Request.Method}:{httpContext.Request.Path}";
var userId = httpContext.User.FindFirst("sub")?.Value ?? "anonymous";
var scopedKey = $"{route}:{userId}:{clientKey}";
// Check for existing record (completed = replay, in-progress = reject)
var existing = await db.IdempotencyRecords
.FirstOrDefaultAsync(r => r.Key == scopedKey);
if (existing is { IsCompleted: true })
{
// Replay: value responses get body, non-value responses get status only
if (existing.ResponseBody is not null)
{
return Results.Text(
existing.ResponseBody,
existing.ContentType ?? "application/json",
statusCode: existing.StatusCode);
}
return Results.StatusCode(existing.StatusCode);
}
if (existing is { IsCompleted: false })
{
// Another request claimed this key but hasn't completed yet.
// Reject to prevent duplicate execution.
return Results.Problem(
"Duplicate request in progress", statusCode: 409);
}
// Atomic claim: insert with unique constraint -- concurrent duplicate
// requests will throw DbUpdateException and get a 409 Conflict
{
var record = new IdempotencyRecord
{
Key = scopedKey,
RequestRoute = route,
UserId = userId,
IsCompleted = false
};
db.IdempotencyRecords.Add(record);
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException)
{
return Results.Problem(
"Duplicate request in progress", statusCode: 409);
}
existing = record;
}
// Execute the actual handler
var result = await next(context);
// Always finalize the record -- handles both value and non-value results
// (Results.Ok(obj), Results.NoContent(), Results.Accepted(), etc.)
existing.StatusCode = result is IStatusCodeHttpResult statusResult
? statusResult.StatusCode ?? 200
: 200;
existing.ResponseBody = result is IValueHttpResult valueResult
? JsonSerializer.Serialize(valueResult.Value)
: null; // No body for non-value results (204, 202, etc.)
existing.ContentType = existing.ResponseBody is not null
? "application/json"
: null;
existing.IsCompleted = true;
await db.SaveChangesAsync();
return result;
}
}
主な設計上の選択肢:
- 3つの状態:レコードなし(確保する)、処理中(409を返す)、完了(キャッシュ済みレスポンスを再送する)
Key列の一意制約により、分散ロックなしでアトミックに確保できます- スコープ付きキー(
route:userId:clientKey)により、エンドポイント間およびテナント間の衝突を防ぎます - レスポンスエンベロープには、シリアライズされた本文、ステータスコード、コンテンツタイプを保存します(
IResultの参照ではありません) - 処理中のレコード(確保済みだが未完了)は、同時に発生した重複リクエストに対して409を返します
- 放置された処理中のレコード(例:実行途中でプロセスがクラッシュした場合)に対応するため、古いレコードをクリーンアップするジョブの追加を検討してください
トランザクショナルアウトボックスパターン
データベースへの書き込みとともに、ドメインイベントを少なくとも1回配信することを保証します。
// 1. Store outbox messages in the same transaction as the domain write
public sealed class OutboxMessage
{
public Guid Id { get; init; } = Guid.NewGuid();
public required string EventType { get; init; }
public required string Payload { get; init; }
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset? ProcessedAt { get; set; }
}
// 2. In the handler -- same DbContext transaction
public async Task<Order> CreateOrderAsync(
CreateOrderRequest request,
CancellationToken ct)
{
await using var transaction = await _db.Database
.BeginTransactionAsync(ct);
var order = new Order { /* ... */ };
_db.Orders.Add(order);
_db.OutboxMessages.Add(new OutboxMessage
{
EventType = "OrderCreated",
Payload = JsonSerializer.Serialize(
new OrderCreatedEvent(order.Id, order.Total))
});
await _db.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
return order;
}
// 3. Background processor publishes outbox messages
// See [skill:dotnet-background-services] for the Channels-based
// processor that polls and publishes these messages.
アウトボックスパターンにより、メッセージブローカーが一時的に利用できない場合でも、データベースへの書き込みが成功すれば、イベントが(最終的に)必ず発行されます。
主な原則
- SOLID原則を適用する -- 単一責任(クラスを変更する理由は1つ)、オープン/クローズド(既存コードの変更ではなく、新しい型によって拡張する)、依存関係逆転(モジュール境界では抽象に依存する)。アンチパターン、修正方法、準拠テストについては、[skill:dotnet-solid-principles] を参照してください。
- 継承よりコンポジションを優先する -- 基底クラスではなく、エンドポイントフィルター、ミドルウェア、パイプラインのコンポジションを使用する
- スライスを独立させる -- 機能間を結び付ける共有抽象化を避ける。DRYが適用されるのは知識の重複であり、境界づけられたコンテキスト間のコードの類似性ではありません
- 早期に検証し、速やかに失敗させる -- ビジネスロジックに入る前に、境界(エンドポイントフィルター)で検証する
- あらゆる箇所でProblem Detailsを使用する -- すべてのエンドポイントで一貫したエラー形式を使用する
- 適切なレベルでキャッシュする -- HTTPレスポンスには出力キャッシュ、共有状態には分散キャッシュ、両方にはHybridCacheを使用する
- 書き込みを冪等にする -- クライアントが再試行する可能性のある、冪等でない操作には冪等性キーを使用する
エージェントに関する注意点
- 冪等性では3つの状態を扱う必要があります -- 冪等性の実装では、レコードなし(処理を取得する)、処理中(重複を拒否する)、完了(キャッシュされた応答を再生する)を区別する必要があります。処理中の状態を保護せずに確認してから実行すると、同時に重複実行される可能性があります。
- 冪等性レコードは必ず無条件に完了させてください -- 特定の
IResultサブタイプ(例:IValueHttpResult)を条件に完了させてはいけません。Results.NoContent()やResults.Accepted()などの値を返さない結果は、処理中の状態のまま永久に残ります。 - キャッシュの無効化は明示的に行う必要があります -- 出力キャッシュや分散キャッシュを使用する場合は、書き込み操作の後に必ず無効化(タグまたはキーによる削除)してください。無効化を忘れると、デバッグが難しい古いデータが読み取られます。
- HybridCache のスタンピード保護が機能するのは
GetOrCreateAsyncの場合のみです --HybridCacheで個別の取得後に設定するパターンを使用してはいけません。同じキーへの並行リクエストをライブラリが直列化できるよう、ファクトリーのオーバーロードを使用してください。 - Outbox メッセージはドメインデータと同じトランザクション内で書き込む必要があります -- Outbox メッセージをドメイントランザクションの外で書き込むと、2つの書き込みの間でクラッシュした際にイベントが失われます。両方の書き込みをアトミックに行うため、必ず
BeginTransactionAsyncを使用してください。 - エンドポイントフィルターの順序は重要です -- 先に追加したフィルターが最も外側で実行されます。検証フィルターは冪等性フィルターより先に実行する必要があります。そうしないと、無効なリクエストが冪等性応答としてキャッシュされます。
- 並行リクエスト間で
DbContextを共有してはいけません --DbContextはスレッドセーフではありません。各リクエストで、DI から独自のスコープ付きインスタンスを解決する必要があります。シングルトンまたは静的なDbContextを使用すると、並行実行時にデータが破損します。
ナレッジソース
このスキルのアーキテクチャパターンは、以下の公開コンテンツに基づいています。
- Jimmy Bogard の Vertical Slice Architecture -- 技術レイヤーではなく機能ごとにコードを整理する方法です。Bogard は、各垂直スライスが独自のリクエスト、ハンドラー、検証、データアクセスを持つことで、機能間の結合を減らすことを提唱しています。彼は .NET におけるリクエスト/ハンドラーのディスパッチ用として広く使われている MediatR ライブラリを開発しましたが、MediatR は現在、商用利用向けの商用製品です。垂直スライスのガイダンスを適用する際は、単純なシナリオでサードパーティのメディエーター依存関係を導入するのではなく、上記の組み込みの IEndpointFilter とハンドラーパターンを優先してください。出典:https://www.jimmybogard.com/vertical-slice-architecture/
- Jimmy Bogard の Domain-Driven Design Patterns -- エンティティ設計、値オブジェクト、ドメインイベント、集約境界など、リッチなドメインモデルに関するガイダンスです。重要な知見:永続化に失敗した場合の不整合を避けるため、ドメインイベントは集約の状態変更が永続化された後に(前ではなく)ディスパッチする必要があります。出典:https://www.jimmybogard.com/
- Nick Chapsas の Modern .NET Patterns -- エラー処理のための結果型、構造化された検証パイプライン、本番コードベースにおける最新の C# 機能の導入など、モダンな .NET の実践的なパターンです。出典:https://www.youtube.com/@nickchapsas
注: このスキルは、公開されているガイダンスを適用したものです。記載されている情報源を代表したり、その見解を代弁したりするものではありません。MediatR は商用利用向けの商用製品です。ここでは、組み込みの .NET 機能を使ってパターンを紹介しています。