Bir kalıbı doğru uyguladığımı sanıyordum. Yıllar sonra açıp baktığımda kalıp yerli yerindeydi, gerekçesi ise hiç gelmemişti — ve o boşlukta bir de sıralama hatası saklıydı.
1. Kopyalanan kalıp: komutu kimlikle sarmalamak
Yıllar önce, tanınmış bir referans mikroservis örneğinden bir kalıp aldım:
IdentifiedCommand. Fikir basit ve ilk bakışta çok mantıklı. Bir komutu doğrudan
göndermezsin; onu bir istek kimliği (Guid) taşıyan bir request envelope içine koyarsın.
Envelope’u işleyen handler önce “bu kimlik daha önce işlendi mi?” diye bakar. İşlendiyse komutu hiç
çalıştırmaz, kaydedilmiş sonucu döner. İşlenmediyse kimliği kaydeder ve iç komutu
çalıştırır.
Bu, dağıtık sistemlerin en gerçek problemlerinden birine cevap veriyor: at-least-once teslimat. Kullanıcı butona iki kez basar, tarayıcı isteği tekrarlar, message broker aynı mesajı iki kez getirir. Sipariş iki kez oluşmasın diye bir tekilleştirme (dedup) noktası gerekir.
Ben de kalıbı olduğu gibi taşıdım. Envelope tipi, envelope handler’ı, kimlik tablosu — hepsi yerindeydi. Sorgulamadığım tek şey şuydu: envelope içindeki kimlik nereden geliyor?
2. Gerekçe taşınmayınca ne oluyor
Kalıbın çalışması tek bir şarta bağlı: kimliği isteği yapan taraf üretir ve tekrar denemede aynısını gönderir. Kimlik, isteğin dışından, çağıranın gönderdiği envelope ile gelmelidir. Aynı işi anlatan iki farklı istek ancak aynı kimliği taşıdığında “aynı istek” sayılabilir.
Bendeki kodda o üreten taraf hiç yoktu. Ne form tarafında anahtar üreten bir yer vardı,
ne de gelen mesajın kendi kimliğini envelope içine geçiren bir satır. Envelope oluşturulurken
kimlik yerinde Guid.NewGuid() duruyordu. Yani her çağrıda yepyeni bir kimlik
üretiliyor, tabloya yazılıyor ve “daha önce görülmedi” cevabı alınıyordu. Sonuç: kod
her istekte bir satır daha yazan, hiçbir şeyi engellemeyen bir tören.
Bunun izini temizlik sırasında kodun içine not olarak da düştüm — event tüketen bir handler’da, gerçek idempotency istenirse ne yapılması gerektiğiyle birlikte:
// Eski IdentifiedCommand sarmasi Guid.NewGuid() kullaniyordu — her teslimde yeni id,
// yani fiilen dedup yapmiyordu. Gercek idempotency istenirse: RequestContext.RequestId = @event.Id
Burada dikkat edilecek şey, hatanın “yanlış kod” olmaması. Kod referansındakiyle aynıydı. Yanlış olan, kalıbın var oluş şartının benim bağlamımda karşılanmamasıydı. Kalıbın şekli taşınmıştı, gerekçesi taşınmamıştı. Ve gerekçesi olmayan koruma, koruma değil süstür — üstelik zararlı bir süs, çünkü “bizde idempotency var” cümlesini kurdurur.
Bu, ilk yazıdaki soruya (bu kuralı unutan ne yaşar?) yeni bir cevap: burada kural unutulmadı. Kural yerindeydi, işliyordu, testten geçiyordu. Taşınmayan şey gerekçeydi — ve gerekçesiz taşınmış kural, unutulmuş kuraldan daha tehlikelidir, çünkü unutulmuş kural en azından yokluğuyla belli olur.
3. Kalıbın içindeki sıralama hatası
Kalıbı sökerken ikinci ve daha ilginç bir şey çıktı. Sıralama.
Envelope modelinde akış şudur: önce kimliği kaydet, sonra iç komutu çalıştır. Doğrulama (validation) ise iç komutun içindedir; yani anahtar, doğrulamadan önce tüketilir.
Şimdi gerçek bir kullanıcı senaryosu koyalım: kullanıcı formu doldurur, bir alanı eksik
bırakır, gönderir. Anahtar tüketilir, komut çalışır, doğrulama hata döner. Kullanıcı
hatayı düzeltir ve aynı isteği tekrar gönderir. Doğru davranış: bu istek işlensin,
çünkü ilkinde hiçbir şey olmadı. Kalıbın davranışı: anahtar zaten yanmış olduğu için
Conflict — ya da tasarıma göre daha kötüsü, ilk çağrının “sonucu” olarak kaydedilmiş
sahte bir başarı.
Peki bu neden referans projede kimsenin gözüne batmıyor? Çünkü orada istemci her denemede yeni bir kimlik üretiyor. Retry ile ilk gönderim birbirinden ayırt edilemiyor, dolayısıyla “aynı anahtarla dönmek” diye bir durum hiç oluşmuyor. Yani bug’ı gizleyen şey, kalıbı zaten anlamsızlaştıran şeyin ta kendisi. İki kusur birbirini örtüyor: kimlik rastgele olduğu için dedup çalışmıyor, dedup çalışmadığı için sıralama hatası görünmüyor.
Bunun bende çıkardığı genel ders şu: bir kalıbı ancak onu bozacak senaryoyu kurabildiğinde anlamış sayılırsın. “İki kez gönderilirse ne olur?” sorusunu sorduğumda kalıp doğru görünüyordu. “Önce hatalı, sonra düzeltilmiş olarak iki kez gönderilirse ne olur?” sorusunu sorduğumda çöktü.
4. Yerine ne koydum: envelope değil pipeline
Elemeyle başlayayım, çünkü ilk seçenek kalıbı tamir etmekti.
- Envelope kalsın, kimliği düzelt. Çalışırdı. Ama envelope modelinin kalıcı bir bedeli var: her çağrı noktası “sarmalamayı unutmamak” zorunda. Bu tam olarak ilk yazıda kovaladığım şey — koruması insanın hafızasına bağlı bir kural. Üstelik envelope, komutun tipini değiştirdiği için pipeline’daki diğer katmanların (loglama, doğrulama) gördüğü şey gerçek komut değil, envelope oluyor.
- Her handler’ın başına guard yazmak. Tekrar eden ve unutulabilir kod. Yalnız iki yerde meşru buldum: bunu aşağıda ayıracağım.
- Envelope’u kaldırıp işi pipeline’a vermek. Seçtiğim bu.
Yeni model iki parçadan ibaret. Birincisi, bir marker interface: komut IIdempotency
implement ediyorsa korunuyor, etmiyorsa hiç dokunulmuyor. Yani koruma bir çağrı adımı
değil, komutun tipinde yazan bir özellik. İkincisi, bu işareti gören bir pipeline
behavior; anahtarı RequestContext.RequestId‘den okuyor, HTTP tarafında bunun kaynağı
Idempotency-Key header’ı.
Asıl karar ise sırada. Pipeline’daki dizilim ve gerekçesi tek bir yerde yazılı:
| Sıra | Behavior | Kapsam |
|---|---|---|
| 1 | LoggingBehavior | tüm request’ler |
| 2 | ValidatorBehavior | tüm request’ler |
| 3 | IdempotencyBehavior | yalnız IIdempotency işaretli command’lar |
Gerekçe satır satır şöyle: loglama redleri de görsün diye önde; doğrulama ucuz ve yan etkisiz olduğu için ondan hemen sonra; idempotency ise anahtar geçersiz istekte yanmasın diye en sonda. Kural tek cümleyle: anahtar yalnız doğrulamayı geçen istekte tüketilir. Üçüncü bölümdeki senaryo böylece kapanıyor — kullanıcı hatasını düzeltip aynı anahtarla döndüğünde istek işleniyor, çünkü ilk deneme anahtarı hiç harcamamıştı.
Bu sıralamanın nerede yazılı olduğu da bir karar. Servislerin kendi bağlama noktaları behavior’ları bilmiyor; sıra, ortak kayıt metodunun içinde bir kez kuruluyor. Beş serviste tekrarlanan bir liste olsaydı, bugün düzelttiğim hatayı yarın bir serviste yanlış sırayla geri getirebilirdim.
Guard’ı elle yazdığım yer ise mesaj tüketimi. Broker en az bir kez teslim ettiği için tüketici tarafı zaten kendi doğal anahtarına sahip; orada ayrı bir altyapıya gerek yok, upsert’in kendisi idempotent:
public Task Handle(RequestReceivedIntegrationEvent @event)
{
var request = _repository.FindByRequestId(@event.RequestId);
if (request == null)
request = new RequestRecord(@event.RequestId, @event.SourceService!, ...);
else if (!request.IsPending)
return Task.CompletedTask; // mezar tasi: kapanmis kayit yeniden acilmaz
request.SetContext(@event.GroupKey, @event.RequesterKind, ...);
_repository.Save(request);
return Task.CompletedTask;
}
Ayrım şu: HTTP tarafında “aynı istek” tanımı ancak istemcinin verdiği anahtarla kurulabilir, bu yüzden altyapı gerekir. Mesaj tarafında ise “aynı istek"in tanımı verinin kendisinde zaten var — orada altyapı eklemek, var olan bir kimliği görmezden gelmek olurdu.
5. Açık bıraktığım uç
Dürüst olayım: bu iş yarım. Backend hazır — marker, behavior, sıra, header sözleşmesi. Anahtarı üreten ön yüz ayağı yapılmadı. Yani bugün sistem, ilk günkü kadar korumasız.
Fark, korumasızlığın bilinerek korumasız olması. Kayıtta yazılı olan şey şu: form başına
bir anahtar üretilecek; hata veya timeout’ta anahtar form üzerinde duracak (retry aynı
anahtarla gitsin), başarıda silinecek (yeni gönderim yeni anahtar). Bir de bilinen zorluk
not edildi: tek bir form gönderimi birden çok API çağrısı yapabildiği için anahtarın düz
kopyalanması Conflict üretir; anahtar + method + path’ten deterministik türetilmesi
gerekir.
Bu, benim “bugün yapma, reçetesi hazır olsun” dediğim şeyin somut hali. İlk versiyondan tek farkı da tam olarak bu: o zaman elimde çalışmayan bir koruma vardı ve çalıştığını sanıyordum; şimdi elimde koruma yok ve yokluğunun tetikleyici koşuluyla planı yazılı.
Karar
Referans projeler kod okumak için değerlidir; kopyalamak için değil. Bir kalıbı alırken sorduğum soru artık “bu nasıl çalışıyor” değil, üç aşamalı:
- Bu kalıbın var oluş şartı ne? (Burada: kimliği isteği yapan taraf üretir.)
- O şart bende sağlanıyor mu? (Hayır — üreten taraf hiç yoktu.)
- Bu kalıbı ne bozar? (Doğrulamadan dönen ve düzeltilip tekrar gönderilen istek.)
Üçüncü soruyu referans proje benim yerime cevaplamıyor, çünkü onun bağlamında o senaryo hiç oluşmuyor. Kalıbın “üretimde kanıtlanmış” olması, benim bağlamımda kanıtlanmış olduğu anlamına gelmiyor.
Şekil taşınabilir bir şeydir; gerekçe değildir. Gerekçe sende yoksa, şekli de taşıma.
Backend/.NET tarafında, mimari ağırlıklı işler arıyorum. Bu yazı, sıfırdan kurduğum bir .NET sisteminde verdiğim kararların defterinden — kod örnekleri gerçek, isimler nötrleştirildi.