minimal-apis

star 0

Best practices for ASP.NET Core Minimal APIs. Include Route Groups, Dependency Injection, TypedResults, and validation.

andychang0121 By andychang0121 schedule Updated 6/10/2026

name: minimal-apis description: Best practices for ASP.NET Core Minimal APIs. Include Route Groups, Dependency Injection, TypedResults, and validation. description_zh: ASP.NET Core Minimal API 的端點群組、依賴注入與結果回傳的最佳實踐。 invocable: false

ASP.NET Core Minimal APIs 最佳實踐

使用時機

當你需要:

  • 建立輕量化、高效能且低記憶體開銷的 HTTP APIs
  • 取代傳統 MVC Controller,以減少專案中的樣版程式碼(Boilerplate)
  • 搭配 .NET 8/10 現代語法(如 Target-typed new, Primary Constructor)進行快速開發
  • 提供對 Swagger/OpenAPI 自動推導友善的強型別 API 接口

模式一:使用 Route Groups 組織程式碼

避免將所有路由直接堆疊在 Program.cs 中,應使用 IEndpointRouteBuilder 擴充方法進行模組化分割。

❌ 錯誤寫法(Program.cs 臃腫,難以維護)

// Program.cs
WebApplication app = builder.Build();

app.MapGet("/api/products", async (AppDbContext db) => ...);
app.MapGet("/api/products/{id}", async (Guid id, AppDbContext db) => ...);
app.MapPost("/api/products", async (ProductCreateDto dto, AppDbContext db) => ...);
// 隨著 API 增加,這裡會堆疊數百行,破壞 Program.cs 的單一職責

app.Run();

✅ 正確寫法(使用 Extension Method 進行模組化)

/// <summary>商品模組的 Endpoint 註冊擴充類別</summary>
public static class ProductEndpoints
{
    /// <summary>
    /// 註冊商品相關的所有 Minimal API 路由群組。
    /// 使用範例:app.MapProductEndpoints();
    /// </summary>
    public static IEndpointRouteBuilder MapProductEndpoints(this IEndpointRouteBuilder routes)
    {
        // 建立路由群組,並統一設定前綴與標籤
        RouteGroupBuilder group = routes.MapGroup("/api/products")
            .WithTags("Products");

        group.MapGet("/", GetProductsAsync);
        group.MapGet("/{id:guid}", GetProductByIdAsync);
        group.MapPost("/", CreateProductAsync);

        return routes;
    }

    private static async Task<IResult> GetProductsAsync(AppDbContext db, CancellationToken ct)
    {
        IReadOnlyList<ProductDto> products = await db.Products
            .AsNoTracking()
            .Select(p => new ProductDto(p.Id, p.Name, p.Price))
            .ToListAsync(ct);
        return TypedResults.Ok(products);
    }

    private static async Task<IResult> GetProductByIdAsync(Guid id, AppDbContext db, CancellationToken ct)
    {
        Product? product = await db.Products.FindAsync([id], ct);
        return product is null 
            ? TypedResults.NotFound() 
            : TypedResults.Ok(new ProductDto(product.Id, product.Name, product.Price));
    }

    private static async Task<IResult> CreateProductAsync(ProductCreateDto dto, AppDbContext db, CancellationToken ct)
    {
        Product product = new()
        {
            Id = Guid.NewGuid(),
            Name = dto.Name,
            Price = dto.Price
        };
        db.Products.Add(product);
        await db.SaveChangesAsync(ct);
        return TypedResults.CreatedAtRoute(nameof(GetProductByIdAsync), new { id = product.Id }, new ProductDto(product.Id, product.Name, product.Price));
    }
}

模式二:依賴注入與參數綁定

Minimal APIs 具備強大的自動參數綁定功能,不需要顯式標記 [FromServices]

❌ 錯誤寫法(過多無謂的屬性標記)

// ❌ 顯式標記 [FromServices] 與 [FromBody] 顯得冗長且舊式
group.MapPost("/", async ([FromServices] IProductService service, [FromBody] ProductCreateDto dto) => 
{
    return Results.Ok(await service.CreateAsync(dto));
});

✅ 正確寫法(隱式解析服務與預設綁定)

// ✅ 服務(IProductService)會自動從 DI 容器中解析,複雜類型(dto)預設視為 Request Body
group.MapPost("/", async (IProductService service, ProductCreateDto dto, CancellationToken ct) => 
{
    ProductDto result = await service.CreateAsync(dto, ct);
    return TypedResults.Created($"/products/{result.Id}", result);
});

模式三:使用 TypedResults 提供強型別回應

為確保 Swagger/OpenAPI 能自動推導正確的 HTTP 狀態碼與 JSON 結構,必須避免使用 Results,而應回傳 TypedResults 或是強型別的 Results<T1, T2...>

