in

CQRS Nedir, Ne Zaman Gerekir? Spring Boot ile Uygulama

CQRS çoğu zaman 'iki veritabanı ve event sourcing' sanılır; aslında komut ve sorgu modellerinin ayrılmasıdır ve üç seviyede uygulanabilir. Hangi belirtilerin CQRS gerektirdiğini, Spring Boot ile aynı veritabanı üzerinde ayrık modelleri, outbox ile beslenen okuma modeli projeksiyonunu, nihai tutarlılığın kullanıcıya nasıl yansıtılacağını ve sahadaki tuzakları anlatıyoruz.

Komut tarafı, olaylar ve okuma modeli projeksiyonunu gösteren CQRS mimari şeması

CQRS (Command Query Responsibility Segregation) hakkında iki yaygın yanılgı var. Birincisi, "iki ayrı veritabanı ve event sourcing gerektirir" — gerektirmez. İkincisi, "sadece mikroservislerde anlamlı" — değil. CQRS'in özü basittir: sistemi değiştiren işlemler (komutlar) ile sistemi okuyan işlemler (sorgular) aynı modeli paylaşmak zorunda değildir. Sipariş oluşturmak için zengin bir domain modeli gerekir; sipariş listesi ekranı için düz bir DTO yeter. Bu ikisini tek JPA entity grafiğiyle karşılamaya çalışmak, kurumsal uygulamalardaki performans ve karmaşıklık sorunlarının önemli bir kaynağıdır.

Bu yazıda CQRS'in ne olduğunu ve ne olmadığını, üç uygulama seviyesini, hangi belirtilerin CQRS gerektirdiğini, Spring Boot ile aynı veritabanı üzerinde ayrık modelleri, outbox ile beslenen okuma modeli projeksiyonunu, nihai tutarlılığın kullanıcıya yansıtılmasını ve sahada gördüğümüz tuzakları anlatıyoruz.

CQRS nedir, ne değildir?

Komut bir niyettir: SiparisOnayla, FiyatGuncelle. Doğrulanır, iş kurallarından geçer, durumu değiştirir ve az şey döndürür (kimlik, sürüm). Sorgu veri döndürür ve yan etkisi yoktur: SiparisListesi(filtre, sayfa). CQRS, bu ikisinin ayrı kod yollarından, gerekirse ayrı modellerden, gerekirse ayrı depolardan geçmesidir. Event sourcing (durumu olay dizisi olarak saklamak) ayrı bir karardır; CQRS'in çoğu uygulaması event sourcing kullanmaz. Mikroservis de gerekmez; modüler bir monolit içinde tek modülde uygulanabilir.

Üç seviye

Seviye

Yazma tarafı

Okuma tarafı

Tutarlılık

Karmaşıklık

Ne zaman

1. Kodda ayrım

Aggregate + JPA, komut işleyicileri

Aynı tablolar, düz SQL/JOOQ ile doğrudan DTO

Anlık (aynı DB)

Düşük

Neredeyse her kurumsal uygulama; listeleme ve rapor ekranlarında N+1 ve DTO dönüşüm yükünü bitirir

2. Okuma için türetilmiş yapılar

Aynı

Aynı DB'de görünümler, materialized view ya da denormalize okuma tabloları; aynı transaction'da ya da kısa gecikmeyle güncellenir

Anlık ya da saniyeler

Orta

Ağır birleştirmeli listeler, panolar, arama filtreleri

3. Ayrı okuma deposu

Aynı

Elasticsearch, Redis ya da ayrı DB; olaylarla (outbox) beslenen projeksiyon

Nihai (eventual)

Yüksek

Farklı ölçekleme, tam metin arama, raporlamanın OLTP'yi boğduğu sistemler

Seviye 1 ücretsizdir ve varsayılan olmalıdır; seviye 2 ölçülmüş bir sorguya cevaptır; seviye 3 ölçülmüş bir ölçekleme sorununa. Sıra atlanmaz: ilk gün seviye 3 ile başlayan projeler nihai tutarlılığın bedelini ihtiyaç olmadan öder.

Belirtiler: CQRS ne zaman gerekir?

  • Listeleme ekranları için entity grafı yüklenip DTO'ya dönüştürülüyor; N+1 ve bellek sorunları listelerde çıkıyor.

  • Yazma modeli karmaşık (aggregate, invariant, durum makinesi), okuma modeli düz (tablo, filtre, sayfa).

  • Okuma/yazma oranı çarpık (1000:1) ve okuma tarafı ayrı ölçeklenmek istiyor.

  • Raporlar ve panolar üretim veritabanını yavaşlatıyor.

  • Aynı verinin farklı temsilleri gerekiyor: arama indeksi, özet kartlar, dışa aktarım.

