Levelwise
فارسی
ASP.NET Core

Minimal API یا Controller؟

در ASP.NET Core دو راه برای نوشتن endpoint داریم. هر دو روی همان Pipeline، همان مسیریابی و همان DI کار می‌کنند. فرق اصلی در شکل کد، امکانات آماده و پشتیبانی از Native AOT است.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۲ دقیقهمثال API محصولات فروشگاهکد C# و .NET 10

نویسنده: bezzad

مشکل: دو راه برای یک کار

تیم ما API محصولات فروشگاه را می‌نویسد. یک نفر می‌گوید «Controller بنویسیم، همیشه همین کار را کرده‌ایم». نفر دیگر می‌گوید «Minimal API جدیدتر و سریع‌تر است». هر دو تا حدی درست می‌گویند. برای انتخاب درست، اول باید بدانیم این دو در کجا یکی هستند و در کجا فرق دارند.

یک endpoint، دو شکل

همان endpoint «یک محصول را با شناسه بده» را با هر دو روش می‌نویسیم.

Controller

[ApiController]
[Route("products")]
public sealed class ProductsController(ShopDb db)
    : ControllerBase
{
    [HttpGet("{id:int}")]
    public async Task<ActionResult<ProductDto>> Get(
        int id, CancellationToken ct)
    {
        var product = await db.Products.FindAsync([id], ct);
        if (product is null) return NotFound();
        return ProductDto.From(product);
    }
}

// Program.cs
builder.Services.AddControllers();
app.MapControllers();

Minimal API

app.MapGet("/products/{id:int}", async Task<Results<Ok<ProductDto>, NotFound>> (
    int id, ShopDb db, CancellationToken ct) =>
{
    var product = await db.Products.FindAsync([id], ct);
    if (product is null) return TypedResults.NotFound();
    return TypedResults.Ok(ProductDto.From(product));
});

چه چیزی یکی است؟

  1. همان Pipeline. Middleware ها، احراز هویت و مدیریت خطا برای هر دو یکسان کار می‌کنند.
  2. همان مسیریابی. قالب آدرس و محدودیت‌هایی مثل int یکی هستند.
  3. همان DI. سرویس‌ها از همان کانتینر می‌آیند.
Controllerکلاس، متد و برچسب‌هاMinimal APIیک تابع برای هر آدرسمسیریابی مشترکمیان‌افزارها: خطا، احراز هویت، مجوزتزریق وابستگی و میزبان
هر دو روش فقط شکل نوشتن endpoint هستند. زیر آن‌ها، موتور یکی است.

Minimal API چطور کار می‌کند؟

هر endpoint یک تابع است. فریم‌ورک به پارامترهای تابع نگاه می‌کند و خودش تصمیم می‌گیرد هر کدام را از کجا پر کند:

int id,int page,CreateProduct command,ShopDb db,CancellationToken ctidاز مسیر آدرسpageاز رشته پرس‌وجوcommandاز بدنه درخواستdbاز کانتینر سرویسctاز خود درخواست/products/7?page=2{"name": "..."}AddDbContextRequestAbortedاگر حدس فریم‌ورک کافی نبود، منبع را صریح بگو
فریم‌ورک برای هر پارامتر، منبع را از نوع و اسم آن حدس می‌زند. اگر لازم بود، با یک Attribute صریحش کن.
  1. اسمی که در آدرس هست. مثل id، از مسیر خوانده می‌شود.
  2. نوع ساده دیگر. مثل page، از Query String خوانده می‌شود.
  3. نوع پیچیده. مثل مدل ساخت محصول، از بدنه JSON خوانده می‌شود.
  4. سرویس ثبت‌شده. مثل ShopDb، از DI می‌آید.
  5. انواع خاص. مثل CancellationToken و HttpContext، از خود درخواست می‌آیند.

نوع خروجی دقیق با TypedResults

در کد بالا، نوع خروجی دقیقاً می‌گوید این endpoint یا 200 با محصول برمی‌گرداند یا 404. این نوع دو فایده دارد:

  • مستند OpenAPI خودکار درست می‌شود. لازم نیست جواب‌ها را جدا توضیح بدهی.
  • کامپایلر چک می‌کند. اگر به اشتباه جواب دیگری برگردانی، کد کامپایل نمی‌شود.

مرتب نگه داشتن Minimal API

بزرگ‌ترین ترس از Minimal API یک فایل Program.cs با ۲۰۰۰ خط است. راه حل ساده است: endpoint های هر بخش را در یک کلاس جدا بگذار و با متد MapGroup گروه کن.

Program.cs/productsگروه محصولات/ordersگروه سفارش، همه با مجوزGET /{id}POST /GET /{id}POST /هر گروه در یک فایل جدا
هر گروه یک پیشوند آدرس و تنظیم‌های مشترک دارد، مثل مجوز. endpoint ها این تنظیم‌ها را به ارث می‌برند.
public static class ProductEndpoints
{
    public static RouteGroupBuilder MapProducts(this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/products").WithTags("Products");

        group.MapGet("/{id:int}", GetById);
        group.MapPost("/", Create).RequireAuthorization("CanEditCatalog");

        return group;
    }

