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

ASP.NET Core Middleware Pipeline: Sıralama, Use, Run, Map ve Kendi Middleware'in

Mert Özen 4 Kas 2026 7 dk 2 görüntülenme
ASP.NET Core Middleware Pipeline: Sıralama, Use, Run, Map ve Kendi Middleware'in

İsteğin middleware halkalarından nasıl geçip response'un nasıl geri döndüğünü görüyoruz: Use, Run ve Map, class olarak middleware yazmak, sıralamanın etkisi, built-in middleware'lerin önerilen sırası ve tipik tuzaklar.

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

Aşağıdaki uygulamaya tek bir istek atıyorsun. Console'a hangi satırlar, hangi sırayla yazılır? Kâğıda yaz, sonra devam et.

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

app.Use(async (context, next) =>
{
    Console.WriteLine("A: before");
    await next();
    Console.WriteLine("A: after");
});

app.Use(async (context, next) =>
{
    Console.WriteLine("B: before");
    await next();
    Console.WriteLine("B: after");
});

app.Run(async context =>
{
    Console.WriteLine("C: handling");
    await context.Response.WriteAsync("Hello from the end of the pipeline");
});

app.Run();

Pipeline: İç İçe Geçmiş Halkalar

Uygulamayı çalıştırıp curl http://localhost:5214/anything gönderdiğimde cevap olarak "Hello from the end of the pipeline" geldi, console'da da şu sıra oluştu:

A: before
B: before
C: handling
B: after
A: after

Tahminin "A, B, C" ile bittiyse yarısını bulmuşsun. Middleware, isteği alan, istersen bir şey yapan ve next() ile sıradakine devreden bir fonksiyondur. await next() satırı geri döndüğünde, ondan sonraki herkes işini bitirmiş olur. Bu yüzden istek A'dan C'ye doğru akar, response ise C'den A'ya doğru geri döner. Her middleware response'a ikinci kez dokunma şansı bulur.

İç içe geçmiş matruşka bebekleri gibi düşün. İstek en dıştaki bebeği açarak içeri girer, en içteki bebekte cevap üretilir, sonra bebekler sırayla kapanarak dışarı çıkılır. ASP.NET Core'daki hemen her şey bu halkalardan biri: HTTPS yönlendirmesi, authentication, exception yakalama, routing ve en sonda controller'ını çalıştıran endpoint.

Use, Run ve Map

Pipeline'ı üç temel method ile kurarsın. Use bir halka ekler; next'i çağırırsa istek devam eder, çağırmazsa orada kesilir. Run terminal bir halkadır: next parametresi bile yoktur, pipeline orada biter. Map ise belirli bir path için pipeline'ı dallara ayırır. ProductCatalog API'nin bu yazıdaki Program.cs'i üçünü de kullanıyor:

using ProductCatalog.Api.Middleware;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/error");
    app.UseHsts();
}

app.UseRequestTiming();

app.Use(async (context, next) =>
{
    if (context.Request.Headers.ContainsKey("X-Maintenance"))
    {
        context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
        await context.Response.WriteAsync("Catalog is under maintenance");
        return;
    }

    await next(context);
});

app.Map("/ping", branch =>
{
    branch.Run(async context => await context.Response.WriteAsync("pong"));
});

app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthorization();

app.MapControllers();
app.Map("/error", () => Results.Problem());

app.Run();

İkinci halka bir bakım modu: istekte X-Maintenance header'ı varsa 503 döner ve next'i çağırmadan çıkar. Buna short-circuit denir; isteğin pipeline'ın geri kalanına hiç uğramaması demektir. Burada next(context) biçimini kullandık. .NET 6 ile gelen bu overload RequestDelegate alır ve önerilen biçimdir; next() biçimi de çalışmaya devam eder.

app.Map("/ping", ...), path /ping ile başlıyorsa isteği kendi küçük pipeline'ına sokar ve ana hatta bir daha dönmez. Dikkat: dosyanın sonundaki app.Map("/error", ...) aynı adı taşısa da başka bir şey. Parametre olarak bir pipeline değil bir fonksiyon aldığı için o bir endpoint tanımıdır; aradaki farkı routing yazısında netleştireceğiz.

Kendi Middleware'imiz: Class Olarak

Lambda ile yazılan middleware birkaç satırı geçince Program.cs'i boğar. Asıl iş yapan middleware'leri class olarak yazarsın. Her isteğin süresini ölçen bir middleware:

