API Gateway ve BFF: YARP ile Angular İçin Tek Giriş Noktası ve Aggregation
Bookstore.Bff, YARP ile Angular'ın tek giriş noktası oluyor: service discovery ile çözülen proxy route'ları, iç adresi sızdıran Location header'ının çevrilmesi ve sipariş route'unda rate limit. Kitap sayfası Catalog ve Inventory'yi paralel çağıran bir aggregation endpoint'inden geliyor; Inventory kapalıyken sayfa stoksuz 533 ms'de, Catalog kapalıyken 30 saniyelik bir 500 yerine 2 saniyede 503 dönüyor.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Aspire 13.6, YARP (Yet Another Reverse Proxy; Microsoft'un .NET için reverse proxy library'si) 2.3, Microsoft.Extensions.ServiceDiscovery.Yarp 10.11 ve Grpc.Net.ClientFactory 2.85 ile derlenip denenmiştir.
Önce Tahmin Et
Angular'daki kitap sayfası iki şey gösteriyor: Catalog'dan kitabın bilgisi, Inventory'den anlık stok. İkisini birleştiren bir endpoint yazıyoruz ve ilk sürümde iki çağrıyı paralel başlatıp ikisini de bekliyoruz. Inventory kapalıyken sayfa ne döner ve ne kadar sürer? Catalog kapalıyken? İkinci cevap, ilk denemede bizi de şaşırttı.
Neden Ayrı Bir BFF Projesi?
Şu ana kadar her service kendi port'unda duruyordu: Catalog 5101, Ordering 5102. Angular bunları doğrudan çağırsaydı tarayıcı beş adres bilecek, her service CORS (Cross-Origin Resource Sharing; tarayıcının başka bir origin'e istek atmasına izin veren kurallar) ayarı yapacak ve 10. yazıda her biri ayrı ayrı oturum yönetecekti. Microservices serisinin API Gateway ve Backend for Frontend yazısında bunun yerine tek bir giriş noktası koymayı ve onu bir istemciye (burada Angular'a) özel tutmayı anlattık: BFF (Backend for Frontend; tek bir istemci için yazılmış ara backend).
Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) bu giriş noktası Bookstore.Bff. Aspire'ın Aspire.Hosting.Yarp paketiyle AppHost'ta bir YARP container'ı tanımlayıp route'larını oradan vermek de mümkündü. Yalnız proxy gerekseydi bu yeterdi; bizim BFF'imizin içinde kod olacak: bu yazıda kitap sayfası için bir aggregation endpoint'i, 10'da OIDC (OpenID Connect; OAuth 2.0 üstünde kimlik katmanı) ile oturum ve token yönetimi, 11'de Angular dosyaları. Bu yüzden kendi ASP.NET Core projemiz ve YARP onun içinde bir library. Proje src/Services/Bff'de, yani 01'deki architecture test onun için de geçerli: başka bir service'in implementasyonuna referans veremiyor, yalnız Bookstore.Inventory.Contracts gibi contract projelerine.

