Mert Özen Her Satırda Daha İleri
Backend API Gateway ve YARP — #05

YARP ile Angular BFF ve Aggregation: Proxy Route'ları ile Kendi Endpoint'lerini Yan Yana Çalıştırmak

Mert Özen 14 Kas 2026 15 dk 14 görüntülenme
YARP ile Angular BFF ve Aggregation: Proxy Route'ları ile Kendi Endpoint'lerini Yan Yana Çalıştırmak

YARP route'ları normal endpoint olduğu için Angular dosyalarını, aggregation endpoint'lerini ve proxy route'larını tek uygulamada, tek origin'den ve CORS'suz sunabilirsin; dikkat edilecek yer fallback'ler, timeout bütçesi ve ikinci trafik yollarıdır. Bookstore'da Catalog, Inventory ve Ordering'i paralel çağıran kitap sayfası endpoint'ini, kısmi hatayı, YARP'ın health bilgisini kullanan HttpClient'ı ve kuruma göre hedef seçen IHttpForwarder'ı gerçek çıktılarla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Yarp.ReverseProxy 2.3, Microsoft.Extensions.ServiceDiscovery 10.11 ve Angular 22.2 (TypeScript 6.0) ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore.Gateway'de Catalog'un üç instance'ı var ve 03'te kurduğumuz active health check her 5 saniyede bir onları yokluyor. Şimdi gateway'in içine kendi yazdığımız bir endpoint ekliyoruz: kitap detay sayfası için Catalog, Inventory ve Ordering'i paralel çağırıp tek cevap döndüren bir aggregation endpoint'i. Bu endpoint Catalog'a, adres listesini service discovery ile configuration'dan okuyan normal bir HttpClient ile gidiyor.

catalog-2'yi kapatıp health check'in onu fark etmesini bekledin. Sonra aynı kitabı altı kez proxy route'undan, altı kez de aggregation endpoint'inden istedin. Proxy route'unda altısı da 200 dönüyor. Aggregation endpoint'inde kaç tanesi 200 döner? Cevabı yazının ortasında, gerçek çıktıyla görüyoruz.

Aynı Uygulamada Proxy Route'ları ve Kendi Endpoint'lerin

