İlginç olan şu ki, Geçen ay, 2025’in Kasım başında İstanbul’da bir fintech demo’su izlerken aynı sahne yine karşıma çıktı: kullanıcı “Öde” düğmesine iki kez bastı, sistem de bunu iki ayrı niyet olarak yorumladı. Can sıkıcı olan şu ki kodun kendisinde teknik bir hata yoktu aslında — mesele, sistemin kararsız anlarda nasıl davrandığıyla ilgiliydi. Hani bazen her şey kağıt üzerinde doğru görünür ama küçük bir gecikme tüm düzeni alt üst eder ya… billing tam olarak öyle bir alan işte.
Doğrusu, Ben bu tip akışları yıllardır hem SaaS projelerinde hem de ödeme entegrasyonlarında izledim (şaşırtıcı ama gerçek). 2023’te Ankara’daki bir ürün ekibiyle çalışırken, timeout yüzünden ikinci kez tahsilat açan bir servis yüzünden tam üç gün boyunca log kovalamıştık — saatler harcadık, sinir bitti, kahve bitti. O deneyimden sonra şunu net kavradım: ücretlendirme tarafında “doğru kod” yazmak tek başına yetmiyor, deterministik davranış gerekiyor. E peki, sonuç ne oldu? Yani sistem sallansa, ağ kopsa, worker çökse bile sonuç aynı noktaya varmalı (şaşırtıcı ama gerçek)
Peki neden?
Neden Çift Çekim Bu Kadar Kolay Oluyor?
Bence, İşin aslına bakarsanız, ödeme akışı tek bir parçadan oluşmuyor. Bir tarafta istemci var, diğer tarafta veritabanı, sonra dış ödeme sağlayıcısı… Arada ağ kopuyor, yanıt gecikiyor, worker bir şekilde çöküyor, kullanıcı da sabredemeyip tekrar tıklıyor (ben de ilk duyduğumda şaşırmıştım). Sonuç? Aynı işlem iki kere tetiklenebiliyor ve sistem bunu zaman zaman meşru iki ayrı istek olarak değerlendiriyor.
Buradaki en sinsi tuzak şu: başarılı ödeme ile başarısız ağ yanıtı birbirinden kolayca ayrılabiliyor. Sağlayıcı parayı çekmiş olabilir ama sunucu o anda yanıt döndüremiyor. Kullanıcı da gayet haklı olarak tekrar deniyor. Açık konuşayım — bu senaryo teoride bayağı basit görünüyor; pratikte ise gerçekten sinir bozucu.
Geçen sene Berlin’de katıldığım küçük bir SaaS buluşmasında bir mühendis şöyle demişti: “Bizde hata yoktu, retry politikası vardı.” Güldük ama acı gerçek tam buydu. Retry mekanizması yanlış (belki yanılıyorum ama) kurgulanınca güvenlik ağı olmaktan çıkıp bizzat tetikleyiciye dönüşüyor. Düşününce trajikomik bir durum.
Üç Katmanlı Savunma Mantığı
Bence, Bu problemi tek hamlede çözmeye kalkmak genelde hayal kırıklığıyla bitiyor. Kağıt üzerinde güzel duran tekil çözümler pratikte çatlıyor, hatta bazen hiç beklenmedik yerlerde. Daha sağlam bir yaklaşım üç katmandan geçiyor: API sözleşmesi, veritabanı sınırı ve yürütme desenleri (inanın bana). Her biri farklı bir deliği kapatıyor.
Lafı gevelemeden söyleyeyim: API’niz değişken davranıyorsa, veritabanınız tekrarları engellemiyorsa. Worker’larınız yarım bırakılmış işleri güvenli şekilde toparlayamıyorsa — çift çekim ihtimali her zaman masada kalır (ben de ilk duyduğumda şaşırmıştım). Kaçış yok.
| Katman | Amaç | Korumaya Aldığı Risk |
|---|---|---|
| L1 — API sözleşmesi | Değişimi kontrol altına almak | Kırılan istemciler, uyumsuz istekler |
| L2 — DB sınırı | Aynı işi iki kez yazmayı engellemek | Mükerrer kayıtlar, çift çekim |
| L3 — Yürütme deseni | Sallantıda bile aynı sonuca varmak | Retry kaosu, worker çökmesi |
L1: Sözleşme Değişmesin Diye CI’ya Kilit Koymak
Dürüst olmak gerekirse, Bence çoğu ekip ilk darbeyi tam burada yiyor. Birisi endpoint’e yeni bir zorunlu alan ekliyor ya da mevcut bir alanın anlamını sessizce değiştiriyor; ardından mobil uygulama eski sürümden istek atınca ortalık birden karışıyor. Billing gibi hassas alanlarda bu tür “küçük” değişiklikler zincirleme etki yaratıyor — bir domino düşünce diğerleri de gidiyor.
Aslında, Bunu ilk kez 2024 baharında kendi test ortamımda yaşadım. İzmir’deki bir e-ticaret panelinde response şemasına ufak bir alan ekledik diye staging ortamında her şey yeşil kaldı,. Eski istemci patladı. O gün şunu anladım: sözleşme dedikleri şey aslında araç güvenlik kemeri gibi çalışıyor — gevşekse araba gidiyor ama siz içeride savruluyorsunuz. Apple, iWork’te Eski Mac Uygulamalarını Neden Sildi? yazımızda bu konuya da değinmiştik.
{
"contract": {
"required_fields": ["amount", "currency", "idempotency_key"],
"breaking_changes": [
"remove_endpoint",
"change_field_type",
"add_required_without_migration"
]
}
}
Bu tür kontrolleri CI aşamasına gömmek bence fena değil, hatta baya iş görüyor. Bilhassa SaaS ekiplerinde hızlı gelişim uğruna kontratın sessizce bozulması çok yaygın bir şey. Enterprise tarafta ise iş daha sert; orada geriye dönük uyumluluk lüks değil, doğrudan zorunluluk haline geliyor. Daha fazla bilgi için ChatGPT bir hayatı nasıl raydan çıkardı: Tehlikeli sessizlik yazımıza bakabilirsiniz.
Bunu biraz açayım.
L2: Veritabanı Sınırı Gerçek Fren Görevi Görüyor
API katmanı ne kadar iyi olursa olsun, veritabanında koruma yoksa olay bitmiyor (buna dikkat edin). Asıl kesin çizgiyi DB çekiyor çünkü. UNIQUE constraint burada sıradan bir indeks değil — bildiğin kapıya konmuş turnike gibi çalışıyor, geçirmek isteyeni geçiriyor, ikincisini ise geri çeviriyor.
Ben olsam business identity için tekilleştirme şartını doğrudan tabloda tutarım. Mesela müşteri + sipariş + idempotency anahtarı birlikte benzersiz olması gerekiyorsa bunu uygulama koduna bırakmam; veritabanına emanet ederim. Kod hata yapabilir… DB ise izin vermez. Basit ama güçlü bir ayrım bu. Linux 7.0 Geldi: Numara Değişti, Asıl Hikâye Başka yazımızda bu konuya da değinmiştik.
- Aynı iş için ikinci insert reddedilir. (bu kritik)
- Dış sağlayıcıdan gelen referans ID saklanır.
- Durum geçişleri geri alınamaz hale getirilir.
- Kayıtlar append-only mantığında tutulursa denetim kolaylaşır. (bu kritik)
Size bir şey söyleyeyim, Küçük startup’ta bu kurgu başta biraz ağır gelebilir, kabul ediyorum. Ama büyüdükçe gerçekten nefes aldırıyor. Kurumsal projede ise böyle bir güvenlik duvarını görmezseniz zaten şaşırırsınız — denetim ekipleri o boşluğu anında yakalar.
Bakın, burayı atlarsanız yazının kalanı anlamsız kalır.
Yürütme Desenleri: Wobble Varsa Akış Dağılmasın
Kendi deneyimimden konuşuyorum, Neyse, gelelim en kritik parçaya. Asıl oyun burada dönüyor diyebilirim. Retry geldiğinde sistem aynı charge_id’yi döndürmeli; async iş gecikirse sonuç yine tek noktaya bağlanmalı; worker ölse bile yeniden başladığında iş yarımdan devam etmeli ama yeni bir işlem üretmemeli.
Bu bölüm biraz mutfak gibidir aslında. Malzemeler ayrı ayrı iyi olsa da yemeği pişiren sıra ve ateştir. Ateşi fazla açarsanız taşar. Az açarsanız çiğ kalır. Billing de öyle — idempotency olmadan retry tehlikeli olur; outbox olmadan dış çağrı ile DB commit’i birbirine dolanır. Doğru kombinasyon lazım. Sihir yok maalesef. Claude’daki “Skills” Neden Prompt Değil, Bağlam Tasarımıdır? yazımızda da bu konuya değinmiştik. Agentic AI: Prompt’tan Özerk Döngülere Geçiş yazımızda da bu konuya değinmiştik.
IDEMPOTENCY-Key Neden Bu Kadar Önemli?
Kullanıcı çift tıkladı diye iki farklı ödeme oluşturulmaması gerekiyorsa niyet kimliğini sabitlemek şart. Idempotency-Key tam olarak bunu yapıyor: aynı anahtar sisteme geldiğinde yeni işlem üretilmiyor, önceki sonuç geri veriliyor. Temiz, net.
Ama dikkat — anahtar metni değil, niyet önemli. Gerçekten önemli mi? Evet, ciddiyim. Çünkü “aynı istek gövdesi” ile “aynı iş niyeti” her zaman birebir örtüşmüyor. Frankfurt’taki bir finans API projesinde çalışan bir arkadaşım sadece request body hash’i kullanmıştı; küçük format farklılıkları yüzünden eşleşmeler kaçmaya başladı, işler karıştı. Sonra business key’e geçince sorunların yarısı uçup gitti. Kulağa basit geliyor ama fark gerçekten büyük — bunu yaşamadan tam anlamıyor insan.
INSERT INTO charges (customer_id, order_id, idempotency_key, amount)
VALUES ($1, $2, $3, $4)
ON CONFLICT (customer_id, order_id) DO UPDATE
SET updated_at = NOW()
RETURNING charge_id;
Outbox ile Dış Servisleri Kontrol Altına Almak
Dış ödeme sağlayıcısını doğrudan transaction içine sokmaya çalışmak çoğu zaman sıkıntı çıkarıyor. DB commit oldu mu olmadı mı derken ağ tarafındaki belirsizlik tüm resmi dağıtıyor. Bu yüzden ben genelde önce DB’ye charge kaydıyla birlikte outbox yazarım, sonra ayrı bir worker dış servisi çağırır. At-least-once modelinin içinden çıkmanın yolu bu (ciddiyim)
Bir de şu var: sağlayıcının kendi idempotency davranışı sizin beklentinizle birebir örtüşmeyebilir. Bazısı header ister, bazısı body, bazısı hiç umursamaz gibi davranır — evet, maalesef böyle sağlayıcılar var. O yüzden kendi kontrolünüzü elden bırakmayın, dış dünyaya fazla güvenmeyin.
“Billing’de başarıyı ölçmenin en iyi yolu ‘kaç işlem yaptık?’ değildir; ‘kaç tanesini yanlışlıkla iki kere yapmadık?’ sorusudur.”
Küçük Startup ile Büyük Kurum Aynı Şeyi Yaşamaz mı?
Kısmen yaşar ama etkisi çok farklı olur. Küçük startup’ta sorun genelde hızla büyür: iki yanlış tahsilat müşteri desteğine düşer, birkaç gün içinde itibar yerle bir olabilir. Enterprise seviyede ise etki daha bürokratik ilerliyor: geri ödeme süreci, uyum raporları, mutabakat dosyaları… Hepsi birbirine giriyor, çözmesi haftalar alıyor.
Ben açıkçası küçük ekiplerde sadelikten yana olurum — ama sade olmak başka, gevşek olmak bambaşka bir şey. Mesela startup için minimum set şu olabilir: idempotency key, UNIQUE constraint, temel outbox worker. Kurumsalda bunlara ek olarak audit ledger, state machine doğrulaması ve schema contract testi de gerekiyor. Tek ülkeye satış yapıyorsanız bir hata belki affedilebilir gibi görünür; beş ülkede ödeme alıyorsanız aynı hata hukuki masrafa dönüşüyor. E haliyle mesele büyüyor.
Bana Göre En Sağlam Pratik Kombinasyon Ne?
Sanırım şöyle özetlemek en doğrusu:
- İstemciden idempotency key iste — bunu es geçmeyin
- Veritabanında unique constraint uygula (bu kritik)
- Outbox ile dış sistemi ayır
- Worker retry politikasını kontrollü tut
- Sağlayıcı yanıtlarını reconciliation için sakla
Bu beşi birlikte kullandığınızda sistem mucizevi biçimde kusursuz olmaz — beklenti bu olmasın zaten. Ama çift çekim ihtimalini ciddi şekilde aşağı çekiyor. Aslında dur bir saniye, önce şunu söyleyeyim: hiçbir desen tek başına kurtarıcı değil. Biri eksik kalınca zincirin o halkası kopuyor; özellikle yoğun trafik altında bu boşluk hemen kendini belli ediyor. Kombinasyon önemli, sıra önemli, tutarlılık önemli. Gerisi detay.
Bu içerik işinize yaradı mı?
Benzer içerikleri kaçırmamak için beni sosyal medyada takip edin.



