Mert Özen Her Satırda Daha İleri
Backend .NET Core Web API — #10

.NET 6 ile ASP.NET Core Minimal API: MapGet, MapPost ve Controller Karşılaştırması

Mert Özen 4 Kas 2026 8 dk 2 görüntülenme
.NET 6 ile ASP.NET Core Minimal API: MapGet, MapPost ve Controller Karşılaştırması

.NET 6 minimal API'leriyle MapGet, MapPost, MapPut ve MapDelete, parametre binding kuralları, Results yardımcıları, eksik validation'ı elle tamamlamak, endpoint'leri gruplamak ve controller ile ne zaman hangisinin seçileceği.

Sürüm notu: Bu yazıdaki kodlar .NET 6 ile yazılmıştır ve .NET 10'da da değişmeden çalışır.

Önce Tahmin Et

Bir önceki yazıdan tanıdık bir model, bu kez controller'sız bir endpoint'te:

public class Product
{
    public int Id { get; set; }

    [Required]
    [StringLength(100, MinimumLength = 2)]
    public string Name { get; set; } = string.Empty;

    [Range(typeof(decimal), "0.01", "100000", ParseLimitsInInvariantCulture = true)]
    public decimal Price { get; set; }
}

Birazdan yazacağımız MapPost("/api/products", ...) endpoint'ine {"name":"","price":-5} gönderiyorsun. Controller'da bu istek iki hatalı alanla 400 dönerdi. Minimal API'de ne döner?

İlk Minimal API

.NET 6 ile gelen minimal API'ler, bir endpoint'i controller class'ı olmadan, doğrudan Program.cs'te tanımlamanı sağlar. Program.cs'i ve app nesnesini daha önce gördük; şimdi ona endpoint ekliyoruz:

using ProductCatalog.Models;

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

List<Product> products = new()
{
    new Product { Id = 1, Name = "Mechanical Keyboard", Price = 1450m },
    new Product { Id = 2, Name = "Wireless Mouse", Price = 520m }
};

app.MapGet("/api/products", () => products);

app.MapGet("/api/products/{id:int}", (int id) =>
{
    Product? product = products.FirstOrDefault(p => p.Id == id);
    return product is null ? Results.NotFound() : Results.Ok(product);
})
.WithName("GetProductById");

app.Run();

AddControllers yok, MapControllers yok, class yok. MapGet iki şey alır: bir route template ve isteği karşılayacak bir delegate, yani handler. Handler'ın döndürdüğü nesne JSON'a çevrilip 200 ile yazılır. Route template'leri ve {id:int} constraint'i routing yazısında gördüğümüzün aynısı; değişen sadece endpoint'in nerede tanımlandığı.

Controller bir restoranın mutfağıysa minimal API bir food truck: aynı yemeği daha az alanda, daha az personelle çıkarırsın. Ama menü büyüdükçe o dar alanda düzen kurmak sana kalır.

CRUD'u Tamamlayalım

Yazma endpoint'leri de aynı kalıpta:

app.MapPost("/api/products", (Product product) =>
{
    product.Id = products.Max(p => p.Id) + 1;
    products.Add(product);
    return Results.CreatedAtRoute("GetProductById", new { id = product.Id }, product);
});

app.MapPut("/api/products/{id:int}", (int id, Product input) =>
{
    Product? existing = products.FirstOrDefault(p => p.Id == id);
    if (existing is null)
    {
        return Results.NotFound();
    }

    existing.Name = input.Name;
    existing.Price = input.Price;
    return Results.NoContent();
});

app.MapDelete("/api/products/{id:int}", (int id) =>
{
    Product? existing = products.FirstOrDefault(p => p.Id == id);
    if (existing is null)
    {
        return Results.NotFound();
    }

    products.Remove(existing);
    return Results.NoContent();
});

CreatedAtRoute, WithName ile verdiğimiz adı kullanarak Location header'ını üretir; controller'daki CreatedAtAction'ın minimal API karşılığı bu. Bir POST ve olmayan bir ürün için GET atalım:

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: http://localhost:5080/api/products/3

{"id":3,"name":"USB-C Hub","price":890}

HTTP/1.1 404 Not Found
Content-Length: 0

İkinci yanıta dikkat. Controller'da NotFound() bir ProblemDetails body'si üretiyordu, burada body tamamen boş. O dönüşümü yapan [ApiController]'dı ve minimal API'de böyle bir katman yok. Hata body'si istiyorsan Results.Problem()'u kendin çağırırsın.

Parametre Binding

Minimal API'de attribute yazmadığında kurallar şöyle işler: adı route template'inde geçen parametre route'tan gelir, basit tipler query string'den, complex tipler body'den. Container'da kayıtlı bir tip service olarak, HttpContext ve CancellationToken gibi özel tipler de doğrudan framework'ten gelir. Gerekirse controller'dan tanıdığın [FromRoute], [FromQuery], [FromHeader], [FromBody] ve [FromServices] attribute'larını burada da kullanırsın:

app.MapGet("/api/info", (
    [FromHeader(Name = "X-Correlation-Id")] string? correlationId,
    IWebHostEnvironment environment,
    HttpContext context) =>
{
    return Results.Ok(new
    {
        correlationId,
        environment = environment.EnvironmentName,
        path = context.Request.Path.Value
    });
});
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{"correlationId":"abc-123","environment":"Production","path":"/api/info"}

IWebHostEnvironment'ın önünde [FromServices] yok ama değer geldi. Model binding yazısında .NET 6 controller'larında bunun 415 ürettiğini görmüştük; minimal API'ler ise ilk günden beri container'a bakıyor. [FromHeader] için dosyanın başına using Microsoft.AspNetCore.Mvc; eklemen gerekiyor.

.NET 6'da bir eksik de var: controller'daki gibi bir class'ı query string'den doldurmak, yani [FromQuery] ProductQuery query desteklenmiyor. Bu ihtiyaç .NET 7'de [AsParameters] ile karşılandı; ilerleyen sürüm yazılarında göreceğiz.

Results ve Dönüş Değerleri

Handler'ın dönüş değerine göre üç davranış var. IResult dönerse, yani Results.Ok, Results.NotFound, Results.CreatedAtRoute, Results.NoContent, Results.Problem, Results.StatusCode gibi bir şey, yanıtı o nesne yazar. string dönerse text/plain olarak yazılır. Başka bir nesne dönerse JSON'a çevrilip 200 ile yazılır.

app.MapGet("/api/hello", () => "Hello from minimal API");

app.MapGet("/api/products/{id:int}/problem", (int id) =>
    Results.Problem(
        title: "Product not found",
        detail: $"There is no product with id {id}.",
        statusCode: StatusCodes.Status404NotFound));
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

Hello from minimal API

HTTP/1.1 404 Not Found
Content-Type: application/problem+json

