ASP.NET Core Minimal API, .NET 6 ile tanıtıldı ve her sürümde controller tabanlı yaklaşıma daha iyi bir alternatif haline geldi. .NET 8 ile birlikte artık büyük ölçekli uygulamalar için de tercih edilebilir olgunluğa ulaştı.

Neden Minimal API?

  • Daha az overhead: Controller pipeline'ından kaçındığı için request başına maliyet düşük
  • Daha az kod: Attribute, base class, action naming kuralı yok
  • Açık bağımlılıklar: Her endpoint'in neye ihtiyaç duyduğu imzadan okunabilir
  • Test kolaylığı: Lambda olduğu için unit test çok daha basit

Proje Kurulumu

var builder = WebApplication.CreateBuilder(args);

// Servisler
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddValidatorsFromAssemblyContaining<Program>(); // FluentValidation
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new() { Title = "Product API", Version = "v1" });
    c.EnableAnnotations();
});

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

// Endpoint'leri grupla
app.MapProductEndpoints();

app.Run();

MapGroup ile Modüler Endpoint Tanımı

// ProductEndpoints.cs — extension method ile gruplanmış endpoint'ler
public static class ProductEndpoints
{
    public static void MapProductEndpoints(this WebApplication app)
    {
        var group = app
            .MapGroup("/api/v1/products")
            .WithTags("Products")
            .WithOpenApi()
            .AddEndpointFilter<ValidationFilter>();

        group.MapGet("/",    GetAll)   .WithName("GetAllProducts");
        group.MapGet("/{id:int}", GetById) .WithName("GetProductById");
        group.MapPost("/",   Create)   .WithName("CreateProduct");
        group.MapPut("/{id:int}",  Update)   .WithName("UpdateProduct");
        group.MapDelete("/{id:int}", Delete)   .WithName("DeleteProduct");
    }

    static async Task<IResult> GetAll(
        IProductService svc,
        [AsParameters] ProductQuery query) // ?page=1&size=20&category=electronics
    {
        var result = await svc.GetAllAsync(query);
        return Results.Ok(result);
    }

    static async Task<IResult> GetById(int id, IProductService svc)
    {
        var product = await svc.GetByIdAsync(id);
        return product is not null
            ? Results.Ok(product)
            : Results.NotFound(new { Error = $"Ürün bulunamadı: {id}" });
    }

    static async Task<IResult> Create(
        CreateProductRequest request,
        IProductService svc,
        LinkGenerator links,
        HttpContext ctx)
    {
        var product = await svc.CreateAsync(request);
        var uri = links.GetUriByName(ctx, "GetProductById", new { id = product.Id });
        return Results.Created(uri, product);
    }

    static async Task<IResult> Update(int id, UpdateProductRequest request, IProductService svc)
    {
        var updated = await svc.UpdateAsync(id, request);
        return updated ? Results.NoContent() : Results.NotFound();
    }

    static async Task<IResult> Delete(int id, IProductService svc)
    {
        var deleted = await svc.DeleteAsync(id);
        return deleted ? Results.NoContent() : Results.NotFound();
    }
}

Endpoint Filter ile Merkezi Validation

Her endpoint'e ayrı validation kodu yazmak yerine, filter ile bunu merkezi hale getirebilirsiniz:

// Tüm gruba uygulanan validation filter
public class ValidationFilter : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        // FluentValidation: request body'deki ilk IValidator<T>'yi bul ve çalıştır
        foreach (var arg in context.Arguments)
        {
            if (arg is null) continue;

            var validatorType = typeof(IValidator<>).MakeGenericType(arg.GetType());
            var validator = context.HttpContext.RequestServices.GetService(validatorType) as IValidator;
            if (validator is null) continue;

            var ctx = new ValidationContext<object>(arg);
            var result = await validator.ValidateAsync(ctx);

            if (!result.IsValid)
                return Results.ValidationProblem(result.ToDictionary());
        }

        return await next(context);
    }
}

// Validator tanımı
public class CreateProductRequestValidator : AbstractValidator<CreateProductRequest>
{
    public CreateProductRequestValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("Ürün adı zorunludur")
            .MaximumLength(100).WithMessage("Ürün adı 100 karakterden uzun olamaz");

        RuleFor(x => x.Price)
            .GreaterThan(0).WithMessage("Fiyat sıfırdan büyük olmalıdır");

        RuleFor(x => x.Stock)
            .GreaterThanOrEqualTo(0).WithMessage("Stok negatif olamaz");
    }
}

Global Hata Yönetimi

// Problem Details (RFC 7807) uyumlu hata yanıtı
app.UseExceptionHandler(errApp =>
{
    errApp.Run(async ctx =>
    {
        var feature = ctx.Features.Get<IExceptionHandlerFeature>();
        var ex = feature?.Error;

        ctx.Response.ContentType = "application/problem+json";
        ctx.Response.StatusCode = ex switch
        {
            NotFoundException  => 404,
            ValidationException => 400,
            UnauthorizedException => 401,
            _ => 500
        };

        await ctx.Response.WriteAsJsonAsync(new
        {
            type   = $"https://httpstatuses.io/{ctx.Response.StatusCode}",
            title  = ex?.Message ?? "Beklenmedik bir hata oluştu",
            status = ctx.Response.StatusCode,
            traceId = ctx.TraceIdentifier
        });
    });
});

AsParameters ile Query String Binding

// [AsParameters] sayfa/filtre parametrelerini tek record'a toplar
public record ProductQuery(
    [property: FromQuery] int    Page     = 1,
    [property: FromQuery] int    Size     = 20,
    [property: FromQuery] string? Category = null,
    [property: FromQuery] string? Search   = null,
    [property: FromQuery] string  OrderBy  = "name");

// Endpoint imzası temiz kalır
static async Task<IResult> GetAll(IProductService svc, [AsParameters] ProductQuery query)
    => Results.Ok(await svc.GetAllAsync(query));

Test Edilebilirlik

// WebApplicationFactory ile integration test
public class ProductApiTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task CreateProduct_ValidRequest_Returns201()
    {
        var client = factory.CreateClient();
        var request = new CreateProductRequest("Test Ürün", 99.99m, 10, "electronics");

        var response = await client.PostAsJsonAsync("/api/v1/products", request);

        response.StatusCode.Should().Be(HttpStatusCode.Created);
        response.Headers.Location.Should().NotBeNull();
    }

    [Fact]
    public async Task CreateProduct_InvalidPrice_Returns400()
    {
        var client = factory.CreateClient();
        var request = new CreateProductRequest("Test", -1m, 0, "");

        var response = await client.PostAsJsonAsync("/api/v1/products", request);

        response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
    }
}

Minimal API, doğru yapılandırıldığında controller tabanlı yaklaşım kadar organize ve daha performanslı bir servis sunabilir. MapGroup, endpoint filter ve [AsParameters] kombinasyonu büyük projelerde bile kodu yönetilebilir tutar.