.NET の最小限の API: 軽量の HTTP API の構築

· 7分で読める

ASP.NET Core で API をしばらく構築している場合は、コントローラー ベースのアプローチに精通していることでしょう。コントローラー クラスを作成し、属性で修飾し、コンストラクターを通じてサービスを注入し、ルートを接続します。それは機能します、そしてそれはうまく機能します。しかし、時々、「このリクエストを受け取り、何かをし、応答を返す」ということに相当する多くの儀式を書いているように感じることがあります。

Minimal API が解決しようとしている問題はまさにそれです。 .NET 6 で導入された Minimal API を使用すると、定型文をほとんど使用せずに HTTP エンドポイントを定義できます。コントローラーも属性もスタートアップ クラスのジャグリングもありません。すっきりとした機能的なスタイルで、ルートからハンドラーへのマッピングを直接行うだけです。

この投稿では、最初のエンドポイントから大規模なアプリケーションの編成、認証、検証の処理、OpenAPI ドキュメント、パフォーマンスの考慮事項まで、最小限の API について知っておくべきことをすべて説明したいと思います。さあ、始めましょう。

なぜ最小限の API なのか?

本題に入る前に、従来のコントローラーベースのアプローチではなく、最小限の API を選択する 理由 について話しましょう。

コントローラーは、構造化された独自のフレームワークが必要な場合に最適です。これらにより、モデル バインディング、フィルター、コンテンツ ネゴシエーション、および懸念事項の明確な分離がすぐに使用できます。数十人の開発者がいる大規模なエンタープライズ アプリケーションの場合、コントローラーが強制する一貫性は大きな利点となります。

一方、最小限の API は、次のような場合に威力を発揮します。

  • 定型文の削減 — コントローラー クラス、[ApiController] 属性、個別の起動設定はありません。
  • 高速な起動 — 起動時のリフレクションベースの操作が少なくなります。
  • よりシンプルなメンタル モデル — ルートはハンドラーに直接マッピングされます。それでおしまい。
  • マイクロサービス対応 — API に 5 ~ 10 のエンドポイントがある場合、コントローラーを完全にセットアップするのはやりすぎのように感じることがあります。

良いニュースは、これは二者択一の決定ではないということです。同じプロジェクト内でコントローラーと最小限の API を混在させることができます。しかし、最小限のアプローチに慣れると、予想よりも頻繁にそれに手を伸ばすようになるかもしれません。

はじめに

Minimal API を最初から作成してみましょう。 .NET SDK がインストールされている場合は、次のように簡単です。

dotnet new web -n MyMinimalApi
cd MyMinimalApi

これにより、次のような Program.cs が得られます。

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.Run();

それだけです。それは動作する API です。 Startup.cs、コントローラー クラス、ルーティング設定はありません。 dotnet run を実行し、http://localhost:5000 を押すと、「Hello World!」が表示されます。戻る。ここでのシンプルさが重要です。

古典的な todo API を使ってもう少し便利にしてみましょう。

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var todos = new List<Todo>();

app.MapGet("/todos", () => todos);

app.MapGet("/todos/{id:int}", (int id) =>
    todos.FirstOrDefault(t => t.Id == id) is { } todo
        ? Results.Ok(todo)
        : Results.NotFound());

app.MapPost("/todos", (Todo todo) =>
{
    todos.Add(todo);
    return Results.Created($"/todos/{todo.Id}", todo);
});

app.MapDelete("/todos/{id:int}", (int id) =>
{
    var todo = todos.FirstOrDefault(t => t.Id == id);
    if (todo is null) return Results.NotFound();
    todos.Remove(todo);
    return Results.NoContent();
});

app.Run();

record Todo(int Id, string Title, bool IsComplete);

30 行以内に完全な CRUD があります。それがミニマルアプローチの力です。

ルート ハンドラー

ルート ハンドラーは、リクエストがルートと一致した場合に実行される関数です。それらを定義するにはいくつかのオプションがあります。

ラムダ式

最も一般的なアプローチと、ほとんどの例で見られるものは次のとおりです。

app.MapGet("/hello", () => "Hello!");
app.MapGet("/hello/{name}", (string name) => $"Hello, {name}!");

メソッドグループ

より複雑なロジックの場合は、名前付きメソッドを指定できます。

app.MapGet("/products", GetProducts);
app.MapPost("/products", CreateProduct);

static IResult GetProducts(AppDbContext db)
    => Results.Ok(db.Products.ToList());