{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.4","title":"Product not found","status":404,"detail":"There is no product with id 99."}

Results.Problem aynı şemayı üretiyor, ama controller tarafındaki traceId burada yok. .NET 6'da Results'ın döndürdüğü tiplerin hepsi IResult, bu da endpoint'in hangi tipi döndürdüğünü dışarıdan görünmez kılıyor. .NET 7 ile gelen TypedResults bu sorunu çözüyor; onu sürüm yazılarında ele alacağız.

Validation Yok: Tahminin Cevabı

{"name":"","price":-5} isteğinin gerçek yanıtı:

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: http://localhost:5080/api/products/4

{"id":4,"name":"","price":-5}

Eksi fiyatlı, isimsiz bir ürün listeye girdi. .NET 6'da minimal API'ler Data Annotations'ı hiç çalıştırmaz. Attribute'lar class'ın üstünde durur ama kimse onlara bakmaz. Kontrolü kendin yapman gerekiyor:

using System.ComponentModel.DataAnnotations;

namespace ProductCatalog.Endpoints;

public static class ModelValidator
{
    public static Dictionary<string, string[]> Validate(object model)
    {
        List<ValidationResult> results = new();
        Validator.TryValidateObject(model, new ValidationContext(model), results, validateAllProperties: true);

        return results
            .SelectMany(r => r.MemberNames.Select(member => (Member: member, Message: r.ErrorMessage ?? "Invalid value.")))
            .GroupBy(x => x.Member)
            .ToDictionary(g => g.Key, g => g.Select(x => x.Message).ToArray());
    }
}
app.MapPost("/api/v2/products", (Product product) =>
{
    Dictionary<string, string[]> errors = ModelValidator.Validate(product);
    if (errors.Count > 0)
    {
        return Results.ValidationProblem(errors);
    }

    product.Id = products.Max(p => p.Id) + 1;
    products.Add(product);
    return Results.CreatedAtRoute("GetProductById", new { id = product.Id }, product);
});
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Name":["The Name field is required."],"Price":["The field Price must be between 0.01 and 100000."]}}

Validator.TryValidateObject, MVC'nin arkada kullandığı Data Annotations motorunun ta kendisi. validateAllProperties: true vermezsen sadece [Required] kontrol edilir, bunu unutma. Bir önceki yazıdaki FluentValidation validator'larını da aynı şekilde handler'a parametre olarak alıp çağırabilirsin. Bu kontrolü her endpoint'e tekrar tekrar yazmak yerine araya giren bir katman .NET 7'de endpoint filter'larla, framework'ün kendi validation desteği ise .NET 10'da AddValidation() ile geldi. İkisini de sürüm yazılarında göreceğiz.

Program.cs Şişmesin: Extension Method ile Gruplamak

Her endpoint'i Program.cs'e yazarsan dosya birkaç haftada yüzlerce satıra çıkar. .NET 6'da en temiz yol, ilgili endpoint'leri bir extension method'da toplamak:

namespace ProductCatalog.Endpoints;

public static class CategoryEndpoints
{
    private static readonly List<string> Categories = new() { "peripherals", "books", "office" };

    public static IEndpointRouteBuilder MapCategoryEndpoints(this IEndpointRouteBuilder app)
    {
        app.MapGet("/api/categories", GetAll);
        app.MapGet("/api/categories/{name}", GetByName);
        return app;
    }

    private static IResult GetAll()
    {
        return Results.Ok(Categories);
    }

    private static IResult GetByName(string name)
    {
        string? category = Categories.FirstOrDefault(c => c == name);
        return category is null ? Results.NotFound() : Results.Ok(category);
    }
}

Program.cs'te tek satır kalır: app.MapCategoryEndpoints();. Handler'lar artık lambda değil, isimli static method'lar. Bu sayede stack trace'te ne olduğunu görürsün ve onları ayrı ayrı test edebilirsin. Ortak bir route ön ekini tek yerde tanımlamayı sağlayan MapGroup .NET 7'de geldi.

Controller mı, Minimal API mi?

İki seçenek, iki farklı ağırlık. Controller'lar yapı getirir: [ApiController]'ın otomatik 400'ü ve ProblemDetails'i, filter pipeline'ı, class'tan query binding, herkesin tanıdığı bir klasör düzeni. Bedeli biraz tören ve her istekte oluşturulan bir controller instance'ı. Büyük ekiplerde, çok sayıda endpoint'i olan ve kurallarını framework'e bırakmak isteyen projelerde rahat ettirir.

Minimal API'ler ise az kodla başlar, her şeyi açıkça yazdırır ve biraz daha hafiftir. Küçük servisler, birkaç endpoint'lik mikroservisler ve hızlı prototipler için ideal. Bedeli, .NET 6'da validation, ortak hata formatı ve gruplama gibi konularda düzeni senin kurman. Sonraki sürümler bu açıkları hızla kapattı; .NET 8 ile dotnet new webapi şablonu varsayılan olarak minimal API üretmeye başladı.

Bu serinin kemik yapısında controller'larla devam ediyoruz, çünkü .NET 6 kod tabanlarının büyük çoğunluğu öyle yazılmış ve konuların çoğu controller tarafında daha zengin. Minimal API'nin olgunlaşmasını sürüm yazılarında adım adım takip edeceğiz.

Sık Düşülen Tuzaklar

En şaşırtıcısı, nullable olmayan query parametresi. Şu endpoint'e ?name=mouse ile istek atarsan 400 alırsın:

app.MapGet("/api/products/search", (string? name, decimal? minPrice, int page) =>
{
    IEnumerable<Product> result = products;
    if (!string.IsNullOrWhiteSpace(name))
    {
        result = result.Where(p => p.Name.Contains(name, StringComparison.OrdinalIgnoreCase));
    }

    if (minPrice is not null)
    {
        result = result.Where(p => p.Price >= minPrice);
    }

    return result.Skip((Math.Max(page, 1) - 1) * 10).Take(10);
});

Controller'da eksik int page sessizce 0 olurdu. Minimal API ise nullable olmayan her parametreyi zorunlu sayar. Development ortamında yanıtın body'si sebebi söyler: Required parameter "int page" was not provided from query string. Production'da ise body boştur ve varsayılan log seviyesinde hiçbir iz kalmaz. Akla ilk gelen çözüm int page = 1 yazmak, ama C# 10 lambda'da varsayılan değere izin vermiyor: error CS8936: Feature 'lambda optional parameters' is not available in C# 10.0. Please use language version 12.0 or greater. .NET 6'da çözüm int? page kullanmak ya da handler'ı isimli bir method'a taşıyıp varsayılan değeri orada vermek.

İkincisi, null dönen bir handler. Controller 204 dönüyordu, minimal API ise 200 OK ve body'de null yazar. Bulunamayan kaynak için her zaman Results.NotFound() dön.

Üçüncüsü, bozuk JSON. "price":"abc" gönderildiğinde Production'da body'siz bir 400 döner. İstemci neyin yanlış olduğunu öğrenemez; bu yüzden istemci tarafını yazan ekiple hata formatını baştan konuşmak gerekir.

Dördüncüsü, Program.cs'teki products listesi. Lambda'lar onu yakaladığı için uygulama boyunca yaşar, yani pratikte bir singleton. Controller yazısındaki static liste gibi thread-safe değil; gerçek veri katmanına geçene kadar idare eder.

Küçük Bir Deneme

★☆☆ /api/products/search endpoint'indeki int page'i int? page yap ve ?name=mouse isteğinin artık 200 döndüğünü doğrula.

★★☆ Ürün endpoint'lerini ProductEndpoints adında bir static class'a taşı ve Program.cs'te sadece app.MapProductEndpoints(); satırı kalsın. Listeyi de o class'ın içine al.

★★★ Aynı GET /api/products/{id} endpoint'ini hem bir controller'da hem minimal API'de yaz (farklı route'larla). Olmayan bir id için iki yanıtı karşılaştır, sonra minimal API tarafının da type, title ve status alanlarıyla bir ProblemDetails body'si dönmesini sağla.

Bu yazıda IWebHostEnvironment'ı bir parametre olarak istedik ve framework onu bir yerden bulup getirdi. Bir önceki yazıda IValidator<T>'yi aynı şekilde aldık. Bu nesneler nerede yaşıyor, kim oluşturuyor, ne kadar süre hayatta kalıyor? Bir sonraki yazıda dependency injection'ı, service lifetime'larını ve en sinsi tuzaklarından biri olan captive dependency'yi konuşacağız.