Gerekmediği yer: basit CRUD ekranları, küçük ekip, ölçülmüş bir sorun yok. "Sorgular yavaş" cümlesi bir ölçüm değildir; önce sorgu planı ve indeks.

Seviye 1: Spring Boot ile kodda ayrım

siparis/
  command/    SiparisOnaylaKomutu, SiparisKomutIsleyici, Siparis (aggregate), SiparisRepository (JPA)
  query/      SiparisListesiSorgusu, SiparisSorguServisi (JdbcClient/JOOQ), SiparisOzetDto (record)
  api/        SiparisController (komutları ve sorguları ayrı servislere yönlendirir)
// Komut tarafı: niyet + aggregate + az dönüş
public record SiparisOnaylaKomutu(SiparisId id, KullaniciId onaylayan) {}

@Service
public class SiparisKomutIsleyici {
    @Transactional
    public long onayla(SiparisOnaylaKomutu k) {
        Siparis s = repo.findById(k.id()).orElseThrow(() -> new SiparisBulunamadi(k.id()));
        s.onayla(k.onaylayan(), clock.instant());            // iş kuralları aggregate'in içinde
        events.publish(new SiparisOnaylandi(s.id(), s.version()));   // outbox'a düşer (bkz. seviye 3)
        return s.version();                                  // istemci "okuma modelim bu sürüme ulaştı mı" diye sorabilir
    }
}
// Sorgu tarafı: entity yok, doğrudan DTO; yalnızca ekranın gerektirdiği sütunlar
public record SiparisOzetDto(long id, String musteriAdi, BigDecimal tutar, String durum, LocalDate tarih, int kalemSayisi) {}

@Service
public class SiparisSorguServisi {
    private final JdbcClient jdbc;

    public Sayfa<SiparisOzetDto> listele(SiparisFiltre f, int sayfa, int boyut) {
        return jdbc.sql("""
                SELECT s.id, m.ad AS musteri_adi, s.toplam_tutar, s.durum, s.tarih,
                       (SELECT count(*) FROM siparis_kalemi k WHERE k.siparis_id = s.id) AS kalem_sayisi
                FROM siparis s JOIN musteri m ON m.id = s.musteri_id
                WHERE s.kiraci_id = :kiraci AND (:durum IS NULL OR s.durum = :durum)
                ORDER BY s.tarih DESC LIMIT :boyut OFFSET :offset
                """)
            .param("kiraci", KiraciBaglami.mevcut()).param("durum", f.durum())
            .param("boyut", boyut).param("offset", sayfa * boyut)
            .query(SiparisOzetDto.class).list()
            .transform(liste -> Sayfa.of(liste, sayfa, boyut));
    }
}

Kazanç hemen görünür: liste sorgusu tek SQL, bellek yükü DTO kadar, sorgu planı elinizde. Yazma tarafı domain kurallarını korur. Aynı transaction, aynı tablo — tutarlılık sorusu yok.

Seviye 3: olaylarla beslenen okuma modeli