static IResult CreateProduct(Product product, AppDbContext db)
{
    db.Products.Add(product);
    db.SaveChanges();
    return Results.Created($"/products/{product.Id}", product);
}
```これは、ワンライナー以上のものに対して私が好むアプローチです。これにより、ルート マッピング セクションがクリーンで読みやすくなります。実装の詳細を調べなくても、どのエンドポイントが存在するかを一目で確認できます。

### ローカル関数

ローカル関数を使用することもできます。これは、ハンドラーをルート定義の近くに保持したい場合に便利です。

```csharp
app.MapGet("/health", CheckHealth);

IResult CheckHealth()
{
    // Some health check logic
    return Results.Ok(new { Status = "Healthy", Timestamp = DateTime.UtcNow });
}

パラメータのバインド

Minimal API について私が本当に感謝している点の 1 つは、パラメーター バインディングがいかに直感的であるかということです。フレームワークは、コンテキストと型に基づいて値をどこから取得するかを判断します。

ルートパラメータ

app.MapGet("/users/{id:int}", (int id) => $"User {id}");
app.MapGet("/files/{*path}", (string path) => $"File: {path}"); // catch-all

クエリ文字列パラメータ

app.MapGet("/search", (string? query, int page = 1, int pageSize = 20) =>
    Results.Ok(new { query, page, pageSize }));

Null 許容型はオプションのパラメーターになります。デフォルト値は期待どおりに機能します。

リクエスト本文

app.MapPost("/orders", (Order order) =>
{
    // 'order' is automatically deserialized from the JSON body
    return Results.Created($"/orders/{order.Id}", order);
});

ヘッダーとサービスのバインディング

app.MapGet("/protected", (
    [FromHeader(Name = "X-Api-Key")] string apiKey,
    [FromServices] ILogger<Program> logger) =>
{
    logger.LogInformation("Request with API key: {Key}", apiKey[..4] + "****");
    return Results.Ok("Authorized");
});

HttpContext と HttpRequest

下位レベルのアクセスが必要な場合:

app.MapGet("/info", (HttpContext context) =>
{
    var userAgent = context.Request.Headers.UserAgent.ToString();
    var ip = context.Connection.RemoteIpAddress?.ToString();
    return Results.Ok(new { userAgent, ip });
});

BindAsync によるカスタム バインディング

複合型の場合、静的 BindAsync メソッドを実装できます。

public record PaginationParams(int Page, int PageSize)
{
    public static ValueTask<PaginationParams?> BindAsync(HttpContext context)
    {
        int.TryParse(context.Request.Query["page"], out var page);
        int.TryParse(context.Request.Query["pageSize"], out var pageSize);

        var result = new PaginationParams(
            Page: page > 0 ? page : 1,
            PageSize: pageSize > 0 ? Math.Min(pageSize, 100) : 20
        );

        return ValueTask.FromResult<PaginationParams?>(result);
    }
}

app.MapGet("/items", (PaginationParams pagination, AppDbContext db) =>
{
    var items = db.Items
        .Skip((pagination.Page - 1) * pagination.PageSize)
        .Take(pagination.PageSize)
        .ToList();

    return Results.Ok(items);
});

これは信じられないほど強力です。バインディング ロジックを一度定義すると、それをすべてのエンドポイントで再利用できます。

検証

Minimal API がコントローラーに比べてすぐに使える機能が少ない領域の 1 つは、モデルの検証です。 [Required] または [StringLength] の自動適用はありません。しかし、それに対処するための明確なパターンがあります。

手動検証

最も単純なアプローチ - ハンドラー内で検証するだけです。

app.MapPost("/users", (CreateUserRequest request) =>
{
    if (string.IsNullOrWhiteSpace(request.Email))
        return Results.BadRequest(new { Error = "Email is required" });

    if (request.Name?.Length > 100)
        return Results.BadRequest(new { Error = "Name must be 100 characters or less" });

    // Create user...
    return Results.Created($"/users/{request.Email}", request);
});

検証ライブラリの使用

重要なことについては、FluentValidation または同様のライブラリを使用することをお勧めします。

public class CreateUserValidator : AbstractValidator<CreateUserRequest>
{
    public CreateUserValidator()
    {
        RuleFor(x => x.Email).NotEmpty().EmailAddress();
        RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
        RuleFor(x => x.Age).InclusiveBetween(0, 150);
    }
}

これをエンドポイント フィルターを通じてエンドポイントに接続できます。これにより、次のセクションに進みます。

エンドポイントフィルター

エンドポイント フィルターは、Minimal API の最も優れた機能の 1 つです。これらはミドルウェアと考えてください。ただし、スコープはパイプライン全体ではなく特定のエンドポイントに限定されます。これらは .NET 7 で導入され、横断的な問題に最適です。

