Oyun Kitabı

CQRS (Command Query Responsibility Segregation) Pipeline Nasıl Çalışır? Command ve Query Akışının Anatomisi (CQRS İceriden Nasil Calisir Command Ve Query Pipeline)

CQRS request pipeline nedir? Bir HTTP isteği Controller, MediatR, pipeline behavior, handler, Outbox ve read model üzerinden nasıl ilerler?

Command and query pipelines flowing through a CQRS architecture

Bir HTTP isteği sisteme geldiğinde neler olur? Validation nerede çalışır, transaction ne zaman açılır, domain kuralları hangi katmanda uygulanır? CQRS kullanan bir sistemde bu yolculuk klasik CRUD mimarisinden farklıdır. Bu yazıda bir isteğin API'den veritabanına, oradan da okuma modeline kadar geçtiği adımları inceleyeceğiz.

CQRS'i bilmeyen bir okuyucu için en kısa sözlük şudur: Command sistemi değiştirme niyetidir; Query sistemi değiştirmeden bilgi ister; Handler bu isteğin işleyicisidir. MediatR gibi bir mediator, Controller'ın doğru handler'ı doğrudan tanıması yerine isteği uygun akışa iletmesini sağlar.

POST /orders
  ↓
Controller
  ↓
Mediator.Send(command)
  ↓
Pipeline behaviors
  ↓
Command handler
  ↓
Aggregate + Repository
  ↓
Database

Pipeline Behavior nedir?

Pipeline behavior, her command'e tekrar tekrar yazmak istemediğimiz ortak teknik kontrollerdir. Bunların hiçbiri iş kuralı değildir; iş kuralı handler ve aggregate içinde yaşar.

ValidationBehavior
  ↓
AuthorizationBehavior
  ↓
LoggingBehavior
  ↓
MetricsBehavior
  ↓
RetryBehavior (yalnızca güvenli işlemlerde)
  ↓
TransactionBehavior
  ↓
Handler

Transaction en sonda açılır; çünkü geçersiz, yetkisiz veya erken reddedilebilecek bir istek için veritabanı bağlantısını ve kilidi gereksiz yere taşımak istemeyiz. Handler'a ulaştığımızda artık iş kararını atomik olarak uygulamaya hazırız.

Aggregate, sistemin asla bozulmaması gereken iş kurallarını—yani invariant'ları—korur. Örneğin tamamlanmış bir sipariş tekrar iptal edilemiyorsa, bu kural Controller'da değil Order aggregate'inde korunmalıdır.

Query tarafı farklı bir yol izler. Aynı sipariş için kullanıcının ihtiyacı, domain nesnesinin tamamı değil, ekrana uygun kısa bir görünüm olabilir:

GET /orders
  ↓
Authorization
  ↓
Cache (uygunsa)
  ↓
Read database / Search index
  ↓
OrderSummary DTO
  ↓
Frontend

Kavramlar ilk geçtiği yerde

📦 Handler
Tek bir command veya query'nin uygulama iş akışını yürütür.

📦 Mediator
Controller'ın handler'ı doğrudan bilmeden isteği doğru işleyiciye göndermesini sağlar.

📦 Pipeline Behavior
Validation, authorization veya logging gibi her istekte çalışan ortak teknik katmandır.

📦 Repository
Aggregate'i yükleyen ve kalıcı olarak saklayan uygulama sınırıdır.

📦 DTO
Domain modelin tamamını değil, bir ekranın ihtiyacı olan veriyi taşır.

Her kavramın bir motivasyonu vardır. Idempotency, aynı istek iki kez gelirse ikinci kez sipariş oluşmasını önler. Outbox, veritabanı kaydı ile event yayınını aynı işlem sınırında güvenli hale getirir. Bu yüzden ikisi pipeline'a sonradan eklenmiş teknik süsler değil, üretim ortamındaki hata senaryolarına verilmiş cevaplardır.

Handler örneğini okurken şu ayrımı akılda tutun: Handler yalnızca iş kararını uygular. Validation, authorization ve logging handler'ın içinde değildir; bunlar pipeline behavior'lar tarafından çözülür. Böylece handler küçük kalır ve her request aynı güvenlik/izlenebilirlik kurallarından geçer.