using System.Diagnostics;

namespace ProductCatalog.Api.Middleware;

public class RequestTimingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestTimingMiddleware> _logger;

    public RequestTimingMiddleware(RequestDelegate next, ILogger<RequestTimingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var stopwatch = Stopwatch.StartNew();

        context.Response.OnStarting(() =>
        {
            context.Response.Headers["X-Elapsed-Ms"] = stopwatch.ElapsedMilliseconds.ToString();
            return Task.CompletedTask;
        });

        await _next(context);

        _logger.LogInformation("{Method} {Path} -> {StatusCode} in {Elapsed} ms",
            context.Request.Method, context.Request.Path, context.Response.StatusCode, stopwatch.ElapsedMilliseconds);
    }
}
namespace ProductCatalog.Api.Middleware;

public static class RequestTimingMiddlewareExtensions
{
    public static IApplicationBuilder UseRequestTiming(this IApplicationBuilder app)
    {
        return app.UseMiddleware<RequestTimingMiddleware>();
    }
}

Bir class'ın middleware olması için bir interface uygulaması gerekmiyor; kurallar şunlar: constructor'da bir RequestDelegate alır ve HttpContext parametreli, Task dönen bir InvokeAsync (ya da Invoke) method'u vardır. Constructor'a ILogger gibi başka service'ler de enjekte edebilirsin. Extension method sadece bir kolaylık; built-in middleware'lerin UseXxx görünümünü kendi middleware'ine de vermiş olursun.

Header'ı neden doğrudan değil de OnStarting içinde yazdığımızı tuzaklar bölümünde göreceğiz. Çalıştırınca response'ta header, console'da da log oluşuyor:

$ curl -i -k https://localhost:7278/api/products
HTTP/2 200
content-type: application/json; charset=utf-8
date: Tue, 03 Nov 2026 21:18:49 GMT
server: Kestrel
x-elapsed-ms: 13

["Mechanical Keyboard","Wireless Mouse"]
info: ProductCatalog.Api.Middleware.RequestTimingMiddleware[0]
      GET /api/products -> 200 in 16 ms

Header'daki 13 ile log'daki 16 arasındaki fark tesadüf değil. Header response başlarken yazıldı, log ise response tamamen gönderildikten sonra.

Sıralama Neden Bu Kadar Önemli

Middleware'ler Program.cs'te eklendikleri sırayla çalışır, ve bu sıra davranışı doğrudan değiştirir. Aynı uygulamaya HTTP üzerinden iki istek attım:

$ curl -i http://localhost:5214/ping
HTTP/1.1 200 OK
Date: Tue, 03 Nov 2026 21:18:48 GMT
Server: Kestrel
Transfer-Encoding: chunked
X-Elapsed-Ms: 0

pong

$ curl -i http://localhost:5214/api/products
HTTP/1.1 307 Temporary Redirect
Content-Length: 0
Date: Tue, 03 Nov 2026 21:18:48 GMT
Server: Kestrel
Location: https://localhost:7278/api/products
X-Elapsed-Ms: 0

/ping HTTP üzerinden cevap verdi, /api/products ise HTTPS'e yönlendirildi. Çünkü /ping dalı UseHttpsRedirection'dan önce tanımlı; istek oraya hiç ulaşmadı. Bakım modu da aynı şekilde HTTPS yönlendirmesinden önce çalışıyor. Bir health check'in HTTP'den cevap vermesini istiyorsan bu sıra işine yarar; ama aynı yanlışı authorization'dan önce yaparsan, korumak istediğin bir endpoint'i herkese açarsın.

RequestTimingMiddleware'i en başa koymamızın sebebi de bu. Ondan sonra gelen her şeyin süresini ölçer; 307 ve 503 cevapları dahil bütün istekler log'a düşer. Sona koysaydık yönlendirilen ya da bakım moduna takılan istekleri hiç görmezdi.

Built-in Middleware'ler ve Önerilen Sıra

Bir Web API'de en sık kullanacağın built-in middleware'ler ve önerilen sıraları şöyle: UseExceptionHandler en başta durur, çünkü sonrasında fırlayan her exception'ı yakalayabilmesi için en dış halka olması gerekir. Ardından UseHsts ve UseHttpsRedirection gelir. Sonra UseRouting, isteğin hangi endpoint'e gideceğine karar verir. UseCors, UseAuthentication ve UseAuthorization bu kararı bilmek zorunda oldukları için routing'den sonra, endpoint'ten önce çalışır. En sonda MapControllers ile tanımladığın endpoint'ler yürütülür.