基本フィルター

app.MapPost("/todos", (Todo todo) =>
{
    // Handle the request
    return Results.Created($"/todos/{todo.Id}", todo);
})
.AddEndpointFilter(async (context, next) =>
{
    var todo = context.GetArgument<Todo>(0);

    if (string.IsNullOrEmpty(todo.Title))
    {
        return Results.BadRequest(new { Error = "Title is required" });
    }

    return await next(context);
});

FluentValidation を使用した検証フィルター

これが本当に強力になるのは、再利用可能な検証フィルターです。

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 model = context.Arguments
            .OfType<T>()
            .FirstOrDefault();

        if (model is null)
            return Results.BadRequest(new { Error = "Request body is required" });

        var result = await _validator.ValidateAsync(model);

        if (!result.IsValid)
        {
            var errors = result.Errors
                .GroupBy(e => e.PropertyName)
                .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());

            return Results.ValidationProblem(errors);
        }

        return await next(context);
    }
}

// Usage
app.MapPost("/users", (CreateUserRequest request) =>
{
    // Only reached if validation passes
    return Results.Created($"/users/{request.Email}", request);
})
.AddEndpointFilter<ValidationFilter<CreateUserRequest>>();

ロギングフィルター

app.MapGet("/products", (AppDbContext db) => db.Products.ToList())
    .AddEndpointFilter(async (context, next) =>
    {
        var logger = context.HttpContext
            .RequestServices.GetRequiredService<ILogger<Program>>();

        var sw = Stopwatch.StartNew();
        var result = await next(context);
        sw.Stop();

        logger.LogInformation("Endpoint executed in {Elapsed}ms", sw.ElapsedMilliseconds);

        return result;
    });

複数のフィルターをチェーンすることができ、ミドルウェアと同様に追加された順序で実行されます。

OpenAPI / Swagger の統合

優れた API ドキュメントはもはやオプションではありません。ありがたいことに、Minimal API は Microsoft.AspNetCore.OpenApi パッケージを通じて OpenAPI のファーストクラスのサポートを備えています。

基本セットアップ

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();

var app = builder.Build();

app.MapOpenApi();

app.MapGet("/todos", () => new List<Todo>())
    .WithName("GetTodos")
    .WithDescription("Retrieves all todo items")
    .WithTags("Todos");

app.Run();

豊富なエンドポイント メタデータ

エンドポイントに関する詳細情報を提供できます。

app.MapPost("/todos", (Todo todo) =>
{
    return Results.Created($"/todos/{todo.Id}", todo);
})
.WithName("CreateTodo")
.WithDescription("Creates a new todo item")
.WithTags("Todos")
.Accepts<Todo>("application/json")
.Produces<Todo>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.ProducesValidationProblem();

WithOpenApi を使用したカスタマイズ

生成された OpenAPI ドキュメントをきめ細かく制御するには:

app.MapGet("/todos/{id:int}", (int id) => Results.Ok(new Todo(id, "Sample", false)))
    .WithOpenApi(operation =>
    {
        operation.Summary = "Get a specific todo";
        operation.Description = "Retrieves a single todo item by its unique identifier.";
        operation.Parameters[0].Description = "The unique identifier of the todo item";
        return operation;
    });

これにより、コントローラーの Swashbuckle XML コメントで得られるのと同じレベルのドキュメント制御が提供されますが、より明示的な、コードファーストの方法で実現されます。

認証と認可

Minimal API エンドポイントのセキュリティは、ASP.NET Core の残りの部分と同じパターンに従います。適用方法が異なるだけです。

基本セットアップ```csharp

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.Authority = “https://your-identity-provider.com”; options.Audience = “your-api”; });

builder.Services.AddAuthorizationBuilder() .AddPolicy(“AdminOnly”, policy => policy.RequireRole(“Admin”)) .AddPolicy(“PremiumUser”, policy => policy.RequireClaim(“subscription”, “premium”));

var app = builder.Build();

app.UseAuthentication(); app.UseAuthorization();


### エンドポイントへの認可の適用

```csharp
// Require any authenticated user
app.MapGet("/profile", (ClaimsPrincipal user) =>
{
    return Results.Ok(new
    {
        Name = user.FindFirstValue(ClaimTypes.Name),
        Email = user.FindFirstValue(ClaimTypes.Email)
    });
}).RequireAuthorization();