CQRS'i çoğu zaman iki klasör, birkaç MediatR handler'ı ve bir cache olarak görüyoruz. Bu görüntü yanıltıcıdır. CQRS'in asıl işi kodu bölmek değil; bir sistemin durumunu değiştiren kararlar ile o durum hakkında bilgi isteyen talepleri birbirinden ayırmaktır.

Bu, CQRS- Kararların Anatomisi koleksiyonunun ikinci bölümü. İlk bölümde ayrışmanın neden gerekli hale geldiğini konuşacağız. Burada ise mekanizmanın içine giriyoruz: bir command hangi kapılardan geçer, bir query neden aynı yolu izlememelidir ve read model ne zaman ayrı bir sisteme dönüşür?

Başlangıç noktası: tek bir istek, iki farklı niyet

CreateOrder bir command'dir. Sisteme yeni bir gerçek eklemek ister. Kuralları çalıştırır, yetki ister, transaction açar ve denetlenebilir olmalıdır.

GetOrderSummary bir query'dir. Yeni bir gerçek üretmez. Hızlı, dar ve ekrana uygun bir görünüm döndürmek ister. Aynı aggregate'i yüklemesi, domain kuralı çalıştırması veya yazma tarafındaki kilitlerle yarışması gerekmez.

Bu ayrımın algoritmik sonucu nettir: command tarafında maliyet çoğu zaman doğrulama ve tutarlılık için kabul edilir; query tarafında ise maliyet, sorgulanan veri miktarıyla birlikte artar. Bir liste ekranı için aggregate grafiğini dolaşmak yerine ihtiyaca göre biçimlenmiş bir read model kullanmak, hem gereksiz I/O'yu hem de bilişsel yükü azaltır.

Command pipeline: kararın güvenli yolu

Command tarafında amaç yalnızca handler çağırmak değildir. İstek, iş kararına ulaşmadan önce sistemin ortak kurallarından geçmelidir.

API
  → Authentication / Authorization
  → Validation
  → Idempotency check
  → Transaction
  → Command Handler
  → Aggregate + Domain Rules
  → Persist + Outbox
  → Commit

Pseudocode:

handle(command):
  authorize(command.actor)
  validate(command)
  return idempotency.execute(command.key):
    begin transaction
    aggregate = repository.load(command.aggregateId)
    aggregate.apply(command)
    repository.save(aggregate)
    outbox.store(aggregate.domainEvents)
    commit transaction

Her adımın tek bir sorumluluğu vardır. Validation, iş kuralının yerine geçmez; geçersiz şekli erken reddeder. Authorization, kararın sahibi olup olmadığını kontrol eder. Aggregate, invariants'ları korur. Outbox ise kalıcı durum ile yayınlanacak olayın aynı transaction sınırında oluşmasını sağlar.

C# tarafında Mediator bu akışı pratik hale getirir:

public sealed record PlaceOrder(Guid CustomerId, IReadOnlyList<OrderLine> Lines) : IRequest<OrderId>;

public sealed class PlaceOrderHandler : IRequestHandler<PlaceOrder, OrderId>
{
    public async Task<OrderId> Handle(PlaceOrder command, CancellationToken ct)
    {
        var order = Order.Place(command.CustomerId, command.Lines);
        await repository.AddAsync(order, ct);
        await outbox.AddAsync(order.DomainEvents, ct);
        return order.Id;
    }
}

Handler kısa kalır, çünkü audit, ölçüm, validation veya retry gibi enine kesen sorumluluklar pipeline behavior'lara taşınır. Bunun getirisi, her handler'ın aynı davranışı elle yeniden üretmemesidir. Bedeli ise çağrı zincirinin görünürlüğünü kaybetme riskidir. Bu yüzden behavior sırası açıkça dokümante edilmeli ve gözlemlenmelidir.

Query pipeline: ihtiyacın kadar oku

Query tarafında temel soru şudur: Kullanıcı hangi bilgiyi, hangi gecikme bütçesi içinde istiyor? Bu sorunun cevabı domain model değil, kullanım senaryosudur.

API
  → Authorization for the view
  → Query Handler
  → Read model / cache / search index
  → DTO shaped for the screen

Bir sipariş listesi için Order aggregate'ini, müşteri ilişkilerini ve stok kurallarını yeniden yüklemek çoğu zaman gereksizdir. Query handler doğrudan ekranın ihtiyacı olan alanları seçer. Böylece maliyet yaklaşık olarak tüm domain grafiği yerine döndürülen satır ve kolon sayısıyla sınırlandırılır.