Örnekte Production'da exception handler'ı açtık. Bir controller exception fırlattığında middleware isteği /error endpoint'ine yeniden yönlendiriyor ve oradaki Results.Problem() bir 500 ProblemDetails cevabı üretiyor. Development'ta ise .NET 6'nın otomatik eklediği developer exception page devrede. Hata yönetimini ayrı bir yazıda bütün ayrıntısıyla ele alacağız.

.NET 6'daki WebApplication bir kolaylık daha sağlar. UseRouting'i hiç çağırmazsan pipeline'ın en başına kendisi ekler, Map... ile tanımladığın endpoint'leri de pipeline'ın sonunda çalıştırır. Bu yüzden şablonda UseRouting satırı yok. Örnekte onu açıkça yazdık, çünkü kendi middleware'lerimizin routing'den önce çalışmasını istiyoruz; çağırdığın yer, routing'in pipeline'daki yeri olur.

Rate limiting ve output caching gibi middleware'ler .NET 7 ile geldi; onları sürüm yazılarında göreceğiz.

Sık Düşülen Tuzaklar

Response başladıktan sonra header yazmak. Aşağıdaki middleware derlenir ama ilk istekte patlar:

app.Use(async (context, next) =>
{
    await next();
    context.Response.Headers["X-Served-By"] = "catalog-01";
});

Log'da şu exception çıkar: System.InvalidOperationException: Headers are read-only, response has already started. next() döndüğünde body yazılmaya başlamış, header'lar çoktan istemciye gitmiştir. Çözüm, header'ı OnStarting callback'inde yazmak:

app.Use(async (context, next) =>
{
    context.Response.OnStarting(() =>
    {
        context.Response.Headers["X-Served-By"] = "catalog-01";
        return Task.CompletedTask;
    });
    await next();
});

Middleware constructor'ına scoped service enjekte etmek. Class middleware uygulama başlarken bir kez oluşturulur ve uygulama boyunca yaşar. Constructor'ına AddScoped ile kaydedilmiş bir service koyarsan Development ortamında uygulama hiç açılmaz: Cannot resolve scoped service 'ProductCatalog.Api.Middleware.AuditLog' from root provider. Production'da scope doğrulaması kapalı olduğu için uygulama açılır ve o service sessizce bütün isteklerin paylaştığı tek bir instance'a dönüşür; bu daha da kötü. Scoped service'i constructor'a değil InvokeAsync'e parametre olarak ver:

public class AuditMiddleware
{
    private readonly RequestDelegate _next;

    public AuditMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context, AuditLog auditLog)
    {
        auditLog.Entries.Add(context.Request.Path);
        await _next(context);
    }
}

app.Run'ı yanlış yere koymak. Terminal bir Run halkası, kendisinden sonra eklenen hiçbir middleware'i çalıştırmaz. Controller'ların cevap vermediği bir projede ilk bakacağın şey, pipeline'ın ortasında unutulmuş bir app.Run(...) olabilir.

Middleware ile filter'ı karıştırmak. Middleware her isteği görür ama hangi action'ın çalışacağını bilmez; MVC'ye özel işler için filter'lar var ve onları ayrı bir yazıda göreceğiz.

Küçük Bir Deneme

★☆☆ Önce Tahmin Et örneğine üçüncü bir Use ekle, ama next()'i çağırmasın. Console çıktısının ve cevabın nasıl değiştiğini gör.

★★☆ RequestTimingMiddleware'i UseHttpsRedirection'ın altına taşı ve HTTP üzerinden /api/products çağır. 307 cevabının log'a düşüp düşmediğine bak.

★★★ İsteğe bir X-Correlation-Id header'ı geldiyse onu, gelmediyse yeni bir Guid'i response header'ına yazan bir class middleware yaz. Header'ı OnStarting ile ekle ve bir extension method ile app.UseCorrelationId() olarak kaydet.

Pipeline'ın ortasında bir yerde UseRouting isteğe bakıyor ve "bu istek şu endpoint'e gidecek" kararını veriyor. Bir sonraki yazıda bu kararın nasıl verildiğini açıyoruz: endpoint routing, attribute routing, route template'leri, constraint'ler ve çakışan route'lar.