// Require a specific policy
app.MapDelete("/admin/users/{id}", (int id) =>
{
    // Delete user logic
    return Results.NoContent();
}).RequireAuthorization("AdminOnly");

// Allow anonymous access
app.MapGet("/public/health", () => Results.Ok(new { Status = "Healthy" }))
    .AllowAnonymous();

ClaimsPrincipal をハンドラー パラメーターに直接挿入する方法に注目してください。残りはフレームワークが処理します。これは、Minimal API を非常にエレガントに感じさせる小さな点の 1 つです。

大規模な API の整理

最小限の API を使用する部屋の象は組織です。 Program.cs に 50 個のエンドポイントがある場合、混乱が生じます。ここでは、物事を管理しやすくするために私が使用しているパターンを示します。

ルートグループ

ルート グループ (.NET 7 で導入) を使用すると、関連するエンドポイント間で構成を共有できます。

var todos = app.MapGroup("/todos")
    .WithTags("Todos")
    .RequireAuthorization();

todos.MapGet("/", (AppDbContext db) => db.Todos.ToListAsync());
todos.MapGet("/{id:int}", (int id, AppDbContext db) => db.Todos.FindAsync(id));
todos.MapPost("/", (Todo todo, AppDbContext db) =>
{
    db.Todos.Add(todo);
    db.SaveChangesAsync();
    return Results.Created($"/todos/{todo.Id}", todo);
});

グループ内のすべてのエンドポイントは、/todos プレフィックス、Todos タグ、および承認要件を共有します。クリーン。

拡張メソッド

これは本当にスケールするパターンです。エンドポイントの各グループを独自の静的クラスに移動します。

// Endpoints/TodoEndpoints.cs
public static class TodoEndpoints
{
    public static RouteGroupBuilder MapTodoEndpoints(this WebApplication app)
    {
        var group = app.MapGroup("/todos").WithTags("Todos");

        group.MapGet("/", GetAll);
        group.MapGet("/{id:int}", GetById);
        group.MapPost("/", Create);
        group.MapPut("/{id:int}", Update);
        group.MapDelete("/{id:int}", Delete);

        return group;
    }

    private static async Task<IResult> GetAll(AppDbContext db)
        => Results.Ok(await db.Todos.ToListAsync());

    private static async Task<IResult> GetById(int id, AppDbContext db)
        => await db.Todos.FindAsync(id) is { } todo
            ? Results.Ok(todo)
            : Results.NotFound();

    private static async Task<IResult> Create(Todo todo, AppDbContext db)
    {
        db.Todos.Add(todo);
        await db.SaveChangesAsync();
        return Results.Created($"/todos/{todo.Id}", todo);
    }

    private static async Task<IResult> Update(int id, Todo updated, AppDbContext db)
    {
        var todo = await db.Todos.FindAsync(id);
        if (todo is null) return Results.NotFound();

        todo.Title = updated.Title;
        todo.IsComplete = updated.IsComplete;
        await db.SaveChangesAsync();

        return Results.Ok(todo);
    }

    private static async Task<IResult> Delete(int id, AppDbContext db)
    {
        var todo = await db.Todos.FindAsync(id);
        if (todo is null) return Results.NotFound();

        db.Todos.Remove(todo);
        await db.SaveChangesAsync();

        return Results.NoContent();
    }
}

// Program.cs — stays clean
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapTodoEndpoints();
app.MapUserEndpoints();
app.MapOrderEndpoints();

app.Run();

Program.cs は API の目次になります。各エンドポイント グループは独自のファイル内に存在します。これは、実稼働アプリケーションに推奨するアプローチです。

カーター図書館

さらに構造を強化したい場合は、Carter ライブラリがモジュールベースのアプローチを提供します。

public class TodoModule : ICarterModule
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        app.MapGet("/todos", async (AppDbContext db) =>
            await db.Todos.ToListAsync());

        app.MapPost("/todos", async (Todo todo, AppDbContext db) =>
        {
            db.Todos.Add(todo);
            await db.SaveChangesAsync();
            return Results.Created($"/todos/{todo.Id}", todo);
        });
    }
}

Carter はすべてのモジュールを自動的に検出して登録します。これは、生の Minimal API アプローチと完全なコントローラーの間の優れた中間点です。

TypedResults と応答タイプ

.NET 7 以降では、タイプ セーフな応答に Results の代わりに TypedResults を使用できるようになりました。これは小さな変更のように思えるかもしれませんが、OpenAPI のドキュメントとテストのしやすさに大きなメリットをもたらします。

