
Minimal API'lerin bir imaj sorunu var. Herkesin gördüğü ilk demo, içinde beş lambda bulunan tek bir Program.cs dosyasıdır ve buradan çıkan doğal sonuç, bunların prototipler için olduğudur. Öyle değil. Lambda'lar isteğe bağlıdır ve controller tabanlı bir servise gereken neredeyse her şey mevcuttur: gruplama, filter'lar, typed results, OpenAPI metadata'sı ve .NET 10'dan beri yerleşik doğrulama. Minimal API'lerin size vermediği şey bir yapıdır. Bir tane seçmeniz gerekir.
NovaTech'te .NET 6/8 mikroservis geliştirmesine liderlik ediyor ve yüksek trafikli özellikler için code review standartlarını belirliyorum. Code review'da önemsediğim soru şu: ellinci endpoint'i bulmak, test etmek ve değiştirmek ilki kadar kolay mı? Bu not, oraya ulaşmak için kullandığım yapıyı ve hâlâ .NET 6 ya da 8 üzerinde olan servisler için bir yükseltme yolunu anlatıyor.
Katmanlar değil, özellik klasörleri
Kodu, kullanıcı için ne yaptığına göre düzenleyin. Sınavlarla ilgili her şey tek bir klasörde durur: endpoint'ler, request ve response tipleri, servis, sorguları. "Sınavı nasıl oluşturuyoruz?" sorusunun cevabını arayan yeni bir geliştirici beş klasör yerine tek bir klasör açar.
src/Exams.Api/
Program.cs
Features/
Catalog/
CatalogEndpoints.cs
CatalogQueries.cs
Exams/
ExamEndpoints.cs
CreateExamRequest.cs
ExamService.cs
ExamsFeature.cs // AddExamsFeature(): registers this feature's services
Infrastructure/
ValidationFilter.cs
RedisHealthCheck.cs
Program.cs bir içindekiler tablosuna dönüşür. Uygulama genelini ilgilendiren (cross-cutting) servisleri ve middleware'leri bağlar, ardından her özellik için bir MapXxxEndpoints() çağırır:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails(o => o.CustomizeProblemDetails = ctx =>
ctx.ProblemDetails.Extensions["traceId"] = Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier);
builder.Services.AddAuthentication().AddJwtBearer(); // settings come from configuration
builder.Services.AddAuthorizationBuilder()
.AddPolicy("ExamAuthor", p => p.RequireRole("author"));
builder.Services.AddOutputCache(o =>
o.AddPolicy("Catalog", b => b.Expire(TimeSpan.FromMinutes(5)).Tag("catalog")));
builder.Services.AddRateLimiter(o =>
{
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
o.AddPolicy("per-client", ctx => RateLimitPartition.GetTokenBucketLimiter(
ctx.User.Identity?.Name ?? ctx.Connection.RemoteIpAddress?.ToString() ?? "anonymous",
_ => new TokenBucketRateLimiterOptions
{
TokenLimit = 20,
TokensPerPeriod = 10,
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
QueueLimit = 0,
}));
});
builder.Services.AddHealthChecks()
.AddCheck<RedisHealthCheck>("redis", tags: new[] { "ready" });
builder.Services.AddCatalogFeature();
builder.Services.AddExamsFeature();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.UseOutputCache();
app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = _ => false });
app.MapHealthChecks("/health/ready", new HealthCheckOptions { Predicate = c => c.Tags.Contains("ready") });
app.MapCatalogEndpoints();
app.MapExamEndpoints();
app.Run();
public partial class Program { } // lets WebApplicationFactory<Program> find the entry point
Route group'lar ve typed results
Her özellik, bir route group oluşturan tek bir extension method sunar. Grup, endpoint'lerinin paylaştığı her şeyi taşır: URL ön eki, OpenAPI etiketi, yetkilendirme politikası, hız sınırı. Tek tek endpoint'ler ise yalnızca kendilerine özgü olanı tanımlar. Gruplar iç içe de geçebilir: /api/exams/{id}/sections için oluşturulan bir alt grup, üst grubun ön ekini ve politikalarını devralır; böylece iç içe kaynaklar bunları tekrarlamaz.
public static class ExamEndpoints
{
public static IEndpointRouteBuilder MapExamEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/exams")
.WithTags("Exams")
.RequireAuthorization("ExamAuthor")
.RequireRateLimiting("per-client");
group.MapGet("/{id:int}", GetById);
group.MapPost("/", Create).AddEndpointFilter<ValidationFilter<CreateExamRequest>>();
return app;
}
private static async Task<Results<Ok<ExamDto>, NotFound>> GetById(
int id, IExamService exams, CancellationToken ct) =>
await exams.FindAsync(id, ct) is { } exam ? TypedResults.Ok(exam) : TypedResults.NotFound();
private static async Task<Created<ExamDto>> Create(
CreateExamRequest request, IExamService exams, IOutputCacheStore cache, CancellationToken ct)
{
var exam = await exams.CreateAsync(request, ct);
await cache.EvictByTagAsync("catalog", ct);
return TypedResults.Created($"/api/exams/{exam.Id}", exam);
}
}
Handler'lar lambda değil, isimli statik metotlardır. Stack trace'lerde düzgün görünürler, doğrudan unit test edilebilirler ve dosya bir fihrist gibi okunur. Results<Ok<ExamDto>, NotFound> imzasıyla TypedResults döndürmek iki iş görür: derleyici, tanımlamadığınız bir status code döndürmenizi engeller ve OpenAPI dokümanı, ek attribute'lara gerek kalmadan olası her yanıtı öğrenir.
Doğrulama
.NET 10'da framework, Minimal API parametrelerini kendisi doğrular. builder.Services.AddValidation() çağırın, request tiplerinize DataAnnotations attribute'larını ekleyin; geçersiz istekler, handler'ınız çalışmadan önce doğrulama hatalarını içeren bir 400 yanıtı alır.
.NET 6 ve 8'de bunu kendiniz yapmanız gerekir ve bunun için en temiz yer bir endpoint filter'dır. (Filter'lar .NET 7'de geldi; bu yüzden .NET 6'da kontrol handler'ın en başında yer alır.) Bu generic filter DataAnnotations kullanır; böylece daha sonra AddValidation()'a geçmek, kurallar yerinde kalırken yalnızca filter'ı silmek anlamına gelir:
public sealed class ValidationFilter<T> : IEndpointFilter where T : class
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
{
var arg = ctx.Arguments.OfType<T>().FirstOrDefault();
if (arg is null)
return TypedResults.Problem("Request body is missing.", statusCode: StatusCodes.Status400BadRequest);
var results = new List<ValidationResult>();
if (Validator.TryValidateObject(arg, new ValidationContext(arg), results, validateAllProperties: true))
return await next(ctx);
var errors = results
.SelectMany(r => r.MemberNames.DefaultIfEmpty(""), (r, member) => (member, r.ErrorMessage))
.GroupBy(x => x.member)
.ToDictionary(g => g.Key, g => g.Select(x => x.ErrorMessage ?? "Invalid value.").ToArray());
return TypedResults.ValidationProblem(errors);
}
}
public sealed record CreateExamRequest
{
[Required, StringLength(200)] public string Title { get; init; } = "";
[Range(1, 600)] public int DurationMinutes { get; init; }
}
Bir tuzak: Validator.TryValidateObject kullanıldığında, record CreateExamRequest([Required] string Title) örneğindeki gibi positional bir record parametresine konan attribute'lar property'ye değil constructor parametresine bağlanır; bu yüzden validator onları hiç görmez. Yukarıdaki gibi property kullanın ya da onları [property: Required] ile açıkça hedefleyin. Veritabanı gerektiren doğrulama (bu başlık benzersiz mi?) attribute'lara değil, handler'a ya da servise aittir.
ProblemDetails biçiminde hatalar
AddProblemDetails(), UseExceptionHandler() ve UseStatusCodePages() ile birlikte, yakalanmamış exception'ları ve boş hata yanıtlarını, loglarınızla eşleşen bir traceId içeren problem details yanıtlarına (RFC 9457, RFC 7807'nin halefi) dönüştürür. İstemciler, rate limiter gibi middleware'lerin ürettiği boş hata yanıtları dâhil her yerde tek bir hata formatı alır. Kendi exception'larınızı belirli status code'lara eşlemek için .NET 8, AddExceptionHandler<T>() ile kaydedilen IExceptionHandler arayüzünü ekledi. Canlı ortamda yanıta asla exception mesajı ya da stack trace koymayın; bunları loglarda bulmak için trace id yeterlidir.
Output caching
Output caching (.NET 7 ve sonrası) yanıtların tamamını sunucuda saklar. Yayımlanmış bir sınav kataloğu gibi herkese açık, okuma ağırlıklı endpoint'lere uygundur. İsimli politikalar kuralları tek bir yerde tutar; etiketler ise bir yazma işleminin tam olarak değiştirdiği şeyi önbellekten çıkarmasını sağlar, tıpkı yukarıda Create metodunun EvictByTagAsync("catalog") ile yaptığı gibi.
Ona güvenmeden önce varsayılanları bilin. Yalnızca 200 yanıtı alan GET ve HEAD istekleri önbelleğe alınır; cookie set eden yanıtlar alınmaz, kimliği doğrulanmış istekler de alınmaz. Son kural bir güvenlik önlemidir; bu yüzden bu yapıda katalog ayrı, herkese açık bir gruptur ve içerik yazma (authoring) endpoint'leri hiç önbelleğe alınmaz. Varsayılan depolama bellek içidir ve instance başınadır; birden fazla instance'ın önbelleğe alınmış yanıtları ve önbellekten çıkarma işlemlerini paylaşması gerektiğinde kullanılmak üzere .NET 8 Redis tabanlı bir store ekledi.
Rate limiting
ASP.NET Core'un System.Threading.RateLimiting içindeki limiter'lar üzerine kurulu rate limiting middleware'i (.NET 7 ve sonrası), politika adıyla endpoint'lere ya da gruplara bağlanan fixed window, sliding window, token bucket ve concurrency limiter'ları sunar. Yukarıdaki bölümlenmiş (partitioned) politika her kullanıcıya, anonim çağrılarda ise her IP adresine kendi token bucket'ını verir. Örnekteki sayılar yer tutucudur; gerçek değerleri ölçülmüş trafiğe göre belirleyin.
Bu sınırların her süreçte ayrı tutulduğunu unutmayın. Bir load balancer arkasında dört instance varsa, bir istemci yapılandırılan hızın kabaca dört katını alabilir. Her instance'ı aşırı yükten korumak için bu yeterlidir. Kesin, global bir sınıra ihtiyacınız varsa bunu gateway'de ya da paylaşılan bir store ile uygulayın.
Health check'ler
Anlamları farklı iki endpoint sunun. Liveness, "süreç ayakta mı?" sorusunu cevaplar ve hiçbir bağımlılık kontrolü çalıştırmaz; Predicate = _ => false tam olarak bunu yapar. Readiness, "bu instance trafik almalı mı?" sorusunu cevaplar ve ready etiketli kontrolleri çalıştırır. Veritabanını liveness'ın dışında tutun: SQL Server kötü bir dakika geçirirse, istediğiniz şey instance'ların rotasyondan çıkarılmasıdır, tüm container'ların aynı anda yeniden başlatılması değil.
public sealed class RedisHealthCheck(IConnectionMultiplexer redis) : IHealthCheck
{
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, CancellationToken cancellationToken = default)
{
try
{
var latency = await redis.GetDatabase().PingAsync();
return HealthCheckResult.Healthy($"PING {latency.TotalMilliseconds:F0} ms");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Redis is unreachable", ex);
}
}
}
.NET 6 ve 8'den bir yükseltme yolu
.NET 6'nın desteği zaten sona erdi, .NET 8'in desteği de Kasım 2026'da bitiyor; bu yüzden pek çok servis bu geçişi şimdi yapıyor. Her versiyonun Minimal API'lere eklediği şeyler, kısaca:
- .NET 7: route group'lar, endpoint filter'lar,
TypedResultsveResults<…>, output caching, rate limiting,[AsParameters]. - .NET 8:
IExceptionHandler, antiforgery ile form binding, Minimal API'ler için Native AOT desteği, keyed services. - .NET 9:
AddOpenApi()veMapOpenApi()ile yerleşik OpenAPI dokümanı üretimi;HybridCacheise ayrı bir paket olarak arkasından geldi ve Mart 2025'ten beri kararlı. - .NET 10:
AddValidation()ile yerleşik doğrulama ve OpenAPI 3.1 desteği.
Benim izleyeceğim sıra şu: önce target framework'ü değiştirip Microsoft paketlerini güncelleyin ve atladığınız her versiyonun breaking change notlarını okuyun. Sonra elle yazılmış ön ek yardımcılarını MapGroup ile değiştirin ve ortak sorumlulukları gruplara taşıyın. Results.X kullanımlarını endpoint endpoint TypedResults'a dönüştürün. Son olarak, kuralları DataAnnotations karşılıyorsa doğrulama filter'ını AddValidation() ile değiştirin; servis gerektiren özel doğrulamaları ise olduğu gibi bırakın. Her adımdan sonra entegrasyon testlerini çalıştırın.
WebApplicationFactory ile test
Yükseltmeyi ve her refactoring'i sürprizsiz bir işe çeviren şey de bu entegrasyon testleridir. Microsoft.AspNetCore.Mvc.Testing paketindeki WebApplicationFactory<Program>, gerçek uygulamayı gerçek middleware pipeline'ı, routing, serialization ve hata yönetimiyle birlikte bellek içinde çalıştırır ve tek tek servisleri değiştirmenize izin verir:
public sealed class CatalogApiTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task Unknown_exam_returns_problem_details()
{
var client = factory.WithWebHostBuilder(b => b.ConfigureTestServices(services =>
{
services.RemoveAll<IExamCatalog>();
services.AddSingleton<IExamCatalog, InMemoryExamCatalog>();
})).CreateClient();
var response = await client.GetAsync("/api/catalog/999");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
}
}
Bu tek test routing'i, handler'ı, status code middleware'ini ve ProblemDetails kurulumunu birlikte kontrol eder; yalnızca handler'ı test eden bir unit test bunu yapamaz. Kimlik doğrulama gerektiren endpoint'ler için ConfigureTestServices içinde bir test authentication handler'ı kaydedin. Veri erişimi için bir container içinde çalışan gerçek bir SQL Server (Testcontainers bunu kolaylaştırır), bellek içi bir sahte implementasyonun gizlediği sorgu ve migration sorunlarını yakalar.
Bu parçaların hiçbiri tek başına yeni değil. Kazanç, her sorumluluğa tam olarak tek bir yer vermekten gelir; böylece kodu inceleyen kişi nereye bakacağını her zaman bilir. Böyle bir geçiş planlıyorsanız, bu yaptığım backend işlerinin bir parçası. Bir sonraki not .NET'i bırakıp Python'a geçiyor: bir yığın sınav PDF'ini, döngüde bir LLM ile yapılandırılmış bir soru bankasına dönüştürmek.
Bu not İngilizce ve Farsça olarak da yayımlandı: İngilizce · Farsça.