Proxy Route'ları ve Service Discovery
Route'lar appsettings.json'daki ReverseProxy bölümünde. Tarayıcının gördüğü yol ile service'in yolu ayrı: BFF /api/catalog/books'u Catalog'un /api/v1/books'una çeviriyor. API versioning (ASP.NET Core Web API serisinin Versioning yazısı) service'lerin iç detayı olarak kalıyor; Angular sürüm numarası görmüyor:
"catalog": {
"ClusterId": "catalog",
"Match": { "Path": "/api/catalog/{**rest}" },
"Transforms": [ { "PathPattern": "/api/v1/{**rest}" } ]
},
"orders-place": {
"ClusterId": "ordering",
"Match": { "Path": "/api/orders", "Methods": [ "POST" ] },
"RateLimiterPolicy": "place-order",
"Transforms": [ { "PathPattern": "/api/v1/orders" } ]
}
...
"Clusters": {
"catalog": {
"HttpRequest": { "ActivityTimeout": "00:00:05" },
"Destinations": { "catalog": { "Address": "https+http://catalog" } }
},
Cluster'ların adresi https+http://catalog; bu, 02'den beri HttpClient'larda kullandığımız Aspire service discovery yazımı. YARP onu kendisi çözemiyor, Microsoft.Extensions.ServiceDiscovery.Yarp paketi bir destination resolver ekliyor:
// Proxy route'ları configuration'dan; "https+http://catalog" adreslerini Aspire'ın service discovery'si çözüyor
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddServiceDiscoveryDestinationResolver()
BFF, AppHost'ta WithExternalHttpEndpoints() ile işaretlenen tek resource ve 5100 port'unda. Diğer service'ler lokalde kendi port'larını hata ayıklama için koruyor; 14'te Kubernetes'e çıkarken yalnız BFF dışarı açılacak. Arama artık BFF üzerinden:
curl "localhost:5100/api/catalog/books?search=emma&pageSize=2"
{"items":[{"isbn":"9780141439587","title":"Emma: A Novel","author":"Jane Austen","category":"Classics","price":199.90,"currency":"TRY","publishedYear":1815,"version":7}],"page":1,"pageSize":2,"totalCount":1}
Ayrıntılar (route match kuralları, transform'lar, config'in canlı yenilenmesi) API Gateway ve YARP serisinin YARP Konfigürasyonu yazısında.
Location Header'ı: İç Adresi Sızdırmamak
07'den beri Ordering siparişe 202 ve bir Location header'ı dönüyor: Location: http://localhost:5102/api/v1/orders/01a1da7d-057f-70b4-bd4d-8621c2cbe883. YARP response header'larını olduğu gibi kopyalar. Tarayıcı bu adresi takip etseydi BFF'yi atlayıp doğrudan Ordering'e gitmeye çalışırdı; production'da da iç ağın adresini dışarı vermiş olurduk. Ordering cluster'ına bir response transform'u ekledik:
.AddTransforms(context =>
{
// Ordering 202 ile Location: http://ordering-host/api/v1/orders/{id} dönüyor. Tarayıcı iç adresi değil,
// BFF'nin dışarıya açık yolunu görmeli.
if (context.Route.ClusterId == "ordering")
{
context.AddResponseTransform(transform =>
{
if (transform.HttpContext.Response.Headers.Location.ToString() is { Length: > 0 } location &&
Uri.TryCreate(location, UriKind.Absolute, out Uri? internalUri))
{
transform.HttpContext.Response.Headers.Location = internalUri.AbsolutePath.Replace("/api/v1/orders", "/api/orders");
}
return ValueTask.CompletedTask;
});
}
});
BFF üzerinden gönderilen siparişin header'ı artık Location: /api/orders/01a1da94-3c53-76a7-8e01-54632a360333 ve bu yol orders-read route'u ile Ordering'e gidiyor.
Aggregation: Kitap Sayfası İçin Tek İstek
Kitap sayfası için ayrı bir endpoint var: GET /api/books/{isbn}. Catalog'u HTTP ile, Inventory'yi 05'teki gRPC contract'ının GetStock RPC'si (Remote Procedure Call; uzak bir service'teki method'u çağırma) ile çağırıyor ve iki cevabı tek bir JSON'da birleştiriyor. İki çağrı aynı anda başlıyor; sayfa ikisinin toplamını değil, yavaş olanını bekliyor. Bir kural koyduk: Inventory'nin cevabı olmazsa sayfa yine gösterilir, stok bilgisi null olur. Catalog'un cevabı olmazsa gösterilecek bir sayfa yoktur.
// Sayfanın Catalog için kendi bütçesi. Bu olmasa standard resilience handler 30 saniyeye kadar
// bekliyor (10 saniyelik denemeler, retry ile), sonra vazgeçiyor.
using CancellationTokenSource catalogBudget = CancellationTokenSource.CreateLinkedTokenSource(ct);
catalogBudget.CancelAfter(TimeSpan.FromSeconds(2));
// İki çağrı aynı anda başlıyor; sayfa toplamı değil, yavaş olanı bekliyor
Task<CatalogBook?> bookTask = catalog.GetBookAsync(isbn, catalogBudget.Token);
Task<StockInfo?> stockTask = GetStockAsync(isbn, inventory, clock, logger, ct);
CatalogBook? book;
try
{
book = await bookTask;
}
catch (Exception ex) when (ex is HttpRequestException or TimeoutRejectedException or OperationCanceledException && !ct.IsCancellationRequested)
{
// Catalog olmadan gösterilecek sayfa yok
logger.LogWarning("Catalog failed for {Isbn}: {Error}", isbn, ex.GetType().Name);
return Results.Problem(title: "The book page is temporarily unavailable", statusCode: StatusCodes.Status503ServiceUnavailable);
}
GetStockAsync gRPC çağrısına 500 ms'lik bir deadline veriyor ve RpcException'da null dönüyor. Sonuç:
{"isbn":"9780141439587","title":"Emma: A Novel","author":"Jane Austen","category":"Classics","price":199.90,"currency":"TRY","publishedYear":1815,"description":"A young woman in a small English village keeps arranging other people's marriages.","stock":{"status":"InStock","available":17}}
HTTP 200 in 0.187830s
İlk istek 188 ms sürdü; sonraki üç istek 4–6 ms. Önce Tahmin Et'in ilk sorusu: Inventory'yi durdurduk.
{"isbn":"9780141439587","title":"Emma: A Novel", ... "stock":null}
HTTP 200 in 0.533609s
[bff] Stock for 9780141439587 unavailable: DeadlineExceeded
Sayfa 533 ms'de geldi: 500 ms'lik deadline artı biraz. Kısmi hata tasarlandığı gibi çalıştı. İkinci soru daha öğretici. Kodun ilk sürümünde catalogBudget yoktu ve yalnız HttpRequestException yakalanıyordu. Catalog'u durdurduk:
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An error occurred while processing your request.","status":500, ...}
HTTP 500 in 30.013406s
[bff] Resilience event occurred. EventName: 'OnTimeout', Source: '-standard//Standard-AttemptTimeout', Operation Key: '', Result: ''
[bff] Resilience event occurred. EventName: 'OnRetry', Source: '-standard//Standard-Retry', Operation Key: '', Result: 'The operation didn't complete
within the allowed timeout of '00:00:10'.'
[bff] Polly.Timeout.TimeoutRejectedException: The operation didn't complete within the allowed timeout of '00:00:30'.
Otuz saniye ve bir 500. ServiceDefaults'taki standard resilience handler her denemeye 10 saniye veriyor, GET'i tekrar ediyor ve toplamda 30 saniyede vazgeçiyor; vazgeçtiğinde fırlattığı TimeoutRejectedException bir HttpRequestException değil. 05'te gördüğümüz gibi Aspire'ın proxy'si kapalı bir project'e giden bağlantıyı kabul edip cevapsız bırakıyor; production'da kapalı bir pod genellikle bağlantıyı hemen reddeder, ama yavaş bir Catalog aynı 30 saniyeyi yaşatır. Varsayılanlar bir service-to-service çağrısı için makul; bir kullanıcının beklediği sayfa için değil. Sayfaya kendi 2 saniyelik bütçesini verdik ve iptali de yakaladık:
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.4","title":"The book page is temporarily unavailable","status":503,"traceId":"00-b61b671a789f3ab637fa3d5bcd3f8d17-58aa7be0f5ac9047-03"}
HTTP 503 in 2.052233s
Aggregation'ın BFF'de olmasının sebebi, bir ekranın ihtiyacına göre şekillenmesi. İçinde iş kuralı yok: iki cevabı birleştiriyor ve biri eksik olduğunda ekranda ne görüneceğine karar veriyor. "Stok 3'ün altındaysa indirim gösterme" gibi bir kural buraya değil, sahibi olan service'e ait.
Timeout ve Rate Limit
Proxy route'larına da bir zaman sınırı gerekiyor. İlk denemede YARP'ın route bazlı "Timeout": "00:00:05" ayarını kullandık; ASP.NET Core'un request timeout middleware'iyle çalışıyor. Catalog kapalıyken proxy route'u 5 saniyede cevap verdi, ama durum kodu şaşırtıcıydı: HTTP 400 in 5.009732s. YARP'ın kaynak koduna baktık. Middleware isteği iptal ettiğinde YARP bunu HttpContext.RequestAborted üzerinden görüyor, istemcinin gittiğini sanıyor ve 400 yazıyor. Tarayıcı kendi isteğini hatalı sanan bir 400 aldığında yanlış yere bakar. Zaman sınırını cluster'ın HttpRequest.ActivityTimeout ayarına taşıdık; bu sınırı YARP'ın kendisi uyguluyor:
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.5","title":"Gateway Timeout","status":504,"traceId":"00-4d4eb92f60b1f4f7469339c8d31a553c-f435fbe0e71d9a3f-03"}
HTTP 504 in 5.016410s
ActivityTimeout toplam süre değil, sessizlik süresi: hedef service veri gönderdikçe sıfırlanıyor. Bizim kısa JSON cevaplarımız için ikisi aynı sonucu veriyor; uzun bir dosya indirme route'unda farkı önemli olur. İki ayarın ilişkisi Microsoft Learn'deki YARP Request Timeouts sayfasında.
Sipariş vermek BFF'nin en pahalı route'u: arkasında bir saga, stok rezervasyonu ve ödeme var. orders-place route'una ASP.NET Core'un rate limiter'ı ile bir sınır koyduk: istemci adresi başına 10 saniyede 5 sipariş.
// Sipariş vermek en pahalı route: istemci adresi başına 10 saniyede en fazla 5
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("place-order", context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions { PermitLimit = 5, Window = TimeSpan.FromSeconds(10), QueueLimit = 0 }));
});
POST 1 -> HTTP 202
POST 2 -> HTTP 202
POST 3 -> HTTP 202
POST 4 -> HTTP 202
POST 5 -> HTTP 202
POST 6 -> HTTP 429
POST 7 -> HTTP 429
Rate limit'in BFF'de olması, Ordering'in kendini korumasının yerini tutmuyor. BFF'yi atlayan bir istemci (başka bir iç service, bir script) Ordering'e doğrudan gidebilir; 10'da bunu authentication ile kapatacağız. Gateway'deki cross-cutting concern'lerin ayrıntısı API Gateway ve YARP serisinin dördüncü yazısında.
Üretimde Dikkat
Rate limit'in anahtarı gerçek istemci olmalı. BFF bir load balancer'ın arkasındaysa RemoteIpAddress load balancer'ın adresidir ve bütün kullanıcılar aynı bucket'ı paylaşır. ForwardedHeaders middleware'i yalnız güvenilen proxy'lerden gelen header'ları kabul edecek şekilde ayarlanmalı. 10'dan sonra anahtar oturumdaki kullanıcı olabilir.
Bu limiter bellekte çalışıyor. BFF'nin üç kopyası varsa limit fiilen üç katı olur. Tutarlı bir global limit gerekiyorsa sayaç Redis gibi paylaşılan bir yerde tutulur; çoğu zaman instance başına limit yeterli bir koruma.
CORS ayarı yok ve olmaması bilinçli. Angular dosyaları BFF'nin wwwroot'undan geliyor, API çağrıları aynı origin'e gidiyor. 11'de Angular build çıktısını buraya koyduğumuzda tarayıcı tek bir origin görecek.
BFF'nin kendisi de bir service. Her istek ondan geçtiği için onun log'u ve trace'i bir siparişin yolunu izlemenin başlangıç noktası; 12'de dağıtık trace'leri buradan başlatacağız.
Sık Düşülen Tuzaklar
Gateway'e iş kuralı koymak. Fiyat hesabı, stok eşiği ya da sipariş doğrulaması BFF'de büyümeye başlarsa BFF ikinci bir monolith olur ve her ekip değişikliği için BFF ekibini bekler.
Aggregation'a bütçe vermemek. Alt çağrıların varsayılan timeout'ları service-to-service iletişim için ayarlanmış; kullanıcının beklediği bir endpoint kendi, daha kısa bütçesini koymalı.
Alt çağrıları sırayla beklemek. await catalog sonra await inventory yazmak sayfa süresini iki çağrının toplamı yapar; iki task'ı önce başlatıp sonra beklemek yeterli.
Response header'larındaki iç adresleri unutmak. Location, Link ve redirect'ler service'in kendi adresini taşır; proxy onları çevirmezse istemci iç ağa yönlenir.
Timeout'un durum kodunu kontrol etmemek. Yukarıdaki 400 gibi yanlış bir kod, alarmları ve istemcinin retry kararını yanlış yönlendirir.
Küçük Bir Deneme
★☆☆ /api/catalog/categories'i BFF üzerinden çağır ve cevap header'larına bak. Catalog'un output cache'inden gelen cevap BFF'den geçerken hangi header'lar korunuyor?
★★☆ Kitap sayfasına üçüncü bir kaynak ekle: Ordering'den müşterinin bu kitabı daha önce alıp almadığı. Bu bilgi kullanıcıya özel; 10'dan önce mi sonra mı eklemek mantıklı? Inventory gibi opsiyonel mi, Catalog gibi zorunlu mu?
★★★ Rate limiter'ı instance'lar arasında paylaşılan hâle getir: sayacı Redis'te tutan bir PartitionedRateLimiter yaz, BFF'den iki kopya çalıştır ve limitin toplamda 5 kaldığını ölç.
BFF artık Angular'ın tek giriş noktası, ama kim olduğunu bilmiyor: siparişi veren müşteri hâlâ istek body'sinde yazıyor ve Catalog'un yönetim endpoint'leri herkese açık. Bir sonraki yazıda Keycloak realm'ini kuruyoruz: BFF'de OIDC ile cookie oturumu, her service'te JWT (JSON Web Token; imzalı ve kendi bilgisini taşıyan token formatı) doğrulaması, kullanıcının token'ını Ordering'e taşımak, Inventory için client credentials ve event'lerle akan kimlik.