app.MapGet("/todos/{id:int}", async Task<Results<Ok<Todo>, NotFound>> (int id, AppDbContext db) =>
{
    var todo = await db.Todos.FindAsync(id);
    return todo is not null
        ? TypedResults.Ok(todo)
        : TypedResults.NotFound();
});

戻り値の型 Results<Ok<Todo>, NotFound> は、このエンドポイントがどの応答タイプを生成できるかをフレームワーク (および OpenAPI ドキュメント) に明示的に伝えます。基本的なケースに対して推測したり、手動で Produces<>() を呼び出したりする必要はもうありません。

複数の可能な結果の場合:

app.MapPost("/todos",
    async Task<Results<Created<Todo>, ValidationProblem, Conflict>>
    (Todo todo, AppDbContext db) =>
{
    if (string.IsNullOrEmpty(todo.Title))
        return TypedResults.ValidationProblem(
            new Dictionary<string, string[]>
            {
                { "Title", new[] { "Title is required" } }
            });

    if (await db.Todos.AnyAsync(t => t.Title == todo.Title))
        return TypedResults.Conflict();

    db.Todos.Add(todo);
    await db.SaveChangesAsync();

    return TypedResults.Created($"/todos/{todo.Id}", todo);
});

すべての新しいプロジェクトで TypedResults を使用し始めました。コンパイラは、宣言された戻り値の型と実際に返される値の間の不一致を検出するため、実行時の驚くべき事態がクラス全体で排除されます。

パフォーマンスに関する考慮事項

Minimal API のセールス ポイントの 1 つはパフォーマンスであり、Minimal API が高速である「理由」を理解することは価値があります。

起動時のオーバーヘッドの削減。 コントローラーは、エンドポイントの検出、モデルのバインド、フィルターの適用をリフレクションに大きく依存します。最小限の API は、ソース ジェネレーター (.NET 7 以降) を使用して、コンパイル時にバインディング コードを生成します。これは、起動時の作業が減り、リクエストごとのメモリ割り当てが少なくなることを意味します。

MVC パイプラインなし。 コントローラーベースの API は、アクションの選択、モデル バインディング、アクション フィルター、結果の実行など、完全な MVC パイプラインを通過します。最小限の API はこれらすべてをスキップし、ルーティングからハンドラーに直接進みます。

RequestDelegate のコンパイル。 フレームワークは、ラムダ式を最適化された RequestDelegate インスタンスにコンパイルします。結果として得られるコードは、HttpContext を直接操作する場合に手動で記述するコードに非常に似ています。

パフォーマンスを最大化するための実践的なヒントをいくつか紹介します。

// Use AsNoTracking for read-only queries
app.MapGet("/products", async (AppDbContext db) =>
    await db.Products.AsNoTracking().ToListAsync());

// Return results directly — avoid unnecessary allocations
app.MapGet("/count", async (AppDbContext db) =>
    await db.Products.CountAsync());

// Use cancellation tokens for long-running operations
app.MapGet("/report", async (AppDbContext db, CancellationToken ct) =>
    await db.Orders
        .AsNoTracking()
        .GroupBy(o => o.Status)
        .Select(g => new { Status = g.Key, Count = g.Count() })
        .ToListAsync(ct));
```また、コントローラーと Minimal API の間のパフォーマンスのギャップは、.NET のリリースごとに縮小し続けていることにも言及する価値があります。ほとんどのアプリケーションでは、その違いはボトルネックにはなりません。データベース クエリと外部サービス呼び出しがボトルネックになります。ベンチマークではなく、開発者の経験とプロジェクトのニーズに基づいて選択してください。

## 結論

最小限の API は、.NET 6 で導入されて以来、長い道のりを歩んできました。「Hello World」デモ機能として始まった機能は、運用 API の正当な選択肢に成長しました。エンドポイント フィルター、ルート グループ、型指定された結果、および堅牢な OpenAPI サポートにより、適切に構造化された保守可能なサービスを構築するために必要なものがすべて揃っています。

私のおすすめは?新しい API プロジェクト、特にマイクロサービスや焦点を絞った内部 API を開始する場合は、Minimal API を真剣に試してみてください。編成には拡張メソッド パターンを使用し、横断的な問題にはエンドポイント フィルターを利用し、型安全性には `TypedResults` を活用します。

既存のコントローラーベースのプロジェクトの場合、急いで移行する必要はありません。どちらのアプローチもうまく機能し、並べて使用することもできます。ただし、次回小さなサービスや簡単な内部 API を追加する必要がある場合は、コントローラーをスキップして最小限に抑えてください。もう戻れないかもしれない。

コーディングを楽しんでください!