    private static async Task<Results<Ok<ProductDto>, NotFound>> GetById(
        int id, ShopDb db, CancellationToken ct)
    {
        var product = await db.Products.FindAsync([id], ct);
        if (product is null) return TypedResults.NotFound();
        return TypedResults.Ok(ProductDto.From(product));
    }

    private static async Task<Created<ProductDto>> Create(
        CreateProduct command, ShopDb db, CancellationToken ct)
    {
        var product = new Product(command.Name, command.Price);
        db.Products.Add(product);
        await db.SaveChangesAsync(ct);
        return TypedResults.Created($"/products/{product.Id}", ProductDto.From(product));
    }
}

// Program.cs
app.MapProducts();

حالا Program.cs فقط یک خط برای محصولات دارد. هر بخش (محصولات، سبد، سفارش) فایل خودش را دارد.

اعتبارسنجی ورودی

در Controller، صفت ApiController باعث می‌شود اگر مدل ورودی قانون‌ها را رعایت نکند، فریم‌ورک خودش جواب 400 بدهد.

در Minimal API تا قبل از .NET 10 این کار آماده نبود. از .NET 10، با ثبت سرویس اعتبارسنجی، همین رفتار برای Minimal API هم وجود دارد:

public sealed record CreateProduct(
    [Required, StringLength(200)] string Name,
    [Range(1.0, 1_000_000_000.0)] decimal Price);

// Program.cs
builder.Services.AddValidation();

اگر نام خالی باشد، جواب 400 با جزئیات خطا (Problem Details) برمی‌گردد و کد endpoint اجرا نمی‌شود.

کد مشترک قبل و بعد از endpoint

هر دو روش راهی برای کد مشترک دارند:

فیلتر در Controller

  • انواع زیادی دارد: فیلتر Action، فیلتر Resource، فیلتر Exception و فیلتر Result.
  • روی یک متد، یک کلاس یا همه برنامه می‌نشیند.
  • امکانات زیاد، ولی یادگیری بیشتر.

فیلتر در Minimal API

  • فقط یک نوع دارد: Endpoint Filter.
  • روی یک endpoint یا یک گروه می‌نشیند.
  • آرگومان‌های endpoint را می‌بیند و می‌تواند جواب را عوض کند.

Native AOT

با Native AOT، برنامه از قبل به کد ماشین تبدیل می‌شود. شروع برنامه خیلی سریع‌تر و حافظه کمتر می‌شود. این برای Serverless و کانتینرهای کوچک مهم است.

  • روش Minimal API از Native AOT پشتیبانی می‌کند. فریم‌ورک کد اتصال پارامترها را در زمان کامپایل می‌سازد.
  • روش Controller (یعنی MVC) از Native AOT پشتیبانی نمی‌کند. چون به Reflection زیادی در زمان اجرا وابسته است.

قانون‌های مهم

  1. هر دو روش در یک برنامه با هم کار می‌کنند. لازم نیست همه کد قدیمی را بازنویسی کنی.
  2. در یک پروژه، یک سبک را اصل بگیر. ترکیب بی‌دلیل، تیم را گیج می‌کند.
  3. با Minimal API، از روز اول گروه‌بندی کن. هر بخش یک فایل و یک MapGroup.
  4. منطق کسب‌وکار را در endpoint ننویس. در هر دو روش، endpoint فقط ورودی را می‌گیرد، سرویس را صدا می‌زند و جواب را برمی‌گرداند.
  5. توکن لغو را بگیر و پاس بده. در هر دو روش، یک پارامتر CancellationToken کافی است.

اشتباه‌های رایج

اشتباه نتیجه راه درست
همه endpoint ها در Program.cs فایل بزرگ و غیرقابل نگهداری می‌شود. یک کلاس و یک MapGroup برای هر بخش.
فکر کردن به اینکه Minimal API فقط برای پروژه کوچک است ابزار مناسب کنار گذاشته می‌شود. با گروه‌بندی، برای پروژه بزرگ هم مناسب است.
انتخاب Controller برای برنامه Native AOT برنامه با AOT ساخته نمی‌شود. روش Minimal API.
منطق کسب‌وکار داخل Controller یا Lambda تست سخت و تکرار کد. منطق در سرویس یا دامنه.
برگرداندن IResult کلی به جای نوع دقیق مستند OpenAPI ناقص می‌شود. خروجی TypedResults با نوع دقیق.

کدام را انتخاب کنیم؟

Minimal API

  • سرویس جدید، به‌خصوص Microservice.
  • نیاز به Native AOT یا شروع سریع.
  • تیمی که کد کم و صریح را ترجیح می‌دهد.

Controller

  • پروژه بزرگ موجود که با Controller نوشته شده است.
  • نیاز به امکانات MVC مثل Model Binder سفارشی یا انواع فیلتر.
  • تیمی که با این ساختار راحت است و دلیلی برای تغییر ندارد.

خلاصه در پنج خط

  1. هر دو روش روی همان Pipeline، مسیریابی و DI کار می‌کنند.
  2. در Minimal API هر endpoint یک تابع است و فریم‌ورک پارامترها را خودش پر می‌کند.
  3. با MapGroup و یک کلاس برای هر بخش، Minimal API مرتب می‌ماند.
  4. از .NET 10، اعتبارسنجی خودکار برای Minimal API هم آماده است.
  5. روش Minimal API از Native AOT پشتیبانی می‌کند. روش Controller نه.