public sealed record GetOrderSummary(Guid OrderId) : IRequest<OrderSummaryDto?>;

public sealed class GetOrderSummaryHandler : IRequestHandler<GetOrderSummary, OrderSummaryDto?>
{
    public Task<OrderSummaryDto?> Handle(GetOrderSummary query, CancellationToken ct) =>
        readDb.OrderSummaries
            .Where(x => x.Id == query.OrderId)
            .Select(x => new OrderSummaryDto(x.Id, x.Status, x.Total, x.UpdatedAt))
            .SingleOrDefaultAsync(ct);
}

Burada DTO, domain'i gizlemek için bir süs değil; okuma sözleşmesidir. Ekran değiştiğinde read model değişebilir. İş kuralları değiştiğinde command modeli değişebilir. İki değişim aynı hızda olmak zorunda değildir.

Mantıksal CQRS ve fiziksel CQRS

İlk adım çoğu ekip için mantıksal CQRS'tir: command ve query kodda ayrıdır, fakat aynı ilişkisel veritabanını kullanırlar. ACID transaction'lar korunur, operasyon maliyeti düşüktür ve hata ayıklama basittir.

Fiziksel CQRS'te read model ayrı bir veri deposuna, cache'e veya arama indeksine taşınır. Bu, okuma tarafını bağımsız ölçekleme fırsatı verir; fakat karşılığında veri tazeliği, projection hataları ve yeniden inşa süreçleri gelir. Bu nedenle fiziksel ayrım bir performans fantezisi değil, kanıtlanmış bir darboğaza verilen cevaptır.

Seçim Kazanç Kabul edilen bedel
Mantıksal CQRS Düşük operasyon maliyeti, güçlü tutarlılık Okuma ve yazma aynı altyapıyı paylaşır
Fiziksel CQRS Bağımsız ölçekleme, ekrana uygun modeller Nihai tutarlılık, projection operasyonu

Write model'den read model'e: CDC mi Outbox mı?

Fiziksel ayrımda kritik soru verinin nasıl aktığıdır. Change Data Capture, veritabanı değişikliklerini loglardan yakalayabilir; mevcut sistemlere dokunmadan replay için güçlü olabilir. Ancak mesaj sözleşmesini veritabanı şemasına yaklaştırır.

Outbox yaklaşımı ise domain event'i aynı transaction içinde outbox tablosuna yazar. Bir relay bu kayıtları broker'a taşır; tüketiciler idempotent davranır. Böylece database write başarılı, event publish başarısız ikilemi yönetilebilir hale gelir. Ama relay, tekrar deneme, dead-letter gözlemi ve tüketici idempotency'si artık tasarımın parçasıdır.

Command commit
  → Outbox record
  → Relay publishes event
  → Projection consumes event
  → Read model updates

Bu akışta exactly-once vaadi yerine, en az bir kez teslimatı ve idempotent işleme tasarlamak daha gerçektir. Örneğin projection, event kimliğini kaydeder; aynı event yeniden gelirse görünümü ikinci kez değiştirmez.

Naif çözümler neden kırılır?

  • Handler içinden doğrudan broker'a mesaj göndermek: transaction geri alınırken mesaj çoktan tüketilmiş olabilir.
  • Her query için aggregate yüklemek: liste ekranları domain kuralları ve join'lerle gereksiz yere pahalılaşır.
  • Read model gecikmesini hata saymak: bazı ekranlar taze veri ister, bazıları saniyelik gecikmeyi tolere eder. Bu ayrım ürünle birlikte belirlenmelidir.
  • Her problemi fiziksel CQRS ile çözmek: ikinci veri deposu yalnızca teknoloji değil, kalıcı operasyon yüküdür.

Karar kontrol listesi

  1. Command başarı koşulu ve idempotency anahtarı açık mı?
  2. Her invariant aggregate sınırında mı korunuyor?
  3. Query, domain yerine kullanım senaryosunun DTO'suna mı göre şekilleniyor?
  4. Read model gecikmesi için ürün dilinde bir beklenti tanımlı mı?
  5. Projection yeniden inşa edildiğinde sistem nasıl izlenecek ve doğrulanacak?

CQRS, sınırsız ölçek için otomatik bir bilet değildir. Doğru kullanıldığında, kararları bir pipeline'a; okumayı ise ihtiyaca uygun bir modele dönüştürür. Bu sayede sistem hem daha görünür hem de değişime daha dayanıklı olur.

