İçeriğe geç

CQRS ve Event Sourcing — Olanı Yaz, Okumayı Ayır

İleri 10 dk Sık karşılaşılı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ı.

Defter asıl kayıttır; pano onun biraz geriden gelen kopyası.
Adım adım oku
  1. Her işlem deftere yeni bir satır olarak eklenir. Eski satırlar silinmez, düzeltilmez.
  2. Bakiye hiçbir yere yazılmaz: satırların toplamıdır.
  3. Lobideki pano defteri izler ve toplamı hazır tutar, ama bir satır geriden gelebilir.
  4. Pano silinse bile kaybolan bir şey yok: defterden baştan yazılır.
  1. Bayt: Bakiye sütununu sildim! Artık yalnızca olup biteni yazıyorum: her yatırma, her çekme bir satır.

  2. Sen: Peki bakiyeyi soran ekran ne yapacak, her seferinde bütün satırları mı toplayacak?

  3. Bayt: Onun için lobiye bir pano asarız. Pano satırları izler, toplamı hazır tutar.

  4. 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.

İki taraf, iki model
// 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 lines

Bedeli 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.

Hızlı kontrolOrta

CQRS'in söylediği temel şey hangisi?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Kullanıcı para yatırdı, komut başarılı döndü. Hemen açılan hesap ekranı eski bakiyeyi gösteriyor. En olası sebep ne?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

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?

Account.java
1final class Account {
2 private BigDecimal balance = BigDecimal.ZERO;
3 private long version;
4
5 static Account replay(List<AccountEvent> history) {
şu an çalışan satır Account account = new Account();
7 history.forEach(account::apply);
8 return account;
9 }
10
11 MoneyWithdrawn withdraw(BigDecimal amount) {
12 if (balance.compareTo(amount) < 0) throw new InsufficientFunds(balance, amount);
13 return new MoneyWithdrawn(amount, TRY);
14 }
15
16 private void apply(AccountEvent event) {
17 balance = switch (event) {
18 case AccountOpened opened -> BigDecimal.ZERO;
19 case MoneyDeposited deposited -> balance.add(deposited.amount());
20 case MoneyWithdrawn withdrawn -> balance.subtract(withdrawn.amount());
21 };
22 version++;
23 }
24}

Debug

Adım 1/7

replay Boş bir hesap. Veritabanından hiçbir bakiye okunmadı.

balance
= 0
version
= 0
Java 21UTF-8LF6:1

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.

Hızlı kontrolOrta

Event sourcing kullanan bir hesapta güncel bakiye nereden gelir?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Hesap bu olaylardan yüklenince ne yazdırılır?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.
Replay.java
1var history = List.of(
2 new AccountOpened("TR01"),
3 new MoneyDeposited(new BigDecimal("200"), TRY),
4 new MoneyWithdrawn(new BigDecimal("50"), TRY),
5 new MoneyDeposited(new BigDecimal("30"), TRY));
6
7Account account = Account.replay(history);
8System.out.println(account.balance() + " v" + account.version());
Java 21UTF-8LF

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.

Hızlı kontrolİleri

MoneyDeposited olayına zorunlu bir currency alanı eklendi. Depoda bu alan olmadan yazılmış eski olaylar var. Doğru yaklaşım hangisi?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

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 1

Yazma tarafı: olay deposu

590 TL

8 olayın toplamı · sürüm #8

Okuma modeli: bakiye tablosu

590 TL

8/8 olay uygulandıGüncel

Olay deposu (yalnızca sona eklenir)

  1. #1Hesap açıldıv2okuma modelinde
  2. #2Para yatırıldı +100v2okuma modelinde
  3. #3Para yatırıldı +100v2okuma modelinde
  4. #4Para çekildi −80v2okuma modelinde
  5. #5Para yatırıldı +150v2okuma modelinde
  6. #6Para yatırıldı +200v2okuma modelinde
  7. #7Para çekildi −80v2okuma modelinde
  8. #8Para yatırıldı +200v2okuma modelinde
Hız
Adım 0

Ş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.

  1. “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.
  2. Okumayı “Kendi yazdığım sürümü bekle” yap. Yatır, oku, sonra zamanı ilerlet: okuma bekler ve taze döner.
  3. Snapshot’ı aç ve bir komut ver. Günlükte kaç olay okunduğuna bak: sekiz yerine üç.
  4. 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.
Hızlı kontrolOrta

Bir hesabın her beş olayında bir snapshot alınıyor. Hesap yüklenirken ne olur?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

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.

Hızlı kontrolİleri

Her özellik hangi fikre ait?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Sınıflandırılmamış

CQRS