❌ 錯誤寫法(OpenAPI 無法自動推導回應型別,除非手動加 Attribute)

// ❌ 回傳 IResult 與 Results.Ok 遺失了強型別,Swagger 會不知道 200 OK 裡面是什麼 DTO
group.MapGet("/{id:guid}", async (Guid id, IProductService service) =>
{
    ProductDto? product = await service.GetByIdAsync(id);
    return product is null 
        ? Results.NotFound() 
        : Results.Ok(product); 
});

✅ 正確寫法(使用 TypedResults 與 Results<T1, T2>)

// ✅ 使用 Results<Ok<ProductDto>, NotFound> 明確標記可能的回傳型別
// Swagger / OpenAPI 會自動辨識 200 OK (含 ProductDto) 與 404 NotFound,不需額外標註
group.MapGet("/{id:guid}", async Task<Results<Ok<ProductDto>, NotFound>> (Guid id, IProductService service, CancellationToken ct) =>
{
    ProductDto? product = await service.GetByIdAsync(id, ct);
    return product is null 
        ? TypedResults.NotFound() 
        : TypedResults.Ok(product);
});

模式四:使用 Filter 進行輸入驗證 (Validation)

避免在 API 主體中撰寫重複的欄位驗證邏輯,應使用 EndpointFilter 進行切面(AOP)式驗證攔截。

❌ 錯誤寫法(驗證邏輯污染 API 業務核心)

group.MapPost("/", async (ProductCreateDto dto, AppDbContext db) =>
{
    // ❌ 欄位驗證邏輯與資料庫操作混在一起,造成程式碼冗長
    if (string.IsNullOrWhiteSpace(dto.Name))
    {
        return Results.BadRequest("商品名稱為必填");
    }
    if (dto.Price <= 0)
    {
        return Results.BadRequest("價格必須大於零");
    }

    // 業務邏輯...
});

✅ 正確寫法(使用自定義 EndpointFilter 統一處理驗證)

/// <summary>驗證 DTO 的 Endpoint 篩選器</summary>
public class ValidationFilter<T>(IValidator<T> validator) : IEndpointFilter where T : class
{
    /// <summary>
    /// 攔截 Endpoint 執行,取得 DTO 參數進行驗證。
    /// </summary>
    public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
    {
        // 尋找類型符合 T 的輸入參數
        T? argToValidate = context.Arguments.FirstOrDefault(x => x is T) as T;

        if (argToValidate is null)
        {
            return TypedResults.BadRequest("找不到驗證目標 DTO");
        }

        ValidationResult result = await validator.ValidateAsync(argToValidate);
        if (-not result.IsValid)
        {
            // 將 FluentValidation 的錯誤轉換為 RFC 9457 ProblemDetails 格式
            return TypedResults.ValidationProblem(result.ToDictionary());
        }

        return await next(context);
    }
}

// 註冊時直接掛載 Filter:
group.MapPost("/", CreateProductAsync)
     .AddEndpointFilter<ValidationFilter<ProductCreateDto>>();

常見陷阱

1. 忘記限制路由參數約束導致 API 匹配衝突

在 Minimal API 中,如果兩個 Endpoint 的路由結構太像,容易發生路由衝突。

// ❌ 若沒有加上約束,當請求 GET /api/products/all 時,"all" 會被當作 Guid 解析而報錯
group.MapGet("/{id}", GetProductByIdAsync); 
group.MapGet("/all", GetAllProductsAsync);

// ✅ 加上 :guid 約束以區隔路由參數
group.MapGet("/{id:guid}", GetProductByIdAsync);
group.MapGet("/all", GetAllProductsAsync);

2. 在 Singleton 服務中解析 Scoped Endpoint Filter 造成生命週期錯置

如果您自定義了 Endpoint Filter,且該 Filter 依賴了 Scoped 服務(如 DbContext),請勿將 Filter 宣告為 Singleton 註冊,否則會發生 Captive Dependency 錯誤。


最佳實踐摘要

使用情境 建議
組織多個 Endpoints ✅ 使用 Extension Method 搭配 MapGroup 模組化
參數依賴注入 ✅ 直接寫在參數列中,由 DI 自動隱式解析
API 回應型別 ✅ 使用 TypedResultsResults<T1, T2> 確保 OpenAPI 自動推導
輸入欄位驗證 ✅ 撰寫自定義 IEndpointFilter 配合 FluentValidation
路由防衝突 ✅ 為路由變數加上明確約束(如 /{id:guid}/{name:alpha}
Install via CLI
npx skills add https://github.com/andychang0121/dotnet-skills --skill minimal-apis
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator
andychang0121
andychang0121 Explore all skills →