Bu yazıda sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için Angular istemcisine bir BFF (Backend for Frontend; belirli bir istemci için yazılmış ara backend) kuruyoruz. BFF'in ne olduğunu, ne zaman gerektiğini ve hangi noktada genel amaçlı bir API'ye dönüştüğünü Microservices serisinin API Gateway ve Backend for Frontend yazısında konuştuk. Burada soru daha pratik: YARP (Yet Another Reverse Proxy; .NET için reverse proxy library'si) ile bunu tek bir ASP.NET Core uygulamasında nasıl kurarız?

YARP'ın library olmasının en çok işe yaradığı yer burası. MapReverseProxy() route'ları normal endpoint olarak ekliyor; yanına Minimal API endpoint'leri, statik dosyalar ve fallback'ler koyabiliyorsun. Hepsi aynı routing tablosunda duruyor ve aynı middleware'lerden geçiyor. Bookstore.Gateway'in bu yazının sonundaki hâli şöyle:

05-bff-single-origin-tr.png

var app = builder.Build();

// Angular'ın build çıktısı (wwwroot) /api ile aynı origin'den sunuluyor: CORS gerekmiyor
app.UseDefaultFiles();
app.UseStaticFiles();

app.UseAuthentication();
app.UseRateLimiter();
app.UseAuthorization();
app.UseRequestTimeouts();
app.UseOutputCache();

app.MapGet("/gateway/ping", () => "pong").AllowAnonymous();
app.MapBookDetails();
app.MapInstitutionForwarding();

app.MapReverseProxy(proxyPipeline => { /* 01-04'teki pipeline */ });

// bilinmeyen /api adresleri index.html değil 404 alır; geri kalan her adres bir Angular route'u
app.MapFallback("/api/{**path}", () => Results.NotFound()).AllowAnonymous();
app.MapFallbackToFile("index.html").AllowAnonymous();

app.Run();

Route'lar arasında çakışma yok: proxy route'ları /api/v1/books/** ve /api/v1/orders/**, aggregation /api/bff/books/{isbn}, direct forwarding /api/v1/institutions/{institutionId}/**. Çakışma olsaydı ASP.NET Core'un endpoint önceliği devreye girerdi: önce Order, sonra daha spesifik pattern. 02'deki catch-all tuzağı burada da geçerli. 04'te eklediğimiz UseCors() ve bookstore-web CORS (Cross-Origin Resource Sharing; tarayıcının başka origin'e yaptığı istekleri yöneten kurallar) policy'si de bu yazıda kalktı. Angular artık sayfayı ve API'yi aynı adresten aldığı için tarayıcının sorduğu bir şey kalmadı.

Angular'ı Aynı Origin'den Sunmak

Angular projesi repo'da web/bookstore-web altında; Angular 22.2 ile oluşturuldu ve Angular'ın istediği TypeScript 6.0'ı kullanıyor. Sayfayı ve uygulamanın yapısını ilerideki ya da yayındaki Angular serileri anlatıyor. Bizi ilgilendiren iki ayar var. Birincisi, ng build çıktısını doğrudan gateway'in wwwroot klasörüne yazdırmak:

"build": {
  "builder": "@angular/build:application",
  "options": {
    "browser": "src/main.ts",
    "outputPath": {
      "base": "../../src/Bookstore.Gateway/wwwroot",
      "browser": ""
    }
  }
},
"serve": {
  "builder": "@angular/build:dev-server",
  "options": {
    "proxyConfig": "proxy.conf.json"
  }
}

"browser": "" ayarı dosyaların wwwroot/browser alt klasörüne değil doğrudan wwwroot'a gitmesini sağlıyor. İkincisi, geliştirmede ng serve kullanırken de tek origin'den konuşmak. Angular'ın dev server'ı /api ile başlayan istekleri gateway'e iletiyor:

{
  "/api": {
    "target": "http://localhost:5300",
    "secure": false
  }
}
cd web/bookstore-web
npm install
npx ng build
Output location: .../src/Bookstore.Gateway/wwwroot

# ng serve açıkken
curl http://localhost:4200/api/bff/books/9780547928227
{"isbn":"9780547928227","title":"The Hobbit","author":"J. R. R. Tolkien","price":310.00,"available":6,"purchasedBefore":null,"warnings":[]}

Angular tarafında URL'ler göreli. Sayfa ister ng serve ile 4200'den, ister gateway'den 5300'den gelsin, aynı kod çalışıyor:

export class BookDetails {
  readonly isbn = input.required<string>();

  // göreli URL: bu uygulamayı ve /api'yi aynı gateway sunuyor, CORS yok
  protected readonly book = httpResource<BookDetailsView>(() => `/api/bff/books/${this.isbn()}`);
}

httpResource Angular 22'de stabil API; signal'e bağlı bir HTTP isteği açıyor ve isbn değişince isteği kendisi yeniliyor. Gateway'i başlatıp sayfayı Playwright'ın Chromium'uyla açtım; sayfadaki metin şuydu:

http://localhost:5300/books/9780441172719 => Dune | Frank Herbert · 420 TRY | 13 in stock

Bu çalışmadan önce iki hatayı gördüm, ikisi de SPA (Single Page Application; tüm ekranları tarayıcıda tek bir HTML sayfası üzerinden çizen uygulama) fallback'inden. İlk denememde MapFallbackToFile("index.html") satırında .AllowAnonymous() yoktu ve sonuç şuydu:

/ 401
/books/9780441172719 401
/main-4UR26WGJ.js 200

JavaScript dosyası geliyor ama sayfanın kendisi 401. Sebep, 04'te koyduğumuz fallback authorization policy'si: AuthorizationPolicy yazılmamış her endpoint kimlik istiyor ve MapFallbackToFile da bir endpoint. Statik dosya middleware'i endpoint değil, o yüzden JavaScript dosyası etkilenmiyor. Kimliksiz kullanıcının açması gereken her endpoint'e AllowAnonymous() yazmak gerekiyor; fallback policy'nin bize kazandırdığı güvenlik de tam olarak bu açıklık.

İkinci hata daha sinsi. /api/{**path} fallback'i olmadan, yazım hatalı bir API adresi şu cevabı aldı:

/api/v2/books (token) 200 text/html

Angular'ın HttpClient'ı 200 alıp JSON beklerken HTML parse etmeye çalışıyor ve hata, gerçek sebepten çok uzakta, bir parse hatası olarak görünüyor. /api altında eşleşmeyen her istek için açık bir 404 fallback'i bu yüzden var. İki fallback'in de Order değeri aynı (en düşük öncelik); daha spesifik pattern olduğu için /api/{**path} kazanıyor.

Aggregation Endpoint'i: Kitap Detay Sayfası

Kitap detay sayfası üç service'ten veri istiyor: kitabın kendisi Catalog'dan, stok Inventory'den, "bu kitabı daha önce aldın mı?" Ordering'den. Inventory bu yazıda eklenen yeni bir service: Bookstore.Inventory.Api, :5303'te, StockItem ve Warehouse kayıtlarıyla depo başına stok tutuyor. Tarayıcının üç ayrı istek atması yerine gateway tek bir endpoint sunuyor:

public static IEndpointRouteBuilder MapBookDetails(this IEndpointRouteBuilder app)
{
    app.MapGet("/api/bff/books/{isbn}", GetAsync)
        .AllowAnonymous()
        .WithRequestTimeout(TrafficPolicies.CatalogRead);
    return app;
}

private static async Task<IResult> GetAsync(
    string isbn, HttpContext context, IHttpClientFactory http, ILoggerFactory loggers, CancellationToken ct)
{
    using CancellationTokenSource optional = CancellationTokenSource.CreateLinkedTokenSource(ct);
    optional.CancelAfter(OptionalBudget);

    // token passthrough: Ordering aynı token'ı kendisi de doğrular (04)
    AuthenticationHeaderValue? bearer =
        AuthenticationHeaderValue.TryParse(context.Request.Headers[HeaderNames.Authorization], out AuthenticationHeaderValue? parsed)
            ? parsed
            : null;
    bool signedIn = context.User.Identity?.IsAuthenticated == true;

    Task<DownstreamResult<CatalogBook>> book = DownstreamCall.GetAsync<CatalogBook>(
        http.CreateClient(BffClients.Catalog), $"/books/{isbn}", null, ct);
    Task<DownstreamResult<StockLevel>> stock = DownstreamCall.GetAsync<StockLevel>(
        http.CreateClient(BffClients.Inventory), $"/stock/{isbn}", null, optional.Token);
    Task<DownstreamResult<Purchase>> purchase = signedIn
        ? DownstreamCall.GetAsync<Purchase>(http.CreateClient(BffClients.Ordering), $"/orders/purchases/{isbn}", bearer, optional.Token)
        : Task.FromResult(new DownstreamResult<Purchase>(default, null, "anonymous", 0));

    await Task.WhenAll(book, stock, purchase);

    // Catalog zorunlu: kitap yoksa sayfa da yok
    if (book.Result.Status == HttpStatusCode.NotFound)
    {
        return Results.NotFound();
    }
    if (book.Result.Value is not { } b)
    {
        return Results.Problem(statusCode: StatusCodes.Status502BadGateway, title: "Catalog is unavailable");
    }

    List<string> warnings = [];
    if (stock.Result.Error is { } stockError)
    {
        warnings.Add($"stock-unavailable: {stockError}");
    }
    if (signedIn && purchase.Result.Error is { } purchaseError)
    {
        warnings.Add($"purchase-history-unavailable: {purchaseError}");
    }

    return Results.Ok(new BookDetailsView(
        b.Isbn, b.Title, b.Author, b.Price,
        stock.Result.Value?.Available,
        purchase.Result.Value?.PurchasedBefore,
        warnings));
}

Kodda logging satırını kısaltmak için çıkardım; repo'daki hâlinde her çağrının süresi ve sonucu tek satırda loglanıyor. DownstreamCall.GetAsync hiçbir downstream sorunu için exception fırlatmıyor; değeri, status code'u, hata türünü ve geçen süreyi bir record'da döndürüyor. Hangi hatanın sayfayı bozacağına endpoint karar veriyor. Endpoint'in kendisi AllowAnonymous(): kimliksiz kullanıcı da kitap sayfasını görebilmeli. Ordering çağrısı yalnız kullanıcı giriş yapmışsa yapılıyor ve gelen Authorization header'ı aynen iletiliyor; 04'teki token passthrough. Üç cevap:

# token'sız
{"isbn":"9780441172719","title":"Dune","author":"Frank Herbert","price":420.00,"available":13,"purchasedBefore":null,"warnings":[]}
# Ada ve Alan, aynı kitap (Emma)
{"isbn":"9780141439587","title":"Emma","author":"Jane Austen","price":185.50,"available":0,"purchasedBefore":true,"warnings":[]}
{"isbn":"9780141439587","title":"Emma","author":"Jane Austen","price":185.50,"available":0,"purchasedBefore":false,"warnings":[]}

      Book 9780141439587: catalog 5 ms ok, inventory 3 ms ok, ordering 53 ms ok

purchasedBefore için üç değer var: true, false ve null. null "bilmiyorum" demek; ya kullanıcı giriş yapmamış ya da Ordering cevap verememiş. available için de aynı kural geçerli: 0 "stokta yok", null "stok bilgisi yok". Bu ayrımı view model'e koymazsan, Inventory çöktüğünde bütün kitaplar "tükendi" görünür ve satış durur. Hangi alanın zorunlu olduğu teknik bir karar değil, sayfanın sahibi olan ekibin ürün kararı. Bookstore'da kitapsız sayfa olmaz, stoksuz sayfa olur.

Token nerede duruyor?

Bu yazıda Angular, 04'teki gibi bir bearer token gönderiyor ve gateway onu doğruluyor. Gerçek bir SPA BFF'inde token'ın tarayıcıya hiç inmemesi daha güvenli: kullanıcı BFF'e OIDC (OpenID Connect; OAuth 2.0 üzerine kurulu kimlik protokolü) ile giriş yapar, BFF bir cookie session tutar ve API'ye giden isteklere access token'ı sunucuda ekler. Bu mimarinin tamamını, anti-CSRF (Cross-Site Request Forgery; kullanıcının tarayıcısı üzerinden onun adına sahte istek gönderme saldırısı) önlemleri ve token yenileme dahil, Authentication ve Authorization serisinin SPA için BFF Pattern yazısında YARP ile kurduk. O yapıya geçtiğinde değişen tek şey token'ın kaynağı oluyor; bu yazıdaki routing, aggregation ve direct forwarding aynı kalıyor. Aggregation endpoint'inde gelen header yerine session'daki token'ı iletmek yeterli.

Kısmi Hata ve Timeout Bütçesi

Microservices serisindeki BFF örneğinde Inventory hızlıca 503 döndüğü için toplam süre değişmemişti ve yazı şu uyarıyla bitiyordu: Inventory cevap vermeden asılı kalsaydı BFF de asılı kalırdı. Burada iki durumu da gerçekten deniyoruz. Önce Inventory'yi kapattım, sonra 3 saniyelik gecikmeyle yeniden başlattım:

# Inventory kapalı
{"isbn":"9780441172719",...,"available":null,"purchasedBefore":true,"warnings":["stock-unavailable: ConnectionError"]}  200 0.016679s
# Inventory 3 saniyede cevap veriyor
{"isbn":"9780441172719",...,"available":null,"purchasedBefore":true,"warnings":["stock-unavailable: timeout"]}  200 0.807893s

      Book 9780441172719: catalog 1 ms ok, inventory 14 ms ConnectionError, ordering 5 ms ok
      Book 9780441172719: catalog 2 ms ok, inventory 805 ms timeout, ordering 1 ms ok

Kapalı bir service ucuz bir hata: bağlantı hemen reddediliyor. Yavaş bir service pahalı: bir bütçe koymasaydık sayfa 3 saniye bekleyecekti. Bütçeyi iki katmanda verdim. Opsiyonel çağrılar (Inventory, Ordering) 800 ms'lik linked bir CancellationTokenSource ile sınırlı. Zorunlu çağrı (Catalog) bu bütçeye girmiyor, onun sınırı endpoint'e bağladığımız catalog-read request timeout policy'si, yani 04'teki 2 saniye. Ordering'i de kapattığımda iki uyarı birden geldi ve süre yine 800 ms civarında kaldı:

{"isbn":"9780441172719",...,"available":null,"purchasedBefore":null,"warnings":["stock-unavailable: timeout","purchase-history-unavailable: ConnectionError"]}  200 0.805972s

Angular sayfası bu durumda kitabı gösterip stok satırında "Stock information is unavailable right now." yazıyor. Burada retry, circuit breaker ve hedge gibi araçları bilerek kullanmadım. 800 ms içinde bir retry yapmak çoğu zaman sadece yavaş service'e ikinci bir yük bindirir. Bu kararların nasıl verildiği ve Microsoft.Extensions.Http.Resilience ile nasıl kurulduğu ilerideki External Service Integration ve Resilience serisinin konusu.

Aggregation'ın Hedefini YARP'ın Health Bilgisiyle Seçmek

Şimdi "Önce Tahmin Et"in cevabı. İlk sürümde üç HttpClient'ın üçü de .NET service discovery ile çözülüyordu; Services:catalog:http altında üç adres vardı. catalog-2'yi kapatıp health check'in onu işaretlemesini bekledim:

== BFF
502 200 200 502 200 200
== proxy route
200 200 200 200 200 200

Proxy route'unda sorun yok, çünkü YARP catalog-2'yi health check sonucuna göre listeden çıkardı. Aggregation endpoint'inde ise her üç istekten biri 502 aldı. Service discovery'nin configuration provider'ı adres listesini veriyor ve aralarından sırayla birini seçiyor; o adresin sağlıklı olup olmadığını bilmiyor. Aynı uygulamanın içinde iki ayrı load balancing var ve biri kör.

Çözüm için YARP'ın zaten bildiği şeyi kullandım. IProxyStateLookup dependency injection container'ından alınabiliyor ve cluster'ın o anki durumunu, health check'e göre süzülmüş destination listesiyle birlikte veriyor. Küçük bir DelegatingHandler isteğin adresini bu listeden seçilen destination'a çeviriyor:

// isteği YARP'ın o an kullanılabilir saydığı bir destination'a gönderir;
// böylece BFF, proxy route'larıyla aynı health check sonuçlarını kullanır
public sealed class ClusterDestinationHandler(IProxyStateLookup proxyState, string clusterId) : DelegatingHandler
{
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        if (!proxyState.TryGetCluster(clusterId, out ClusterState? cluster))
        {
            throw new InvalidOperationException($"Cluster '{clusterId}' is not configured.");
        }

        IReadOnlyList<DestinationState> available = cluster.DestinationsState.AvailableDestinations;
        if (available.Count == 0)
        {
            throw new HttpRequestException(HttpRequestError.ConnectionError, $"Cluster '{clusterId}' has no available destination.");
        }

        DestinationState destination = available[Random.Shared.Next(available.Count)];
        request.RequestUri = new Uri(new Uri(destination.Model.Config.Address), request.RequestUri!.PathAndQuery);
        return base.SendAsync(request, cancellationToken);
    }
}
// Catalog'un üç instance'ı var: YARP cluster'ının kullanılabilir destination'larından biri seçilir
services.AddHttpClient(Catalog, client => client.BaseAddress = new Uri("http://catalog"))
    .AddHttpMessageHandler(sp => new ClusterDestinationHandler(sp.GetRequiredService<IProxyStateLookup>(), "catalog"));

// tek instance'lı service'ler: "http://inventory" Services:inventory:http'den çözülür
foreach (string name in (string[])[Inventory, Ordering])
{
    services.AddHttpClient(name, client => client.BaseAddress = new Uri($"http://{name}"))
        .AddServiceDiscovery();
}
== BFF
200 200 200 200 200 200 200 200 200 200 200 200

Bu handler'ın sınırlarını da bil. Seçim rastgele; cluster'ın PreferSameZone policy'si burada çalışmıyor, çünkü load balancing middleware'i yalnız proxy pipeline'ında var. Bu çağrılar YARP'ın passive health check'ine de bilgi vermiyor. AvailableDestinations, cluster'ın HealthyOrPanic ayarına uyuyor: hepsi sağlıksızsa hepsini döndürüyor. Ve health check'in fark etmesi zaman alıyor; 03'teki ayarlarla iki başarısız probe, yani 10 saniyeye kadar. O aralık için yine bir timeout ve gerekirse bir retry gerekiyor.

IHttpForwarder ile Direct Forwarding: Kuruma Göre Hedef

Bookstore'un kurumsal müşterileri (okullar, kütüphaneler, şirketler) toplu sipariş veriyor. Büyük bir kurum, örneğin inst-42, kendi Ordering instance'ında çalışıyor; diğerleri ortak instance'ı paylaşıyor. Hangi kurumun nereye gideceği configuration'da; liste uzayabilir ve her kurum için ayrı route ve cluster yazmak istemiyoruz. Route config'i burada yetersiz kalıyor, çünkü hedef istek anında, path'teki kurum ID'sine göre seçiliyor. YARP'ın route, cluster, load balancing ve health check katmanlarını atlayıp yalnız "bu isteği şu adrese ilet" işini yapan IHttpForwarder tam bunun için var:

// uygulama ömrü boyunca tek invoker; HttpClient değil HttpMessageInvoker, cevaplar buffer'lanmadan akar
HttpMessageInvoker httpClient = new(new SocketsHttpHandler
{
    UseProxy = false,
    AllowAutoRedirect = false,
    AutomaticDecompression = DecompressionMethods.None,
    UseCookies = false,
    EnableMultipleHttp2Connections = true,
    ActivityHeadersPropagator = new ReverseProxyPropagator(DistributedContextPropagator.Current),
    ConnectTimeout = TimeSpan.FromSeconds(15),
});
ForwarderRequestConfig requestConfig = new() { ActivityTimeout = TimeSpan.FromSeconds(30) };

// direct forwarding AddTransforms'u çalıştırmaz; iç header kuralı burada yeniden uygulanır
HttpTransformer transformer = app.ServiceProvider.GetRequiredService<ITransformBuilder>()
    .Create(context => InternalHeaderTransforms.Add(context, "institutions")
        .AddPathRemovePrefix("/api/v1"));

app.Map("/api/v1/institutions/{institutionId}/{**rest}", async (
    HttpContext context, string institutionId, IHttpForwarder forwarder, IConfiguration configuration) =>
{
    // büyük kurumların kendi Ordering instance'ı var; diğerleri ortak instance'ı kullanır
    string destination = configuration[$"Institutions:Dedicated:{institutionId}"]
        ?? configuration["Institutions:Shared"]!;

    ForwarderError error = await forwarder.SendAsync(context, destination, httpClient, requestConfig, transformer);
    if (error != ForwarderError.None)
    {
        context.RequestServices.GetRequiredService<ILoggerFactory>()
            .CreateLogger("Bookstore.Gateway.Forwarding")
            .LogWarning("Institution {InstitutionId} -> {Destination} failed: {Error}", institutionId, destination, error);
    }
}).RequireAuthorization(GatewayAuthentication.InstitutionBuyerPolicy);

SocketsHttpHandler ayarları YARP'ın direct forwarding dokümanındaki önerilerle aynı: proxy'nin kendisi cookie tutmamalı, redirect'leri takip etmemeli ve sıkıştırılmış cevabı açmamalı; bunlar istemcinin işi. IHttpForwarder AddReverseProxy() ile zaten kayıtlı. Endpoint bir Minimal API endpoint'i olduğu için RequireAuthorization ile policy bağlamak da aynı şekilde çalışıyor. Grace'in InstitutionBuyer rollü token'ıyla, üstelik Alan gibi görünmeye çalışarak:

curl -H "Authorization: Bearer $BUY" -H "X-Bookstore-User-Id: alan" http://localhost:5300/api/v1/institutions/inst-42/orders
{"institutionId":"inst-42","instance":"ordering-dedicated","userIdHeader":"grace"}  200
curl -H "Authorization: Bearer $BUY" -H "X-Bookstore-User-Id: alan" http://localhost:5300/api/v1/institutions/inst-7/orders
{"institutionId":"inst-7","instance":"ordering","userIdHeader":"grace"}  200

ada: 403
anon: 401
# dedicated instance kapalıyken
502
      Institution inst-42 -> http://localhost:5322/ failed: Request

inst-42 kendi instance'ına, inst-7 ortak instance'a gitti. Hedef kapalıyken forwarder 502 yazıyor ve ForwarderError.Request döndürüyor; loglamak ve gerekirse cevabı değiştirmek senin elinde. Kurumun bu kullanıcıya ait olup olmadığını gateway kontrol etmiyor; o kural Ordering'in.

Transformer satırı bu bölümün asıl dersi. İlk denememde transformer yalnız AddPathRemovePrefix("/api/v1") içeriyordu ve Ordering'e giden header şuydu:

{"institutionId":"inst-42","instance":"ordering-dedicated","userIdHeader":"alan"}  200

02'de yazdığımız "X-Bookstore- önekli header'ları yalnız gateway koyar" kuralı AddTransforms ile route'lara bağlıydı. Direct forwarding route kullanmadığı için o callback hiç çalışmadı ve istemcinin yazdığı alan olduğu gibi gitti. Kuralı tek bir yerde tutmak için InternalHeaderTransforms'u, hem route'ların hem de direct forwarding'in çağırabileceği bir Add method'una ayırdım. Gateway'e ikinci bir trafik yolu eklediğinde, birinci yolda kurduğun her güvenlik kuralının bu yolda da çalıştığını ayrıca test et.

Üretimde Dikkat

Aggregation endpoint'leri proxy route'larından farklı izlenmeli. Proxy route'unda bir status code bir backend'e karşılık geliyor; aggregation'da ise 200 dönen bir cevap içinde iki uyarı olabilir. Uyarıları metric olarak say (örneğin stock-unavailable sayısı) ve her downstream çağrının süresini ayrı logla; yukarıdaki tek satırlık log bunun için var. Sayfanın p95 süresi, opsiyonel bütçenin (800 ms) üstüne çıkıyorsa sorun Catalog'dadır.

Bütçeleri ölçerek seç. 800 ms, Inventory'nin normal p99'unun birkaç katı olmalı; aksi hâlde normal günde de uyarı üretirsin. Zorunlu çağrının timeout'u da sayfanın kabul edilebilir en uzun süresinden kısa olmalı.

Statik dosyaları gateway'den sunmak küçük ve orta ölçekte pratik. Trafik büyüdüğünde Angular dosyalarını bir CDN'e (Content Delivery Network; statik dosyaları kullanıcıya yakın sunuculardan dağıtan ağ) koyup yalnız /api'yi gateway'de bırakmak yaygın bir adım; bu durumda origin'ler yine ayrılır ve CORS'u ya da CDN üzerinden tek origin'i yeniden düşünmen gerekir. index.html'in cache'lenmemesi, hash'li JavaScript dosyalarının ise uzun süre cache'lenmesi gerekir.

Direct forwarding'de YARP'ın health check, load balancing ve passive health özellikleri yok. Dedicated instance çökerse o kurumun bütün istekleri 502 alır. Kurum başına birden çok instance gerekiyorsa, IProxyStateLookup ile o kuruma ait bir cluster seçip context.ReassignProxyRequest ile proxy pipeline'ına bırakmak daha doğru; 06'da canary için bu yolu kullanacağız.

Sık Düşülen Tuzaklar

Birincisi, BFF'i küçük bir monolith'e çevirmek. Aggregation endpoint'i veriyi birleştirir ve biçimlendirir; indirim hesaplamaz, stok rezerve etmez. "Bunu BFF'te yapalım, daha hızlı" diye başlayan her business kuralı, yarın ikinci bir istemci (mobil) geldiğinde ya kopyalanacak ya da BFF genel amaçlı bir API'ye dönüşecek.

İkincisi, opsiyonel veriyi zorunlu gibi ele almak. Task.WhenAll içindeki herhangi bir çağrının exception'ı bütün sayfayı 500'e çevirir. Her çağrının hatasını ayrı yakala ve hangi hatanın sayfayı bozacağına bilinçli karar ver.

Üçüncüsü, "bilinmiyor" ile "sıfır"ı karıştırmak. int? yerine int kullanan bir view model, Inventory çöktüğünde bütün kitapları stoksuz gösterir.

Dördüncüsü, SPA fallback'inin /api'yi yutması. Eşleşmeyen API istekleri 200 ve HTML alır; hata bir JSON parse hatası olarak görünür.

Beşincisi, gateway'deki ikinci trafik yolunu unutmak. Direct forwarding, kendi yazdığın endpoint'ler ve proxy route'ları aynı güvenlik kurallarına tabi değil; iç header silme ve authorization her yol için ayrıca kurulmalı.

Ne Zaman Kullanma

Tek bir backend'e konuşan bir Angular uygulaması için ayrı bir BFF katmanı genelde gereksiz: Angular'ı o backend'in kendisinden sunmak ya da ng serve proxy'siyle geliştirmek aynı sonucu verir. Aggregation da her ekran için gerekli değil. Ekran iki çağrı yapıyorsa ve bunlar bağımsızsa tarayıcı da paralel çağırabilir; BFF ancak tur sayısı, mobil ağ gecikmesi ya da downstream'in dışarıya açılmaması gibi somut bir sebep olduğunda değer katar. Gateway'deki aggregation endpoint'lerinin sayısı on'u, yirmiyi geçiyor ve her biri farklı bir ekranın sahibine aitse, BFF'i ayrı bir uygulamaya çıkarıp gateway'i yalnız edge işlerine bırakmayı düşün.

Küçük Bir Deneme

★☆☆ Inventory'yi --Inventory:ResponseDelayMs=700 ile başlat ve kitap sayfasını on kez iste. Kaç tanesinde uyarı geldi? Bütçeyi 800 ms'den 500 ms'ye düşür ve sonucu karşılaştır; doğru bütçeyi neye göre seçeceğini kendi cümlelerinle yaz.

★★☆ Aggregation endpoint'inin cevabına Catalog'un hangi instance'tan geldiğini ekle (X-Catalog-Instance header'ını oku). catalog-2'yi kapat, health check onu işaretlemeden önceki 10 saniyede kaç istek başarısız oluyor? ClusterDestinationHandler'ı, başarısız olursa başka bir destination'ı bir kez deneyecek şekilde değiştir ve farkı ölç.

★★★ /api/v1/institutions/{institutionId}/** için direct forwarding yerine kurum başına bir cluster kullanan bir sürüm yaz: Institutions:Dedicated listesindeki her kurum için ordering-inst-42 gibi bir cluster tanımla, proxy pipeline'ında IProxyStateLookup.TryGetCluster ve context.ReassignProxyRequest ile isteği o cluster'a yönlendir. Health check'in dedicated instance kapandığında ne yaptığını iki sürümde karşılaştır.

Gateway artık hem bir proxy hem de Angular için bir BFF. Bir sonraki yazıda aynı araçlarla eski bir uygulamadan kademeli olarak çıkacağız: strangler fig ile route tablosunu adım adım değiştirecek, ReassignProxyRequest ile trafiğin bir yüzdesini yeni sürüme gönderecek ve config reload ile anında geri döneceğiz.