Ayrı okuma deposu (örneğin Elasticsearch'te sipariş arama indeksi ya da denormalize bir "sipariş kartı" tablosu) yazma tarafındaki olaylarla güncellenir. Olayın kaybolmaması için outbox deseni şarttır; projeksiyon tüketicisi idempotent olmalıdır (aynı olay iki kez gelirse okuma modeli bozulmaz) ve olay sürümüyle sıra bozulmasına dayanmalıdır.

@Component
public class SiparisKartiProjeksiyonu {

    @KafkaListener(topics = "siparis-olaylari", groupId = "siparis-karti-projeksiyonu")
    @Transactional
    public void uygula(SiparisOlayi olay) {
        SiparisKarti kart = kartRepo.findById(olay.siparisId()).orElseGet(() -> SiparisKarti.bos(olay.siparisId()));
        if (olay.version() <= kart.sonUygulananVersion()) return;      // idempotent: eski ya da tekrar olay atlanır
        switch (olay) {
            case SiparisOlusturuldu o -> kart.olusturuldu(o);
            case SiparisOnaylandi o   -> kart.durum("ONAYLANDI");
            case SiparisSevkEdildi o  -> kart.sevk(o.kargoTakip());
        }
        kart.sonUygulananVersion(olay.version());
        kartRepo.save(kart);
    }
}

Her projeksiyon yeniden inşa edilebilir olmalıdır: okuma deposu silinip olaylar baştan oynatılarak (ya da yazma modelinden toplu yükleme ile) yeniden kurulabilmeli. Bu yol yoksa projeksiyondaki ilk hata kalıcı veri tutarsızlığıdır.

Nihai tutarlılık ve kullanıcı

Kullanıcı siparişi onaylar, listeye döner ve eski durumu görür: bu, seviye 3'ün kaçınılmaz sorusudur. Cevaplar: (1) komut yanıtındaki sürümü istemciye ver, liste isteğinde ?minVersion= ile projeksiyonun o sürüme ulaşmasını beklet (kısa bekleme, sonra eski veriyle dön); (2) arayüzde iyimser güncelleme — kullanıcı onayladığı satırı hemen güncel görür; (3) kritik ekranlarda (ödeme sonucu) okuma modelini değil yazma modelini sorgula. Hangisi olursa olsun gecikme ölçülür (olay zamanı → projeksiyon zamanı) ve panoda izlenir.

Sahada sık yapılan hatalar

  1. İlk gün seviye 3: Ölçülmüş sorun olmadan ayrı okuma deposu; ekip nihai tutarlılıkla boğuşur, kazanç yok.

  2. Komut işleyicide sorgu mantığı: Onay işlemi ekrana dönecek 40 alanı hesaplıyor; komut az döndürür, ekran sorguyu ayrıca çağırır.

  3. Sorgu tarafında entity: "Sorgu servisi" JPA entity yükleyip DTO'ya çeviriyorsa CQRS yoktur, yeniden adlandırma vardır.

  4. İdempotent olmayan projeksiyon: Kafka'nın en az bir kez teslimatı çift sayımla sonuçlanır.

  5. Yeniden inşa yolu yok: Bozulan okuma modeli elle düzeltilir; sonra bir daha bozulur.

  6. Doğrulamayı iki tarafa dağıtmak: İş kuralı aggregate'te; okuma modeli yalnızca yansıtır.

  7. Arayüzün nihai tutarlılığı görmezden gelmesi: "Onayladım ama listede onaysız görünüyor" destek talebi.

Sık sorulan sorular

CQRS için Axon gibi bir çerçeve gerekli mi?

Seviye 1 ve 2 için hayır; birkaç paket ve disiplin yeter. Seviye 3'te olay yayınlama, projeksiyon ve yeniden oynatma altyapısını çerçeve hızlandırabilir; ama outbox + Kafka + idempotent tüketici ile elle kurulan altyapı da yaygın ve daha şeffaftır. Event sourcing'e geçilecekse çerçeve değerlenir.

CQRS ile event sourcing'i birlikte kullanmalı mıyız?

Ayrı kararlar. Event sourcing tam denetim izi ve zamanda geriye gitme sağlar ama şema evrimi, sorgu zorluğu ve öğrenme eğrisi ciddidir. Kurumsal uygulamaların büyük çoğunluğu CQRS'i klasik durum saklama ile uygular; event sourcing yalnızca denetim izinin iş gereksinimi olduğu alanlarda (finansal defter, sözleşme yaşam döngüsü).

Okuma modeli için hangi depo?

Sorunun şekline göre: tam metin ve fasetli arama → Elasticsearch/OpenSearch; anahtar-değer hızlı erişim → Redis; ağır rapor → ayrı PostgreSQL replikası ya da analitik depo. Çoğu durumda aynı PostgreSQL'de denormalize tablo ilk ve yeterli adımdır.

Sonuç

CQRS bir mimari devrim değil, bir ayrım disiplinidir: komutlar domain modelinden, sorgular düz DTO'lardan geçer. Seviye 1 her kurumsal uygulamada bedavadır; seviye 2 ve 3 ölçülmüş sorunlara cevaptır ve nihai tutarlılık, idempotent projeksiyon ve yeniden inşa yolu ile birlikte gelir. Doğru seviyeyi seçmek, deseni doğru uygulamaktan daha önemlidir.

Uygulamanızın okuma ve yazma yollarını ayrıştırmak ya da ölçeklenebilir bir okuma modeli tasarlamak için yazılım tasarımı ve mimari hizmetimize göz atabilirsiniz.