dotnet-minimal-api-endpoints について
Minimal API のエンドポイント設計と実装を支援する .NET ガイドです。CRUD、ルートグループ、検証、認可、テストのテンプレートを含み、機能単位で構成する HTTP API 向けです。
主な機能
- 01CRUD エンドポイントとリクエスト DTO のテンプレート
- 02MapGroup によるルート構成
- 03TypedResults による型付きレスポンス
- 04エンドポイントフィルターと認可の例
- 05テストと OpenAPI ドキュメントのパターン
活用シーン
- 01機能ごとの Minimal API エンドポイントの構築
- 02認可と検証を備えたルートグループの構成
- 03OpenAPI メタデータ付きのテスト可能なエンドポイント作成
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ ronnythedev/ dotnet-clean-architecture-skills --skill dotnet-minimal-api-endpointsインストール後、Claude Code で/dotnet-minimal-api-endpointsを入力して読み込まれたか確認します。
詳細
Minimal API エンドポイントジェネレーター
概要
新規プロジェクトに対して Microsoft が推奨する方法です。 Minimal API は、コントローラーよりも定型コードを減らし、HTTP API をシンプルかつ高性能に構築できます。
コントローラーと比較したメリット
- ✅ シンプルな構文 - コードを減らし、生産性を高めます
- ✅ 優れたパフォーマンス - オーバーヘッドを削減します
- ✅ テストが容易 - ハンドラーメソッドをテストできます
- ✅ モダンなアプローチ - 最新の .NET 機能を活用できます
- ✅ 手続きが少ない - コントローラークラスは不要です
クイックリファレンス
| HTTP メソッド | 拡張メソッド | 用途 |
|---|---|---|
MapGet | 単一項目/一覧の読み取り | app.MapGet("/users/{id}", ...) |
MapPost | 作成 | app.MapPost("/users", ...) |
MapPut | 更新(全体) | app.MapPut("/users/{id}", ...) |
MapDelete | 削除 | app.MapDelete("/users/{id}", ...) |
エンドポイントの構造
/API/Endpoints/
├── {Feature}/
│ ├── {Feature}Endpoints.cs
│ └── Request{Action}{Entity}.cs
└── ...
テンプレート:完全な CRUD エンドポイント
// src/{name}.api/Endpoints/{Feature}/{Feature}Endpoints.cs
using {name}.application.{feature}.Create{Entity};
using {name}.application.{feature}.Delete{Entity};
using {name}.application.{feature}.Get{Entity}ById;
using {name}.application.{feature}.Get{Entities};
using {name}.application.{feature}.Update{Entity};
using {name}.infrastructure.authorization;
using MediatR;
using Microsoft.AspNetCore.Http.HttpResults;
namespace {name}.api.Endpoints.{Feature};
/// <summary>
/// Endpoints for {Entity} management
/// </summary>
public static class {Feature}Endpoints
{
/// <summary>
/// Maps all {Entity} endpoints
/// </summary>
public static RouteGroupBuilder Map{Feature}Endpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/{entities}")
.WithTags("{Feature}")
.RequireAuthorization();
group.MapGet("/{id:guid}", GetById)
.WithName("Get{Entity}ById")
.WithSummary("Get {entity} by ID")
.Produces<{Entity}Response>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/", GetAll)
.WithName("GetAll{Entities}")
.WithSummary("Get all {entities}")
.Produces<IReadOnlyList<{Entity}ListResponse>>(StatusCodes.Status200OK);
group.MapPost("/", Create)
.WithName("Create{Entity}")
.WithSummary("Create new {entity}")
.RequireAuthorization(Permissions.{Entities}Write)
.Produces<Guid>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest);
group.MapPut("/{id:guid}", Update)
.WithName("Update{Entity}")
.WithSummary("Update existing {entity}")
.RequireAuthorization(Permissions.{Entities}Write)
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
group.MapDelete("/{id:guid}", Delete)
.WithName("Delete{Entity}")
.WithSummary("Delete {entity}")
.RequireAuthorization(Permissions.{Entities}Write)
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
return group;
}
// ═══════════════════════════════════════════════════════════════
// HANDLER METHODS
// ═══════════════════════════════════════════════════════════════
/// <summary>
/// Gets an {entity} by ID
/// </summary>
private static async Task<Results<Ok<{Entity}Response>, NotFound>> GetById(
Guid id,
ISender sender,
CancellationToken cancellationToken)
{
var query = new Get{Entity}ByIdQuery(id);
var result = await sender.Send(query, cancellationToken);
return result.IsSuccess
? TypedResults.Ok(result.Value)
: TypedResults.NotFound();
}
/// <summary>
/// Gets all {entities}
/// </summary>
private static async Task<Ok<IReadOnlyList<{Entity}ListResponse>>> GetAll(
ISender sender,
CancellationToken cancellationToken)
{
var query = new GetAll{Entities}Query();
var result = await sender.Send(query, cancellationToken);
return TypedResults.Ok(result.Value);
}
/// <summary>
/// Creates a new {entity}
/// </summary>
private static async Task<Results<Created<Guid>, BadRequest<Error>>> Create(
RequestCreate{Entity} request,
ISender sender,
CancellationToken cancellationToken)
{
var command = new Create{Entity}Command(
request.Name,
request.Description);
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? TypedResults.Created($"/api/{entities}/{result.Value}", result.Value)
: TypedResults.BadRequest(result.Error);
}
/// <summary>
/// Updates an existing {entity}
/// </summary>
private static async Task<Results<NoContent, NotFound, BadRequest<Error>>> Update(
Guid id,
RequestUpdate{Entity} request,
ISender sender,
CancellationToken cancellationToken)
{
var command = new Update{Entity}Command(id, request.Name, request.Description);
var result = await sender.Send(command, cancellationToken);
if (result.IsFailure)
{
return result.Error.Code.Contains("NotFound")
? TypedResults.NotFound()
: TypedResults.BadRequest(result.Error);
}
return TypedResults.NoContent();
}
/// <summary>
/// Deletes an {entity}
/// </summary>
private static async Task<Results<NoContent, NotFound>> Delete(
Guid id,
ISender sender,
CancellationToken cancellationToken)
{
var command = new Delete{Entity}Command(id);
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? TypedResults.NoContent()
: TypedResults.NotFound();
}
}
テンプレート:リクエスト DTO
// src/{name}.api/Endpoints/{Feature}/RequestCreate{Entity}.cs
namespace {name}.api.Endpoints.{Feature};
/// <summary>
/// Request to create a new {entity}
/// </summary>
public sealed record RequestCreate{Entity}(
string Name,
string? Description);
/// <summary>
/// Request to update an existing {entity}
/// </summary>
public sealed record RequestUpdate{Entity}(
string Name,
string? Description);
テンプレート:Program.cs の登録
// src/{name}.api/Program.cs
using {name}.api.Endpoints.{Feature};
using {name}.application;
using {name}.infrastructure;
var builder = WebApplication.CreateBuilder(args);
// Add services
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
// Map endpoints
app.Map{Feature}Endpoints();
// Add more endpoint groups here
app.Run();
テンプレート:MapGroup を使った複雑なエンドポイント
// src/{name}.api/Endpoints/{Feature}/{Feature}Endpoints.cs
public static class {Feature}Endpoints
{
public static RouteGroupBuilder Map{Feature}Endpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/{entities}")
.WithTags("{Feature}")
.RequireAuthorization();
// Standard CRUD
group.MapGet("/{id:guid}", GetById);
group.MapGet("/", GetAll);
group.MapPost("/", Create);
group.MapPut("/{id:guid}", Update);
group.MapDelete("/{id:guid}", Delete);
// Custom operations
group.MapPost("/{id:guid}/activate", Activate)
.WithName("Activate{Entity}")
.RequireAuthorization(Permissions.{Entities}Write);
group.MapPost("/{id:guid}/deactivate", Deactivate)
.WithName("Deactivate{Entity}")
.RequireAuthorization(Permissions.{Entities}Write);
// Search
group.MapGet("/search", Search)
.WithName("Search{Entities}")
.AllowAnonymous();
// Related resources
var childrenGroup = group.MapGroup("/{parentId:guid}/children")
.WithTags("{Feature} - Children");
childrenGroup.MapGet("/", GetChildren);
childrenGroup.MapPost("/", AddChild);
childrenGroup.MapDelete("/{childId:guid}", RemoveChild);
return group;
}
private static async Task<Results<Ok, BadRequest<Error>>> Activate(
Guid id,
ISender sender,
CancellationToken cancellationToken)
{
var command = new Activate{Entity}Command(id);
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? TypedResults.Ok()
: TypedResults.BadRequest(result.Error);
}
private static async Task<Ok<IReadOnlyList<{Entity}Response>>> Search(
string? term,
int pageNumber,
int pageSize,
ISender sender,
CancellationToken cancellationToken)
{
var query = new Search{Entities}Query(term, pageNumber, pageSize);
var result = await sender.Send(query, cancellationToken);
return TypedResults.Ok(result.Value);
}
}
テンプレート:エンドポイントフィルター(検証)
// src/{name}.api/Endpoints/Filters/ValidationFilter.cs
using {name}.domain.abstractions;
namespace {name}.api.Endpoints.Filters;
/// <summary>
/// Endpoint filter for validation
/// </summary>
public class ValidationFilter<T> : IEndpointFilter where T : class
{
private readonly IValidator<T> _validator;
public ValidationFilter(IValidator<T> validator)
{
_validator = validator;
}
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var request = context.Arguments.OfType<T>().FirstOrDefault();
if (request is null)
{
return await next(context);
}
var validationResult = await _validator.ValidateAsync(request);
if (!validationResult.IsValid)
{
return TypedResults.BadRequest(new Error(
"Validation.Failed",
string.Join(", ", validationResult.Errors.Select(e => e.ErrorMessage))));
}
return await next(context);
}
}
// Usage in endpoint
group.MapPost("/", Create)
.AddEndpointFilter<ValidationFilter<RequestCreate{Entity}>>();
テンプレート:認可
// Multiple authorization options
// 1. Require authentication for all endpoints in group
var group = routes.MapGroup("/api/{entities}")
.RequireAuthorization();
// 2. Specific permission on endpoint
group.MapPost("/", Create)
.RequireAuthorization(Permissions.{Entities}Write);
// 3. Multiple policies
group.MapDelete("/{id:guid}", Delete)
.RequireAuthorization(Permissions.{Entities}Write, Permissions.Admin);
// 4. Allow anonymous (override group auth)
group.MapGet("/public", GetPublicData)
.AllowAnonymous();
// 5. Roles
group.MapPost("/admin/action", AdminAction)
.RequireAuthorization(policy => policy.RequireRole("Admin", "SuperAdmin"));
テンプレート:MapGroup を使った API バージョニング
// src/{name}.api/Endpoints/{Feature}/{Feature}Endpoints.cs
public static class {Feature}Endpoints
{
public static void Map{Feature}Endpoints(this IEndpointRouteBuilder routes)
{
// Version 1
var v1 = routes.MapGroup("/api/v1/{entities}")
.WithTags("{Feature} V1")
.HasApiVersion(1.0);
v1.MapGet("/{id:guid}", GetByIdV1);
v1.MapPost("/", CreateV1);
// Version 2 with breaking changes
var v2 = routes.MapGroup("/api/v2/{entities}")
.WithTags("{Feature} V2")
.HasApiVersion(2.0);
v2.MapGet("/{id:guid}", GetByIdV2);
v2.MapPost("/", CreateV2);
}
}
TypedResults パターン
型安全でテスト可能なレスポンスには、常に TypedResults を使用してください。
// ✅ CORRECT: TypedResults with union return type
private static async Task<Results<Ok<UserResponse>, NotFound>> GetUser(
Guid id,
ISender sender,
CancellationToken cancellationToken)
{
var result = await sender.Send(new GetUserQuery(id), cancellationToken);
return result.IsSuccess
? TypedResults.Ok(result.Value)
: TypedResults.NotFound();
}
// ❌ WRONG: Non-typed Results
private static async Task<IResult> GetUser(Guid id, ISender sender)
{
var result = await sender.Send(new GetUserQuery(id));
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound();
}
TypedResults のメリット
- ✅ コンパイル時の安全性 - コンパイル時にエラーを検出します
- ✅ 優れたテスト性 - 特定の結果型をアサートできます
- ✅ OpenAPI メタデータ - Swagger ドキュメントを自動生成します
- ✅ IntelliSense のサポート - IDE での操作性が向上します
Minimal API エンドポイントのテスト
// Unit test for endpoint handler
public class {Feature}EndpointsTests
{
[Fact]
public async Task GetById_ExistingId_ReturnsOk()
{
// Arrange
var sender = Substitute.For<ISender>();
var response = new {Entity}Response(Guid.NewGuid(), "Test");
sender.Send(Arg.Any<Get{Entity}ByIdQuery>(), Arg.Any<CancellationToken>())
.Returns(Result.Success(response));
// Act
var result = await {Feature}Endpoints.GetById(
Guid.NewGuid(),
sender,
CancellationToken.None);
// Assert - Type-safe assertion
Assert.IsType<Ok<{Entity}Response>>(result.Result);
}
[Fact]
public async Task GetById_NonExistingId_ReturnsNotFound()
{
// Arrange
var sender = Substitute.For<ISender>();
sender.Send(Arg.Any<Get{Entity}ByIdQuery>(), Arg.Any<CancellationToken>())
.Returns(Result.Failure<{Entity}Response>({Entity}Errors.NotFound));
// Act
var result = await {Feature}Endpoints.GetById(
Guid.NewGuid(),
sender,
CancellationToken.None);
// Assert
Assert.IsType<NotFound>(result.Result);
}
}
OpenAPI/Swagger ドキュメント
group.MapGet("/{id:guid}", GetById)
.WithName("Get{Entity}ById")
.WithSummary("Get {entity} by ID")
.WithDescription("Retrieves a single {entity} by its unique identifier")
.WithOpenApi()
.Produces<{Entity}Response>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status400BadRequest);
エンドポイントの整理
オプション 1:機能ごとの静的クラス(推奨)
/Endpoints/
├── Users/
│ ├── UsersEndpoints.cs
│ └── Requests.cs
├── Products/
│ ├── ProductsEndpoints.cs
│ └── Requests.cs
オプション 2:IEndpointRouteBuilder の拡張メソッド
public static class EndpointExtensions
{
public static IEndpointRouteBuilder MapAllEndpoints(this IEndpointRouteBuilder routes)
{
routes.MapUsersEndpoints();
routes.MapProductsEndpoints();
routes.MapOrdersEndpoints();
return routes;
}
}
// In Program.cs
app.MapAllEndpoints();
重要なルール
- TypedResults を使用する - 常に Results より TypedResults を優先してください
- ハンドラーには静的メソッドを使う - テストが容易になります
- 整理には MapGroup を使う - 関連するエンドポイントをグループ化してください
- XML ドキュメントを記述する - すべてのパブリックメソッドを文書化してください
- 戻り値の型を明示する - Results<T1, T2, ...> のユニオン型を使用してください
- 常に CancellationToken を使う - すべての非同期処理に渡してください
- ルート制約を使う -
{id:guid}、{id:int}などを使用してください - デフォルトで認可を適用する - グループに RequireAuthorization を設定してください
- リンク生成には WithName を使う - CreatedAtRoute 用に名前付きルートを設定してください
- 機能ごとに整理する - HTTP 動詞ごとに整理しないでください
避けるべきアンチパターン
// ❌ WRONG: Lambdas with business logic
app.MapPost("/users", async (CreateUserRequest request, IUserRepository repo) =>
{
if (await repo.ExistsByEmail(request.Email))
return Results.BadRequest("Email exists");
var user = new User { Email = request.Email };
repo.Add(user);
await repo.SaveAsync();
return Results.Created($"/users/{user.Id}", user);
});
// ✅ CORRECT: Handler method calls command via MediatR
app.MapPost("/users", Create);
private static async Task<Results<Created<Guid>, BadRequest<Error>>> Create(
CreateUserRequest request,
ISender sender,
CancellationToken cancellationToken)
{
var command = new CreateUserCommand(request.Email);
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? TypedResults.Created($"/users/{result.Value}", result.Value)
: TypedResults.BadRequest(result.Error);
}
// ❌ WRONG: Returning IResult (not type-safe)
private static async Task<IResult> GetUser(Guid id)
// ✅ CORRECT: Explicit return type with TypedResults
private static async Task<Results<Ok<UserResponse>, NotFound>> GetUser(Guid id)
// ❌ WRONG: Controllers in Minimal API project
public class UsersController : ControllerBase { }
// ✅ CORRECT: Static endpoint classes
public static class UsersEndpoints { }
関連スキル
02-dotnet-cqrs-command-generator- エンドポイント用のコマンドを生成します03-dotnet-cqrs-query-generator- エンドポイント用のクエリを生成します08-dotnet-result-pattern- エンドポイントの結果を処理します12-dotnet-jwt-authentication- 認証を追加します13-dotnet-permission-authorization- 認可を追加します01-dotnet-clean-architecture- 全体的なアーキテクチャ
コントローラーからの移行
コントローラーから移行する場合:
// Before: Controller
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
[HttpGet("{id}")]
public async Task<ActionResult<UserResponse>> GetById(Guid id)
{
// ...
}
}
// After: Minimal API
public static class UsersEndpoints
{
public static RouteGroupBuilder MapUsersEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/users");
group.MapGet("/{id:guid}", GetById);
return group;
}
private static async Task<Results<Ok<UserResponse>, NotFound>> GetById(
Guid id,
ISender sender,
CancellationToken cancellationToken)
{
// ...
}
}
Minimal API は Microsoft が推奨する方法です。コントローラーよりもパフォーマンスに優れ、コードがシンプルで、テストも容易です。
コメント
Markdown 対応