Bir sonraki bölümde bu mantık tek servis sınırını aşacak: event broker'lar, projection'lar ve dağıtık sistemlerdeki geri alma stratejileriyle CQRS'i inceleyeceğiz.

Read Model nedir?

Read model, yazma tarafındaki domain modelin kopyası değildir; bir ekranın ihtiyacı için hazırlanmış görünümüdür.

Orders (write model)
Id | CustomerId | Status | Lines | Rules
          ↓ Projection
OrderSummary (read model)
Id | CustomerName | Status | Total | BadgeColor

CDC ve Outbox karşılaştırması

Kriter CDC Outbox
Domain event'in niyeti Dolaylı Açık
DB şemasına bağımlılık Yüksek Daha düşük
Replay Güçlü Güçlü
Event içeriği kontrolü Sınırlı Tam
Mikroservis iletişimi Duruma bağlı Çok uygun

Naif çözümler neden production'da kırılır?

Handler
  → DbContext.SaveChanges()
  → Message publish

İkinci adım başarısız olursa veri yazılmış, event kaybolmuş olur. Ya da transaction geri alınırken aşağıdaki çağrı çoktan yan etki üretmiş olabilir:

await emailService.Send(...)

Bu yüzden Outbox, kalıcı değişiklik ile yayınlanacak event'i aynı transaction sınırına alır; e-posta gibi yan etkiler ise commit sonrasında, tekrar denemeye dayanıklı tüketicilerde ele alınır.

Gerçek bir istek nasıl akar?

POST /orders
  ↓
Controller
  ↓
Mediator.Send()
  ↓
Validation → Authorization → Logging → Metrics → Transaction
  ↓
Handler
  ↓
Aggregate
  ↓
Repository + Outbox
  ↓
Commit
  ↓
Relay → Broker / Kafka
  ↓
Projection
  ↓
Read database
  ↓
GET /orders → Query handler → DTO → Frontend

CQRS otomatik ölçek değildir. Ancak pipeline'daki her adımın neden var olduğunu açıklayabiliyorsanız, sisteminize bilinçli bir sorumluluk ayrımı getirir. Açıklayamıyorsanız, CQRS'i kullanmaya henüz hazır olmayabilirsiniz.

Bu akışı neden bilmeliyim?

Bir request'in hangi katmanlardan geçtiğini bilirseniz:

  • Validation'ı Controller veya handler içine rastgele yazmazsınız.
  • İş kuralını HTTP katmanına koymaz, aggregate sınırında korursunuz.
  • Transaction'ı gereğinden erken açmazsınız.
  • Handler'ları loglama, yetki ve ölçüm kodlarıyla büyütmezsiniz.
  • Cross-cutting concern'leri pipeline'a taşırsınız.

Query tarafındaki fark da aynı derecede pratiktir:

Sipariş detayı
  → Order aggregate
  → kurallar ve davranış için doğru model

Sipariş listesi
  → OrderSummaryDto
  → ekrana hızlı ve dar bir görünüm

Bu ayrımı görmek, CQRS'i bir klasör düzeni olarak değil; her sorumluluğu doğru yere koyma disiplini olarak kullanmanızı sağlar.

SSS

Sık sorulan sorular

Handler nedir?

Tek bir command veya query'nin uygulama iş akışını yürütür.

Mediator nedir?

Controller'ın handler'ı doğrudan bilmeden isteği doğru işleyiciye göndermesini sağlar.

"CQRS (Command Query Responsibility Segregation) Pipeline Nasıl Çalışır? Command ve Query Akışının Anatomisi" ne anlatıyor?

CQRS request pipeline nedir? Bir HTTP isteği Controller, MediatR, pipeline behavior, handler, Outbox ve read model üzerinden nasıl ilerler?

Ogrenilen Muhendislik Prensipleri

  • CQRS iki veritabanı değil; okuma ve yazmanın farklı sorumluluklar olduğunu kabul etmektir.
  • Pipeline, tekrar eden kontrolleri handler'lardan çıkarır; iş kararını görünür bırakır.
  • Fiziksel ayrım ancak gecikme, tekrar deneme ve veri tazeliği bir ürün kararı olarak tasarlandığında değerlidir.

Okumaya devam et

Okumaya devam et

Ilgili yazilar

Ilgili yazilar

Ilgili yazilar

Paylaş