CQRS ve Event Sourcing — Olanı Yaz, Okumayı Ayır
Önce şunu oku: Outbox ve Idempotent Consumer — Olay Kaybolmasın, İki Kez de İşlenmesin
30 saniyede özet
Hesabın son hâlini değil, başına gelen her şeyi sırayla yazabilirsin; bakiye bu satırların toplamıdır (event sourcing). Okumayı da ekrana göre şekillenmiş ayrı bir kopyadan yapabilirsin (CQRS). İkisi ayrı kararlar ve ikisinin de bedeli var.
Bankadaki eski hesap cüzdanlarını düşün: kimse bakiyeyi silip yenisini yazmazdı. Her para girişi ve çıkışı yeni bir satırdı; bakiye, satırların toplamıydı.
Adım adım oku
- Her işlem deftere yeni bir satır olarak eklenir. Eski satırlar silinmez, düzeltilmez.
- Bakiye hiçbir yere yazılmaz: satırların toplamıdır.
- Lobideki pano defteri izler ve toplamı hazır tutar, ama bir satır geriden gelebilir.
- Pano silinse bile kaybolan bir şey yok: defterden baştan yazılır.
-
Bayt: Bakiye sütununu sildim! Artık yalnızca olup biteni yazıyorum: her yatırma, her çekme bir satır.
-
Sen: Peki bakiyeyi soran ekran ne yapacak, her seferinde bütün satırları mı toplayacak?
-
Bayt: Onun için lobiye bir pano asarız. Pano satırları izler, toplamı hazır tutar.
-
Bayt: Yalnız pano bazen bir satır geriden geliyor. Bunu da konuşmamız lazım!
Yazan model, okuyan model
Para çekerken merak edilen, kuralın buna izin verip vermediğidir. Hesap ekranı ise son on işlemi ve bakiyeyi ister. İkisini tek tabloya sığdırmak çoğu zaman iki tarafı da zorlar.
CQRSCommand Query Responsibility Segregation: değiştiren işler (komutlar) ile okuyan işlerin (sorgular) ayrı modellerle yapılması. Okuma tarafı yazmanın biraz gerisinden gelebilir.Sözlükte gör → bu iki işi ayırır. Komutlar yazma modeline gider ve kuralları korur; sorgular ekranın sorusuna göre şekillenmiş bir read modelCQRS'te yalnızca okumak için tutulan, ekranın sorusuna göre şekillenmiş veri. Yazma tarafındaki değişikliklerden beslenir; silinip yeniden kurulabilir.Sözlükte gör → cevaplanır.
// Write side: checks the rules, records the change, returns the new version.long version = accounts.handle(new Withdraw(accountId, new BigDecimal("80")));
// Read side: a flat table shaped for the screen, filled from the changes a moment later.AccountSummary summary = summaries.findById(accountId); // balance + last 10 linesBedeli gecikmedir: okuma modeli yazmanın biraz gerisinden gelir. Kullanıcı “kaydedildi” görüp sayfayı yenilediğinde eski bakiyeyle karşılaşabilir. Bu, okuma replikalarındaki read-your-writes sorununun aynısıdır; çözümleri de benzer.
Kafam karıştı, daha basit anlat
Yazmak için bir defter, okumak için bir pano. Pano defteri izler ama bir adım geriden gelebilir.
CQRS'in söylediği temel şey hangisi?
Kullanıcı para yatırdı, komut başarılı döndü. Hemen açılan hesap ekranı eski bakiyeyi gösteriyor. En olası sebep ne?
Satır satır: bakiyeyi olaylardan kurmak
event sourcingBir nesnenin son hâlini değil, başına gelen olayları saklamak. Durum, olaylar baştan sırayla uygulanarak kurulur; olaylar yalnızca sona eklenir.Sözlükte gör → kullanan bir sistemde hesabın son hâli hiçbir yere yazılmaz. Saklanan şey olaylardır: MoneyDeposited, MoneyWithdrawn. Olaylar yalnızca sona eklenir; hiçbiri değiştirilmez.
Hesabı yüklemek, olayları baştan sırayla uygulamak demektir. Kuralları bilen nesne, zengin domain modeli dersindeki aggregate’in ta kendisi; yalnızca durumunu olaylardan kurar.
Bakiye nereden geliyor?
final class Account { private BigDecimal balance = BigDecimal.ZERO; private long version; static Account replay(List<AccountEvent> history) { Account account = new Account(); history.forEach(account::apply); return account; } MoneyWithdrawn withdraw(BigDecimal amount) { if (balance.compareTo(amount) < 0) throw new InsufficientFunds(balance, amount); return new MoneyWithdrawn(amount, TRY); } private void apply(AccountEvent event) { balance = switch (event) { case AccountOpened opened -> BigDecimal.ZERO; case MoneyDeposited deposited -> balance.add(deposited.amount()); case MoneyWithdrawn withdrawn -> balance.subtract(withdrawn.amount()); }; version++; }}Debug
replay Boş bir hesap. Veritabanından hiçbir bakiye okunmadı.
- balance
- = 0
- version
- = 0
Sol/sağ ok tuşlarıyla da gezebilirsin.
Olaylar binlere çıkınca her komutta baştan okumak yorar. Snapshot, arada bir bakiyenin fotoğrafını çeker; yükleme son fotoğraftan başlar ve yalnızca sonrasını okur. Fotoğraf kaybolsa da olaylar durduğu için yeniden çekilir.
Olay deposu aynı zamanda bir outbox gibi çalışır: yazılan tek şey olay olduğu için dual write oluşmaz. Olayları yayınlayan bir relay ve idempotent tüketiciler yine gerekir.
Kafam karıştı, daha basit anlat
Bakiyeyi saklamazsın, satırları toplarsın. Satırlar çoğalınca arada bir ara toplam yazarsın ve sonraki seferde oradan devam edersin.
Event sourcing kullanan bir hesapta güncel bakiye nereden gelir?
Hesap bu olaylardan yüklenince ne yazdırılır?
Eski olaylar, yeni kod
Üç yıl önce hesaplar tek para birimliydi ve MoneyDeposited olayında currency alanı yoktu. Bugünkü kod bu alanı zorunlu sayıyor.
Okuma modelini silip olaylardan baştan kurmaya karar verdin. Ne olur? Cevabı göster
Kurulum ilk eski olayda durur. Bugünkü kod currency alanı olmayan olayı okuyamaz. Şimdiye kadar fark edilmedi, çünkü okuma modeli eski kodla kurulmuştu ve kimse baştan okumamıştı.
Olayı depoda düzeltmek cazip gelir, ama olay olmuş bir gerçektir ve onu okumuş servisler var. Çözüm upcastingDepoda eski biçimde duran bir olayı, okunurken yeni biçime çevirmek. Olayın kendisi değişmez; yalnızca kod onu yeni sürüm gibi görür.Sözlükte gör →: eski olay okunurken yeni biçime çevrilir (currency = "TRY"), depodaki olay aynen kalır.
Yeni olaylar hep son sürümle yazılır. Bir alanın anlamı değişecekse eski olayı zorlamak yerine yeni bir olay tipi açmak daha güvenlidir.
MoneyDeposited olayına zorunlu bir currency alanı eklendi. Depoda bu alan olmadan yazılmış eski olaylar var. Doğru yaklaşım hangisi?
Kendin gör
Üstte iki bakiye var: olaylardan hesaplanan gerçek bakiye ve okuma modelinin gösterdiği. Altta olay deposu.
Event sourcing ve CQRS — olaylar, okuma modeli ve gecikme
Tohum 1Yazma tarafı: olay deposu
590 TL
8 olayın toplamı · sürüm #8
Okuma modeli: bakiye tablosu
590 TL
Olay deposu (yalnızca sona eklenir)
- #1Hesap açıldıv2okuma modelinde
- #2Para yatırıldı +100v2okuma modelinde
- #3Para yatırıldı +100v2okuma modelinde
- #4Para çekildi −80v2okuma modelinde
- #5Para yatırıldı +150v2okuma modelinde
- #6Para yatırıldı +200v2okuma modelinde
- #7Para çekildi −80v2okuma modelinde
- #8Para yatırıldı +200v2okuma modelinde
Şu an ne oldu?
Hesapta 8 olay var, bakiye 590 TL
Bakiye hiçbir yerde saklanmıyor; olayların toplamı. Bir komut ver, hemen ardından bakiyeyi oku.
Görevler0/4
Az önce yatırdığın parayı bakiyede görmeaçık
İpucu
Komut ver, zamanı ilerletmeden okuma modelinden oku.
Bekleyerek kendi yazdığını taze okuaçık
İpucu
Okumayı "kendi yazdığım sürümü bekle" yap, komut ver, oku, sonra zamanı ilerlet.
Okuma modelini baştan kurarken eski bir olayda takılaçık
İpucu
Eski sürüm olayları aç, upcaster kapalı kalsın.
Eski olaylar varken okuma modelini sorunsuz yeniden kuraçık
İpucu
Eski sürüm olaylar ve upcaster açık.
Olay günlüğü (0)
Henüz olay yok. Oynat veya adımla.
- “100 TL yatır”a bas, hemen ardından “Bakiyeyi oku”. Okuma modeli eski bakiyeyi gösteriyor. Oynat’a bas ve yeni olayın “yolda”dan “okuma modelinde”ye geçmesini izle.
- Okumayı “Kendi yazdığım sürümü bekle” yap. Yatır, oku, sonra zamanı ilerlet: okuma bekler ve taze döner.
- Snapshot’ı aç ve bir komut ver. Günlükte kaç olay okunduğuna bak: sekiz yerine üç.
- Eski sürüm olayları aç ve okuma modelini baştan kur. Kurulum #2’de kırılıyor. Upcaster’ı da açıp yeniden dene.
Bir hesabın her beş olayında bir snapshot alınıyor. Hesap yüklenirken ne olur?
Tuzaklar
İkisini tek paket sanmak. CQRS olağan tablolarla da yapılır; event sourcing ayrı bir okuma modeli olmadan da. Birini seçmek ötekini zorunlu kılmaz.
Gerek yokken kurmak. Ekle, düzenle, listele ekranı için olay deposu, projeksiyon ve upcaster ağır bir bedeldir. Yalnızca “kim, ne zaman değiştirdi” soruluyorsa bir değişiklik geçmişi tablosu yeter.
Okuma modeline bakarak karar vermek. Para çekme kuralı gecikmeli bakiyeye bakarsa yanlış karar verir. Kurallar yazma tarafında, olaylardan yüklenen hâlle çalışır.
Projeksiyonu idempotent yazmamak. Olaylar en az bir kez gelir. Projeksiyon, uyguladığı son olayın sıra numarasını tutarak tekrarı tanır.
Her özellik hangi fikre ait?
Derinleş · Hesap hareketleri: olay deposu, upcaster ve okuma modeli 4 dosya · ~61 satır · ilk okumada atlayabilirsin
Kendini sına
Önce hızlı bir ısınma: puan yok, kayıt yok. Sonra asıl sorular.
CQRS kullanmak için event sourcing şarttır.
Bu senaryoda hangi yolu seçersin?
Aklında kalacak üç şey
- 1 CQRS yazmayı ve okumayı ayrı modellere böler. Okuma modeli yazmanın biraz gerisinden gelir; kullanıcının kendi yazdığını görmesi gereken ekran bu gecikmeyi hesaba katar.
- 2 Event sourcing son hâli değil olayları saklar. Durum olaylar baştan uygulanarak kurulur; snapshot bunu hızlandırır, upcaster eski olay biçimlerini okunur tutar.
- 3 İkisi birbirinden bağımsız seçimlerdir. Geçmişin kendisi değerli değilse ve okuma ihtiyaçları sıradansa, olağan tablolar aynı işi çok daha az bedelle görür.
4 kart sonraki derste seni bekliyor