Angular Frontend: Eventual Consistency'yi UI'da Yönetmek ve Sipariş Durumunu Canlı Göstermek
Sipariş 202 ile kabul edildiğinde UI "onaylandı" değil "onay bekleniyor" demeli; durum değişikliği Notifications'tan SignalR ile push edilir ama sayfa siparişi her push'ta Ordering'den yeniden okur. Angular 22 uygulaması BFF'nin arkasında tek origin'den sunuluyor; push'un POST cevabından önce gelmesi, Notifications kapalıyken polling'e düşmek, Redis ile tek seferlik e-posta ve stok, ödeme ve fiyat hataları gerçek tarayıcıda denendi.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Aspire 13.6, Angular 22.2 (TypeScript 6.0), @microsoft/signalr 11.0, MailKit 5.0 ve Mailpit 1.33 ile derlenip denenmiştir.
Önce Tahmin Et
Ada sepetindeki Emma için "Place order"a basıyor. BFF (Backend for Frontend; Angular'a özel ara backend) 202 Accepted dönüyor, Angular sipariş sayfasına geçiyor ve Notifications service'i siparişin durumunu SignalR ile tarayıcıya push ediyor. Hangisi önce gelir: POST /api/orders'ın cevabı mı, "siparişin alındı" push'u mu? İkinci soru: sipariş onaylandığı anda Notifications kapalıysa ekranda ne görünür ve kullanıcı onayı ne zaman görür? Cevaplar gerçek bir tarayıcıyla yaptığımız denemelerde.
Angular BFF'nin Arkasında: Tek Origin
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) arayüzü web/bookstore-web klasöründe bir Angular 22 uygulaması. Angular 22.2, TypeScript 6.0 istiyor; proje ng new ile standalone component'ler, signal'ler ve zone.js'siz çalışacak şekilde oluştu. Uygulamanın kendi sunucusu yok. Build çıktısı BFF'nin wwwroot'una gidiyor ve BFF onu API ve SignalR hub'ı ile aynı origin'den sunuyor:
"build": {
"builder": "@angular/build:application",
"options": {
"outputPath": {
"base": "../../src/Services/Bff/Bookstore.Bff/wwwroot",
"browser": ""
},
"browser": "src/main.ts",
Tek origin üç şeyi basitleştiriyor. CORS (Cross-Origin Resource Sharing; tarayıcının başka bir origin'e istek atmasına izin veren kurallar) ayarı gerekmiyor. 10'daki bookstore.session cookie'si her isteğe kendiliğinden ekleniyor. Angular kodunda bir API adresi yok, bütün URL'ler göreli: /api/catalog/books, /hubs/orders.
Lokalde her değişiklikte elle build almak istemiyoruz. AppHost'a Angular'ı bir JavaScript resource'u olarak ekledik; Aspire önce npm install'ı çalıştırıyor, sonra npm run watch ile ng build --watch'ı başlatıyor:
// Lokalde "npm run watch" her değişiklikte uygulamayı BFF'nin wwwroot'una yeniden build ediyor ve BFF onu
// API ile aynı origin'den sunuyor. Production build'i BFF'nin image'ına giriyor (14. yazı).
if (builder.ExecutionContext.IsRunMode)
{
builder.AddJavaScriptApp("web", "../../web/bookstore-web", "watch")
.WithParentRelationship(bff);
}
Dashboard'da web-installer resource'u Finished, web ise Running görünüyor. BFF tarafında 09'da koyduğumuz MapStaticAssets'ı UseStaticFiles ile değiştirdik. MapStaticAssets yalnız BFF build edildiği anda wwwroot'ta olan dosyaları biliyor; ng build --watch ise yeni dosyaları BFF çalışırken yazıyor. Cache header'larını da kendimiz veriyoruz: index.html hiç cache'lenmiyor, production build'inde adında hash olan dosyalar (main-UPE27H4M.js) bir yıl cache'leniyor. Gerçek cevaplar: / ve /orders/123 için Cache-Control: no-cache, development build'inin hash'siz main.js'i için header yok. Bilinmeyen yollar MapFallbackToFile("index.html") ile Angular router'ına bırakılıyor.
BFF, /api altındaki GET dışı her istekte X-CSRF: 1 header'ı istiyordu (10). Angular'da bunu bir interceptor ekliyor; ikinci interceptor ise süresi dolmuş bir oturumu yakalıyor:
// BFF, durum değiştiren /api çağrılarını bu header olmadan reddediyor (10. yazı). Başka bir sitedeki
// form ya da resim özel header ekleyemiyor; HttpClient ekleyebiliyor.
export const csrfHeader: HttpInterceptorFn = (request, next) =>
request.method === 'GET' || request.method === 'HEAD'
? next(request)
: next(request.clone({ setHeaders: { 'X-CSRF': '1' } }));
// /api'den gelen 401, oturumun (ya da arkasındaki access token'ın) gittiği anlamına geliyor: yeniden giriş yap ve buraya dön.
// /bff/user hariç: orada 401 yalnız "henüz giriş yapılmamış" demek.
export const sessionExpired: HttpInterceptorFn = (request, next) => {
const session = inject(Session);
return next(request).pipe(
catchError((error: unknown) => {
if (error instanceof HttpErrorResponse && error.status === 401 && request.url.startsWith('/api/')) {
session.signIn(location.pathname + location.search);
}
return throwError(() => error);
})
);
};
signIn tam sayfa bir yönlendirme yapıyor (/bff/login?returnUrl=...): OIDC (OpenID Connect; OAuth 2.0 üstündeki kimlik katmanı) akışı Angular'da değil, BFF ile Keycloak arasında. Angular kullanıcı hakkında yalnız /bff/user'ın verdiği adı, e-postayı ve rolleri biliyor.
Katalog ve Sepet: httpResource ve Signal'ler
Veri yüklemenin büyük kısmı httpResource ile. Angular serisinin "Angular 20: Stable Signal API'leri, httpResource ve Yeni Dosya Adlandırma" yazısında anlattığımız gibi isteği bir signal'in fonksiyonu olarak tanımlıyoruz; arama değişince önceki istek iptal ediliyor, yenisi başlıyor:
protected readonly search = signal('');
// İstek signal'in fonksiyonu: yeni bir arama önceki isteği iptal edip yenisini başlatıyor
protected readonly books = httpResource<PagedResponse<BookSummary>>(() => ({
url: '/api/catalog/books',
params: { search: this.search(), pageSize: 20 }
}));
Kitap sayfası 09'daki aggregation endpoint'ini (/api/books/{isbn}) kullanıyor ve stoğun null gelebileceğini biliyor. Bu bir teori değil: sistem yeni açıldıktan sonraki ilk kitap sayfasında BFF'nin log'unda Stock for 9780141439587 unavailable: DeadlineExceeded satırı çıktı ve sayfa "Stock information is not available right now." gösterdi. İlk gRPC çağrısı, BFF'nin Keycloak'tan client credentials token'ı alması ve Inventory'nin ilk isteği 500 ms'lik deadline'a sığmadı. Sayfa yine geldi, sepete ekleme düğmesi de çalıştı; ikinci yüklemede stok In stock olarak göründü. Kısmi hatayı 09'da BFF'de tasarlamıştık, UI'da da karşılığı olmalı.
Sepetin sahibi bir service değil, tarayıcı. Sipariş verilene kadar hiçbir service'in sepetle ilgisi yok; sepet bir signal ve her değişiklikte localStorage'a yazılıyor:
// Sepet yalnız tarayıcıda yaşıyor: müşteri siparişi verene kadar hiçbir service onun sahibi değil
@Injectable({ providedIn: 'root' })
export class Cart {
readonly lines = signal<CartLine[]>(read());
readonly count = computed(() => this.lines().reduce((sum, line) => sum + line.quantity, 0));
readonly total = computed(() => this.lines().reduce((sum, line) => sum + line.unitPrice * line.quantity, 0));
constructor() {
// Her değişiklik localStorage'a yazılıyor; sepet sayfa yenilemesinden ve OIDC yönlendirmesinden sağ çıkıyor
effect(() => localStorage.setItem(storageKey, JSON.stringify(this.lines())));
}
localStorage burada süs değil. Giriş yapmamış Ada "Sign in to order"a bastığında tarayıcı Keycloak'a gidip geri geliyor; Angular uygulaması baştan yükleniyor ve bellekteki her şey kayboluyor. Denemede geri döndüğümüzde localStorage'daki tek anahtar bookstore.cart'tı ve sepette Emma duruyordu. Tarayıcıda token yok; üç cookie'nin üçü de httpOnly=true.
Checkout: 202 Gelince "Onaylandı" Yazmamak
07'den beri Ordering siparişe 202 Accepted dönüyor: sipariş kaydedildi, ama stok ve ödeme saga'da sonra belirlenecek. Optimistic UI (sunucu cevabını beklemeden başarıyı göstermek) bir beğeniye ya da sepete eklemeye uygun; yanlış çıkarsa geri almanın bedeli küçük. Siparişte değil. Stok yoksa ya da kart reddedilirse "siparişin onaylandı" demiş olursun; müşteri o sırada sayfayı kapatmış ve onay e-postası beklemeye başlamış olabilir. Bu yüzden 202'nin karşılığı "siparişini aldık, stok ve ödemeyi kontrol ediyoruz":
this.http.post('/api/orders', { items }, { observe: 'response' }).subscribe({
next: response => {
// 202 Accepted: sipariş var, sonucu henüz belli değil. Location nereden izleneceğini söylüyor.
const location = response.headers.get('Location') ?? '';
this.cart.clear();
void this.router.navigateByUrl(location.replace('/api/orders/', '/orders/'));
},
error: (error: HttpErrorResponse) => {
this.placing.set(false);
this.problem.set(this.explain(error));
}
});
explain her hata koduna ayrı bir cümle veriyor: 409'da fiyatlar değişmiş, sepet yeni fiyatları alıyor ve müşteri yeni toplamı görüp tekrar karar veriyor; 429'da birkaç saniye beklemesi söyleniyor (09'daki rate limit). Status 0, yani istek ağda kaybolmuşsa, otomatik retry yok. İstek Ordering'e ulaşmış ve sipariş oluşmuş olabilir; Ordering'in POST'u hâlâ Idempotency-Key almıyor (04). Müşteriye önce "Siparişlerim"e bakmasını söylüyoruz.
Notifications: Saga Event'lerinden SignalR Push'una
Notifications 06'dan beri OrderPlaced, OrderConfirmed ve OrderCancelled'ı dinleyip log yazıyordu. Şimdi bir SignalR hub'ı var. Hub'ın istemcinin çağırabileceği method'u yok; yalnız sunucu gönderiyor. Mesajı alacak kullanıcıyı bulmak için 10'da event'lere eklediğimiz CustomerId'yi kullanıyoruz:
// Hub'da istemcinin çağırabileceği method yok: yalnız sunucu push ediyor. Kullanıcının kim olduğu
// istemciden değil, BFF'nin taşıdığı access token'dan geliyor.
[Authorize]
public sealed class OrderStatusHub : Hub<IOrderStatusClient>;
// SignalR varsayılan olarak ClaimTypes.NameIdentifier'a bakıyor; MapInboundClaims = false ile Keycloak'un subject'i "sub".
// Aynı değer event'lerde CustomerId olarak taşınıyor (10. yazı), böylece Clients.User(customerId) bağlantıları buluyor.
public sealed class SubjectUserIdProvider : IUserIdProvider
{
public string? GetUserId(HubConnectionContext connection) => connection.User.FindFirst("sub")?.Value;
}
public static async Task Handle(
OrderCancelled message, IHubContext<OrderStatusHub, IOrderStatusClient> hub, IOrderEmails emails, CancellationToken ct)
{
await hub.Clients.User(message.CustomerId).OrderStatusChanged(new(message.OrderId, "Cancelled", message.Reason));
await emails.SendOnceAsync(message.OrderId, "cancelled", message.CustomerEmail,
"Your Bookstore order was cancelled",
$"Order {message.OrderId} was cancelled: {message.Reason}. If you were charged, the amount will be refunded.", ct);
}
Tarayıcı Notifications'a doğrudan bağlanmıyor. BFF'ye bir YARP (Microsoft'un .NET için reverse proxy library'si) route'u daha geldi; AuthorizationPolicy oturum istiyor ve 10'daki request transform'u kullanıcının access token'ını negotiate isteğine de WebSocket upgrade isteğine de ekliyor. Notifications token'ı diğer service'ler gibi audience ile doğruluyor:
"order-status-hub": {
"ClusterId": "notifications",
"AuthorizationPolicy": "default",
"Match": { "Path": "/hubs/orders/{**rest}" }
}
Angular tarafında tek bir OrderStatusHub service'i var: kullanıcı giriş yapınca bağlantıyı açıyor, withAutomaticReconnect() kullanıyor ve gelen her push'u bir signal'e yazıyor. SignalR'ın kendisi (hub, kullanıcılar, yeniden bağlanma) Real-time Uygulamalar serisinin dördüncü, beşinci ve yedinci yazılarında.
Push Bir İpucu, Gerçek Ordering'de
Sipariş sayfası push'un içindeki durumu ekrana yazmıyor. Push yalnız "bu sipariş değişti" diyor; sayfa siparişi Ordering'den yeniden okuyor:
// Bu siparişe ait bir push ya da push'ların kaçmış olabileceği bir yeniden bağlanma: siparişi yeniden oku
effect(() => {
const change = this.hub.lastChange();
this.hub.resyncs();
if (!change || change.orderId === this.id()) {
untracked(() => this.order.reload());
}
});
// Canlı bağlantı yok (Notifications kapalı, WebSocket'i düşüren bir proxy): polling'e geç
effect(onCleanup => {
if (this.hub.state() === 'connected' || this.final()) {
return;
}
const timer = setInterval(() => this.order.reload(), 5000);
onCleanup(() => clearInterval(timer));
});
Neden bu kadar dolaylı? Gerçek bir Chromium ile yaptığımız denemenin zaman çizelgesi cevabı veriyor (saniyeler test başından itibaren):
2.47 POST /hubs/orders/negotiate?negotiateVersion=1 -> 200
2.47 websocket /hubs/orders
2.55 frame {"type":1,"target":"OrderStatusChanged","arguments":[{"orderId":"01a1dabc-434d-7193-ae84-ddff1562c2d6","status":"Pending","reason":null}]}
2.57 POST /api/orders -> 202
2.60 GET /api/orders/01a1dabc-434d-7193-ae84-ddff1562c2d6 -> 200
2.60 UI: We received your order and are confirming stock and payment…
4.03 frame {"type":1,"target":"OrderStatusChanged","arguments":[{"orderId":"01a1dabc-434d-7193-ae84-ddff1562c2d6","status":"Confirmed","reason":null}]}
4.04 GET /api/orders/01a1dabc-434d-7193-ae84-ddff1562c2d6 -> 200
4.05 UI: Confirmed. A confirmation e-mail is on its way.
Önce Tahmin Et'in ilk cevabı: push. "Pending" push'u, POST'un cevabından 20 ms önce geldi. Ordering outbox'ı commit'ten hemen sonra RabbitMQ'ya boşaltıyor, Notifications mesajı işliyor ve push, YARP'ın 202 cevabını tarayıcıya iletmesinden önce yetişiyor. Sistem ısınmışken onay 0,13 saniyede geldiğinde (07) Confirmed push'u da sipariş sayfası açılmadan gelebilir. Sayfa push'a güvenseydi o push'u kaçırır ve "bekleniyor"da kalırdı. Push'u ipucu, Ordering'i single source of truth saymak bu yarışı ortadan kaldırıyor: sayfa açılışta siparişi zaten okuyor, sonraki her push'ta yeniden okuyor. Aynı push'un iki kez gelmesi de zararsız; en fazla bir GET fazla atılıyor.
E-posta: Fake SMTP ve İki Kez Gelen Event
Onay ve iptal e-postaları için AppHost'ta bir Mailpit container'ı var: SMTP (Simple Mail Transfer Protocol; e-posta gönderme protokolü) ile gelen her e-postayı kabul ediyor, kimseye iletmiyor, bir web arayüzünde ve API'de gösteriyor. Notifications MailKit ile gönderiyor; MailKit 5.0 (yazının yazıldığı haftada çıktı) SendAsync'e mesajın kendisini değil bir SendRequest veriyor. Asıl soru başka: Notifications'ın veritabanı ve inbox'ı yok, RabbitMQ ise en az bir kez teslim ediyor. Aynı OrderConfirmed iki kez gelirse müşteri iki e-posta alır. 01'de Notifications'a Redis vermiştik; burada kullanıyoruz:
IDatabase db = redis.GetDatabase();
string key = $"notifications:email:{orderId}:{kind}";
// SET NX: anahtarı yalnız ilk teslimat alabiliyor. 7 gün tutuluyor, her yeniden teslimden çok daha uzun.
if (!await db.StringSetAsync(key, "sending", TimeSpan.FromDays(7), When.NotExists))
{
logger.LogInformation("E-mail {Kind} for order {OrderId} already sent, skipping", kind, orderId);
return;
}
Gönderim başarısız olursa anahtar siliniyor ve exception Wolverine'e gidiyor; SMTP hataları için 1, 5 ve 30 saniyelik bir retry kuralı var. Denemek için RabbitMQ'nun management API'si ile Grace'in siparişinin OrderConfirmed mesajını aynı içerik ve yeni bir message id ile bookstore.ordering exchange'ine bir kez daha yayınladık:
[notifications] E-mail confirmed for order 01a1dabb-c522-7cb9-b9eb-8a08876f1532 sent to grace@example.com
[notifications] E-mail confirmed for order 01a1dabb-c522-7cb9-b9eb-8a08876f1532 already sent, skipping
Mailpit'te o sipariş için tek e-posta vardı. Bedeli de yazalım: anahtar gönderimden önce kuruluyor, yani Notifications e-postayı gönderirken çökerse anahtar kalır ve e-posta hiç gitmez. Bildirim e-postası için bu ihtimali tekrar gelen e-postaya tercih ettik; fatura gibi kaybolmaması gereken bir belge için outbox ve inbox'lı bir service gerekirdi.
Hata Deneyimi: Stok, Ödeme, Fiyat ve Kopan Bağlantı
İptal edilen bir siparişte müşteri nedenini görmeli. OrderResponse'a CancellationReason alanını ekledik; oasdiff bunu kırıcı değil, iki info olarak raporladı (response-required-property-added). Üç hata yolunu aynı tarayıcı oturumunda denedik: stokta 3 tane olan War and Peace'ten 4 adet, sahte ödeme sağlayıcısının reddettiği 5.169 TRY'lik (Türk lirası) bir sipariş ve sepette eski fiyatıyla (379 yerine 389) duran Middlemarch:
1.12 [stock] UI: Cancelled: Out of stock: 9781400079988
1.17 [stock] cart after put back: War and Peace TRY2,756.00
1.35 POST /api/orders -> 202
2.67 [payment] UI: Cancelled: Payment failed: card_declined
2.80 POST /api/orders -> 409
2.81 [price] Some prices have changed. Check the new total and place the order again. | Total: TRY389.00
2.89 POST /api/orders -> 202
4.90 [price] UI: Confirmed. A confirmation e-mail is on its way.
İptal edilen siparişte "Put the books back in the cart" düğmesi kitapları sepete geri koyuyor; sepeti 202'de temizlemenin bedelini bu düğme ödüyor. Mailpit'e iki iptal e-postası düştü, ikisinde de neden yazıyordu.
Son olarak Notifications'ı kapattık. Siparişin beklemede kalması için önce Payments'ı durdurduk, sipariş verdik, 20. saniyede Notifications'ı durdurduk, 46. saniyede Payments'ı, 77. saniyede Notifications'ı başlattık:
19.98 websocket closed
20.01 Live updates: reconnecting | We received your order and are confirming stock and payment… [polling]
25.00 GET /api/orders/{id} -> 200
...
54.99 GET /api/orders/{id} -> 200
55.11 Live updates: reconnecting | Confirmed. A confirmation e-mail is on its way.
77.70 POST /hubs/orders/negotiate -> 200
77.70 websocket opened
77.72 GET /api/orders/{id} -> 200
77.75 Live updates: connected | Confirmed. A confirmation e-mail is on its way.
Önce Tahmin Et'in ikinci cevabı: bağlantı koptuğu anda sayfa 5 saniyelik polling'e geçti ve onayı Payments geldikten birkaç saniye sonra, Notifications hâlâ kapalıyken gösterdi. OrderConfirmed o sırada notifications.order-events queue'sunda bekliyordu; Notifications açılınca işledi, push'u gönderdi ve e-postayı yolladı. Push, bağlantı henüz kurulmadığı için tarayıcıya ulaşmadı; yeniden bağlanınca sayfanın attığı GET bunu telafi etti. Bir not: lokalde Aspire'ın proxy'si kapalı bir project'e giden bağlantıyı açık tutuyor, bu yüzden istemcinin ilk yeniden bağlanma denemesi Notifications gelene kadar bekledi. Production'da kapalı bir pod bağlantıyı hemen reddeder; istemci varsayılan olarak 0, 2, 10 ve 30 saniye bekleyerek dener ve vazgeçer. Sayfa o durumda da (onclose) polling'de kalıyor.
Üretimde Dikkat
Access token beş dakikada bitiyor, ama 401 beşinci dakikada gelmiyor. 10'daki ★☆☆ alıştırmasını tarayıcıyla denedik: Ada giriş yaptıktan 310 saniye sonra "My orders" yine 200 döndü. Sebep JwtBearer'ın TokenValidationParameters.ClockSkew'u: sunucular arasındaki saat farkı için varsayılan olarak 5 dakika tolerans tanıyor, yani süresi dolmuş token 5 dakika daha kabul ediliyor. Testi 610 saniyeyle tekrarladık:
0.92 websocket opened
610.90 hub: Live updates: connected
611.07 GET /api/catalog/books -> 200
611.15 GET /api/orders -> 401
611.20 GET /bff/login -> 302
611.30 (keycloak) GET /realms/bookstore/protocol/openid-connect/auth -> 302
611.33 (keycloak) GET /realms/bookstore/login-actions/authenticate -> 200
611.36 POST /signin-oidc -> 302
611.38 GET /bff/user -> 200
611.40 GET /api/orders -> 200
401, Angular'daki interceptor'a düştü ve tarayıcı /bff/login'e gitti. Keycloak'un SSO (Single Sign-On; bir kez giriş yapıp birden fazla uygulamada oturumun sürmesi) oturumu hâlâ açık olduğu için şifre sorulmadı; 250 ms içinde aynı sayfaya geri döndük ve liste yüklendi. Kullanıcı yalnız bir sayfa yenilemesi gördü, ama formda yazdığı bir şey olsaydı kaybolurdu. Asıl çözüm 10'da eksik bıraktığımız token yenileme: BFF refresh token ile access token'ı süresi dolmadan yenilemeli. İkinci gözlem: WebSocket 610 saniye boyunca açık kaldı. Hub token'ı yalnız bağlantı kurulurken doğruluyor; süresi dolan bir kullanıcının push almaya devam etmesini istemiyorsan CloseOnAuthenticationExpiration'ı ya da .NET 11'in authentication refresh desteğini düşün (Real-time Uygulamalar serisinin altıncı ve dokuzuncu yazıları).
Notifications'ın birden fazla kopyası olacaksa backplane gerekir. Clients.User(...) yalnız o instance'a bağlı bağlantıları görür; push'u yapan instance kullanıcının bağlandığı instance değilse mesaj kaybolur. Redis backplane ve sticky session Real-time Uygulamalar serisinin scale-out yazısında. Bizde push yalnız ipucu olduğu ve sayfa polling'e düşebildiği için kayıp ciddi değil, ama "canlı" deneyim bozulur.
WebSocket'in yolu üzerindeki her proxy'nin idle timeout'u var. Load balancer'ların bir kısmı boşta duran bağlantıyı varsayılan olarak bir dakikada kapatıyor; SignalR'ın 15 saniyelik keep-alive'ı bunu çoğu durumda önlüyor. YARP'ta Notifications cluster'ına ActivityTimeout koymadık; varsayılanı 100 saniye ve keep-alive onu da sıfırlıyor.
Lokal kaynaklara dikkat. Bu yazıyı yazarken Docker'ın VM'i başka container'larla paylaşıldığında SQL Server There is insufficient system memory in resource pool 'internal' hatasıyla siparişleri düşürdü. SQL Server container'ına MSSQL_MEMORY_LIMIT_MB=1024, Keycloak'a JAVA_OPTS_KC_HEAP=-Xms128m -Xmx512m verdik; ikisi de varsayılan olarak VM'in tüm belleğini görüp ona göre büyüyor. Kaynak kullanımına 15'te sayılarla döneceğiz.
Sık Düşülen Tuzaklar
202'yi başarı gibi göstermek. "Siparişin onaylandı" yazıp beş saniye sonra "iptal edildi" demek, hiç onay göstermemekten daha kötü bir deneyim.
Push'u tek kaynak saymak. Bağlantı kurulmadan gelen, kopukken gönderilen ya da iki kez gelen push'lar var; durumu her zaman sahibinden (Ordering) yeniden oku.
Ağ hatasında POST'u otomatik tekrar etmek. Idempotency-Key olmadan ikinci istek ikinci sipariş demek.
Token'ı tarayıcıya taşımak. SignalR'ın accessTokenFactory'si cazip görünebilir; BFF'de token tarayıcıya hiç gelmiyor, bağlantı cookie ile kuruluyor ve token'ı BFF ekliyor.
Consumer'da yan etkiyi idempotent yapmamak. Inbox'ı olmayan bir consumer'ın gönderdiği her e-posta, SMS (kısa mesaj) ya da webhook, tekrar gelen mesajda yeniden gider.
Sepeti yalnız bellekte tutmak. OIDC yönlendirmesi sayfayı baştan yüklüyor; sepet ya tarayıcı storage'ında ya da sunucuda durmalı.
Küçük Bir Deneme
★☆☆ Sipariş sayfasındaki durum metnini ekran okuyucular için aria-live="polite" olan bir elemana koy ve "bekleniyor"dan "onaylandı"ya geçişin okunduğunu kontrol et.
★★☆ Ordering'in POST endpoint'ine Idempotency-Key desteği ekle ve Angular'da anahtarı sepet oluştuğunda üret. Ağ hatasından sonra aynı anahtarla tekrar gönderilen istek ne dönmeli?
★★★ Notifications'ı iki kopya çalıştır (WithReplicas(2)) ve push'ların bir kısmının kaybolduğunu ölç. Sonra Microsoft.AspNetCore.SignalR.StackExchangeRedis ile Redis backplane ekleyip aynı ölçümü tekrarla.
Artık bir siparişin yolu tarayıcıdan başlayıp BFF, Ordering, RabbitMQ, Inventory, Payments ve Notifications'tan geçerek tekrar tarayıcıya dönüyor. Bu yolda bir şey yavaşladığında hangi adımın yavaşladığını şimdilik log'ları yan yana koyarak buluyoruz. Bir sonraki yazıda OpenTelemetry ile tek bir siparişi Angular'dan RabbitMQ'ya kadar tek bir trace olarak izliyoruz: HTTP, gRPC, Entity Framework Core (EF Core) ve Wolverine span'ları, mesaj header'ında taşınan trace context ve business metric'ler.