Yazma ve okuma ayrı modeller

    Event sourcing

    Son hâl değil, olaylar saklanır

      Derinleş · Hesap hareketleri: olay deposu, upcaster ve okuma modeli 4 dosya · ~61 satır · ilk okumada atlayabilirsin
      Proje dosyaları

      src/main/resources/db/migration/ V21__account_events.sql Olay tablosu. Birincil anahtar aynı sürüme iki yazmayı engeller: iyimser kilit bedavaya gelir.

      src/main/resources/db/migration/V21__account_events.sql
      CREATE TABLE account_events (
      account_id VARCHAR(34) NOT NULL,
      seq BIGINT NOT NULL, -- the aggregate's version after this event
      event_type VARCHAR(100) NOT NULL,
      schema_version INT NOT NULL,
      payload JSONB NOT NULL,
      recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
      PRIMARY KEY (account_id, seq) -- two writers expecting the same version: one fails
      );
      -- Read side: shaped for the account screen, rebuildable from account_events at any time.
      CREATE TABLE account_balance (
      account_id VARCHAR(34) PRIMARY KEY,
      balance NUMERIC(19,2) NOT NULL,
      last_seq BIGINT NOT NULL
      );

      src/main/java/com/bank/account/ AccountEvent.java Olaylar değişmez record'lar. Sealed interface, apply'daki switch'in hiçbir olayı unutmamasını derleyiciye kontrol ettirir.

      src/main/java/com/bank/account/AccountEvent.java
      public sealed interface AccountEvent permits AccountOpened, MoneyDeposited, MoneyWithdrawn {}
      public record AccountOpened(String accountId) implements AccountEvent {}
      public record MoneyDeposited(BigDecimal amount, Currency currency) implements AccountEvent {}
      public record MoneyWithdrawn(BigDecimal amount, Currency currency) implements AccountEvent {}

      src/main/java/com/bank/account/ MoneyDepositedUpcaster.java Upcaster: v1 olayı okunurken v2'ye çevrilir. Depodaki satır aynen kalır.

      src/main/java/com/bank/account/MoneyDepositedUpcaster.java
      // v1 events were written before accounts had a currency: {"amount": 100}.
      // They stay that way in the store; only what the code sees is translated.
      final class MoneyDepositedUpcaster implements Upcaster {
      @Override
      public boolean canUpcast(StoredEvent event) {
      return event.type().equals("MoneyDeposited") && event.schemaVersion() == 1;
      }
      @Override
      public StoredEvent upcast(StoredEvent event) {
      ObjectNode payload = event.payload().deepCopy();
      payload.put("currency", "TRY"); // every v1 account was a TRY account
      return event.withPayload(payload).withSchemaVersion(2);
      }
      }

      src/main/java/com/bank/account/ BalanceProjection.java Okuma modeli: sıra numarası tutarak tekrarı ve sıra dışı gelişi tanır. Silinirse olaylardan yeniden kurulur.

      src/main/java/com/bank/account/BalanceProjection.java
      @Component
      class BalanceProjection {
      private final JdbcTemplate jdbc;
      BalanceProjection(JdbcTemplate jdbc) {
      this.jdbc = jdbc;
      }
      @KafkaListener(topics = "account-events", groupId = "balance-projection")
      @Transactional
      public void on(@Payload MoneyDeposited event, @Header("account-id") String accountId,
      @Header("seq") long seq) {
      // Apply only the next event of this account.
      // 0 rows updated means a duplicate, or a gap the retry will fill once the earlier event lands.
      jdbc.update("""
      UPDATE account_balance
      SET balance = balance + ?, last_seq = ?
      WHERE account_id = ? AND last_seq = ?
      """, event.amount(), seq, accountId, seq - 1);
      }
      }

      Kendini sına

      Önce hızlı bir ısınma: puan yok, kayıt yok. Sonra asıl sorular.

      Şimşek turu1/5

      CQRS kullanmak için event sourcing şarttır.

      Soru 1/3İleri

      Bu senaryoda hangi yolu seçersin?

      Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

      SenaryoÜç kişilik bir ekip, şirket içi bir tedarikçi kayıt ekranı yazıyor: ekle, düzenle, listele, ara. Denetçi yalnızca 'kaydı en son kim, ne zaman değiştirdi' sorusunu soruyor. Trafik düşük, liste ekranı birkaç tabloyu birleştiren tek bir sorgu.

      Aklında kalacak üç şey

      1. 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. 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. 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.
      Sonraki kapı Sunucu bir alanın numarasını değiştirdi. Hiç hata yok, ama alıcı IBAN'ı kayboldu. Nasıl? gRPC ve REST — Aynı Havale, İki Dil · 9 dk

      4 kart sonraki derste seni bekliyor

      0/4 kart bu dersten toplandı