minimal-api について
エンドポイントの構成、型安全なレスポンス、API ドキュメントを扱う、.NET 10 向け Minimal APIs ガイドです。エンドポイントグループの自動検出により、Program.cs で個別登録せずに追加できます。
主な機能
- 01エンドポイントグループの自動検出
- 02TypedResults による型安全な応答
- 03パラメーターのバインドパターン
- 04エンドポイントフィルター
- 05組み込み OpenAPI 設定
- 06レート制限と出力キャッシュ
活用シーン
- 01新しい HTTP API の構築
- 02機能ごとの API エンドポイント整理
- 03型情報付き OpenAPI ドキュメントの生成
- 04エンドポイントへの検証やレート制限の追加
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ codewithmukesh/ dotnet-claude-kit --skill minimal-apiインストール後、Claude Code で/minimal-apiを入力して読み込まれたか確認します。
詳細
Minimal API(.NET 10)
基本原則
- Minimal API をデフォルトにする — レガシーコードを移行する場合に限り、コントローラーを使用します。Minimal API は軽量で高速であり、どのようなアーキテクチャスタイルとも相性よく組み合わせられます。
- エンドポイントは
MapGroupでグループ化する —Program.csに個別のMapGet/MapPost呼び出しを散在させないでください。関連するエンドポイントをまとめてグループ化します。 - OpenAPI には
TypedResultsを使用する —TypedResults.Ok(value)を使うと、コンパイル時の型安全性と正確な OpenAPI ドキュメントを両方得られます。Results.Ok(value)では得られません。 - コメントではなくメタデータを使う — エンドポイントの説明には
.WithName()、.WithTags()、.WithSummary()を使用します。メタデータは OpenAPI 仕様に反映されます。
パターン
エンドポイントグループの自動検出(必須パターン)
各エンドポイントグループは専用のファイルに配置し、IEndpointGroup を実装します。Program.cs で app.MapEndpoints() を一度呼び出すと、すべてのグループが自動的に検出され、登録されます。新しいエンドポイントグループを追加しても、Program.cs を変更する必要はありません。
// Extensions/IEndpointGroup.cs
public interface IEndpointGroup
{
void Map(IEndpointRouteBuilder app);
}
// Extensions/EndpointExtensions.cs
public static class EndpointExtensions
{
public static WebApplication MapEndpoints(this WebApplication app)
{
var groups = typeof(Program).Assembly
.GetTypes()
.Where(t => t.IsAssignableTo(typeof(IEndpointGroup)) && !t.IsInterface && !t.IsAbstract)
.Select(Activator.CreateInstance)
.Cast<IEndpointGroup>();
foreach (var group in groups)
group.Map(app);
return app;
}
}
// Program.cs — this NEVER changes when adding endpoints
var app = builder.Build();
app.MapEndpoints();
app.Run();
// Features/Orders/OrderEndpoints.cs — one file per endpoint group
public sealed class OrderEndpoints : IEndpointGroup
{
public void Map(IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/orders").WithTags("Orders");
group.MapPost("/", CreateOrder)
.WithName("CreateOrder")
.WithSummary("Create a new order")
.Produces<OrderResponse>(StatusCodes.Status201Created)
.ProducesValidationProblem()
.RequireAuthorization();
group.MapGet("/{id:guid}", GetOrder)
.WithName("GetOrder")
.Produces<OrderResponse>()
.ProducesProblem(StatusCodes.Status404NotFound);
group.MapGet("/", ListOrders)
.WithName("ListOrders")
.Produces<PagedList<OrderResponse>>();
}
private static async Task<Results<Created<OrderResponse>, ValidationProblem>> CreateOrder(
CreateOrderRequest request,
ISender sender,
CancellationToken ct)
{
var result = await sender.Send(new CreateOrder.Command(request.CustomerId, request.Items), ct);
return result.IsSuccess
? TypedResults.Created($"/api/orders/{result.Value.Id}", result.Value)
: TypedResults.ValidationProblem(result.Errors);
}
private static async Task<Results<Ok<OrderResponse>, NotFound>> GetOrder(
Guid id,
ISender sender,
CancellationToken ct)
{
var result = await sender.Send(new GetOrder.Query(id), ct);
return result.IsSuccess
? TypedResults.Ok(result.Value)
: TypedResults.NotFound();
}
private static async Task<Ok<PagedList<OrderResponse>>> ListOrders(
[AsParameters] ListOrdersQuery query,
ISender sender,
CancellationToken ct)
{
var result = await sender.Send(query, ct);
return TypedResults.Ok(result);
}
}
型安全なレスポンスのための TypedResults
TypedResults はコンパイル時の保証と OpenAPI スキーマの自動生成を提供します。
// GOOD — TypedResults with union return type
private static async Task<Results<Ok<Product>, NotFound, ValidationProblem>> GetProduct(
Guid id,
AppDbContext db,
CancellationToken ct)
{
var product = await db.Products.FindAsync([id], ct);
return product is not null
? TypedResults.Ok(product)
: TypedResults.NotFound();
}
パラメーターのバインド
.NET 10 の Minimal API は、ルート、クエリ、ヘッダー、ボディ、DI からパラメーターを自動的にバインドします。
// Route parameters
app.MapGet("/orders/{id:guid}", (Guid id) => ...);
// Query parameters (nullable = optional)
app.MapGet("/orders", (int page, int? pageSize, string? status) => ...);
// Complex query parameters with [AsParameters]
public record ListOrdersQuery(int Page = 1, int PageSize = 20, string? Status = null);
app.MapGet("/orders", ([AsParameters] ListOrdersQuery query) => ...);
// Header binding
app.MapGet("/orders", ([FromHeader(Name = "X-Correlation-Id")] string? correlationId) => ...);
// DI services are auto-resolved (no attribute needed)
app.MapPost("/orders", (CreateOrderRequest request, ISender sender) => ...);
エンドポイントフィルター
フィルターは Minimal API におけるアクションフィルターに相当します。検証、ロギング、冪等性チェックなど、横断的関心事に使用します。
標準の ValidationFilter<TRequest> 実装(FluentValidation を使用し、バリデーターを DI から解決し、登録されていない場合は適切にスキップするもの)は、error-handling スキルにあります。プロジェクトごとに再実装せず、そちらを使用してください。
// Apply the canonical filter (see error-handling skill) to a mutating endpoint
group.MapPost("/", CreateOrder)
.AddEndpointFilter<ValidationFilter<CreateOrderRequest>>();
// Apply a filter to a group (affects all endpoints in the group)
group.AddEndpointFilter<LoggingFilter>();
OpenAPI/Swagger の設定
.NET 10 には OpenAPI サポートが組み込まれています。Swashbuckle の代わりに使用してください。
// Program.cs — service registration only, no endpoint wiring
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.MapEndpoints(); // auto-discovers all IEndpointGroup implementations
// Endpoint metadata enriches the OpenAPI spec
group.MapPost("/", CreateOrder)
.WithName("CreateOrder")
.WithSummary("Create a new order")
.WithDescription("Creates a new order for the specified customer with the given line items.")
.Produces<OrderResponse>(StatusCodes.Status201Created)
.ProducesValidationProblem()
.ProducesProblem(StatusCodes.Status500InternalServerError);
レート制限
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("api", opt =>
{
opt.PermitLimit = 100;
opt.Window = TimeSpan.FromMinutes(1);
});
});
// Apply inside an IEndpointGroup.Map method
var group = app.MapGroup("/api/orders")
.WithTags("Orders")
.RequireRateLimiting("api");
出力キャッシュ
builder.Services.AddOutputCache(options =>
{
options.AddBasePolicy(builder => builder.Expire(TimeSpan.FromMinutes(5)));
options.AddPolicy("ByIdCache", builder => builder
.Expire(TimeSpan.FromMinutes(10))
.SetVaryByRouteValue("id"));
});
group.MapGet("/{id:guid}", GetOrder)
.CacheOutput("ByIdCache");
アンチパターン
エンドポイントを Program.cs に配置しない
// BAD — endpoints scattered in Program.cs
app.MapGet("/orders", async (AppDbContext db) => await db.Orders.ToListAsync());
app.MapGet("/orders/{id}", async (Guid id, AppDbContext db) => await db.Orders.FindAsync(id));
app.MapPost("/orders", async (Order order, AppDbContext db) => { /* ... */ });
app.MapGet("/products", async (AppDbContext db) => await db.Products.ToListAsync());
// ALSO BAD — manual MapGroup calls in Program.cs (grows with every feature)
app.MapGroup("/api/orders").WithTags("Orders").MapOrderEndpoints();
app.MapGroup("/api/products").WithTags("Products").MapProductEndpoints();
app.MapGroup("/api/customers").WithTags("Customers").MapCustomerEndpoints();
// Program.cs grows every time you add a feature...
// GOOD — auto-discovered, Program.cs never changes
app.MapEndpoints(); // discovers all IEndpointGroup implementations
型指定のない結果を使用しない
// BAD — Results.Ok doesn't contribute to OpenAPI schema
private static async Task<IResult> GetOrder(Guid id, AppDbContext db)
{
var order = await db.Orders.FindAsync(id);
return order is not null ? Results.Ok(order) : Results.NotFound();
}
// GOOD — TypedResults with explicit union type
private static async Task<Results<Ok<Order>, NotFound>> GetOrder(Guid id, AppDbContext db)
{
var order = await db.Orders.FindAsync(id);
return order is not null ? TypedResults.Ok(order) : TypedResults.NotFound();
}
ドメインエンティティを直接返さない
// BAD — leaks internal structure, can't evolve independently
app.MapGet("/orders/{id}", async (Guid id, AppDbContext db) =>
await db.Orders.Include(o => o.Items).FirstOrDefaultAsync(o => o.Id == id));
// GOOD — map to a response DTO
app.MapGet("/orders/{id}", async (Guid id, AppDbContext db) =>
{
var order = await db.Orders
.Where(o => o.Id == id)
.Select(o => new OrderResponse(o.Id, o.Total, o.CreatedAt))
.FirstOrDefaultAsync();
return order is not null ? TypedResults.Ok(order) : TypedResults.NotFound();
});
選択ガイド
| シナリオ | 推奨事項 |
|---|---|
| 新しい HTTP API | 機能ごとに IEndpointGroup を作成し、app.MapEndpoints() で自動検出する |
| 既存の MVC プロジェクト | コントローラーを維持し、段階的に移行する |
| OpenAPI ドキュメント | TypedResults + .WithName() + .WithSummary() を使用する |
| リクエストの検証 | FluentValidation を使ったエンドポイントフィルター |
| 認証/認可 | グループまたはエンドポイントに .RequireAuthorization("PolicyName") を設定する |
| レート制限 | AddRateLimiter + .RequireRateLimiting() |
| レスポンスキャッシュ | AddOutputCache + .CacheOutput() |
| 複雑なモデルバインド | レコード型と [AsParameters] を使用する |
コメント
Markdown 対応