Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi CQRS ve Event Sourcing — #10

CQRS ve Event Sourcing Ne Zaman Gereksiz? Anti-Pattern'ler ve Karar Rehberi

Mert Özen 7 Kas 2026 11 dk 4 görüntülenme
CQRS ve Event Sourcing Ne Zaman Gereksiz? Anti-Pattern'ler ve Karar Rehberi

CQRS ve event sourcing düz CRUD'da, MVP'de, statik referans verisinde, anlık tutarlı görünüm şartında ve deneyimsiz ekipte gereksizdir; her bounded context için ayrı karar verilir. Bookstore'un context haritası, CQRS seviye 1/2/3 ve event sourcing için bir karar akışı ve dört anti-pattern: tek veritabanında CQRS töreni, her yerde event sourcing, state-change event'leri ve iç event'i integration contract yapmak.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.

Önce Tahmin Et

Yeni katılan bir developer, Catalog'da bir kitabın açıklamasını değiştiren ekranı "CQRS'e (Command Query Responsibility Segregation) uygun" hâle getirmiş. Pull request'te şu dosyalar var:

// "CQRS'li" Catalog: her CRUD işlemi için command, query, handler, DTO ve dispatcher
public interface ICommand;
public interface IQuery<TResult>;
public interface ICommandHandler<in TCommand> where TCommand : ICommand
{
    Task HandleAsync(TCommand command);
}
public interface IQueryHandler<in TQuery, TResult> where TQuery : IQuery<TResult>
{
    Task<TResult> HandleAsync(TQuery query);
}

public sealed record UpdateBookDescription(Guid BookId, string Description) : ICommand;
public sealed record GetBookById(Guid BookId) : IQuery<BookDto>;
public sealed record BookDto(Guid Id, string Title, string Description);

public sealed class UpdateBookDescriptionHandler(Dictionary<Guid, BookDto> books) : ICommandHandler<UpdateBookDescription>
{
    public Task HandleAsync(UpdateBookDescription command)
    {
        books[command.BookId] = books[command.BookId] with { Description = command.Description };
        return Task.CompletedTask;
    }
}

public sealed class GetBookByIdHandler(Dictionary<Guid, BookDto> books) : IQueryHandler<GetBookById, BookDto>
{
    public Task<BookDto> HandleAsync(GetBookById query) => Task.FromResult(books[query.BookId]);
}

public sealed class Dispatcher(IServiceProvider services)
{
    public Task SendAsync<TCommand>(TCommand command) where TCommand : ICommand =>
        ((ICommandHandler<TCommand>)services.GetService(typeof(ICommandHandler<TCommand>))!).HandleAsync(command);

    public Task<TResult> QueryAsync<TQuery, TResult>(TQuery query) where TQuery : IQuery<TResult> =>
        ((IQueryHandler<TQuery, TResult>)services.GetService(typeof(IQueryHandler<TQuery, TResult>))!).HandleAsync(query);
}

Aynı işin eski hâli:

// Aynı iş, aynı tablo: tek bir service
public sealed record Book(Guid Id, string Title, string Description);

public sealed class BookCatalog(Dictionary<Guid, Book> books)
{
    public void UpdateDescription(Guid bookId, string description) =>
        books[bookId] = books[bookId] with { Description = description };

    public Book Get(Guid bookId) => books[bookId];
}

İkisini de çalıştırıp sonucu ve tip sayısını yazdırdım:

Dictionary<Guid, BookDto> dtos = new() { [dune] = new BookDto(dune, "Dune", "") };
ServiceProvider services = new ServiceCollection()
    .AddSingleton(dtos)
    .AddScoped<ICommandHandler<UpdateBookDescription>, UpdateBookDescriptionHandler>()
    .AddScoped<IQueryHandler<GetBookById, BookDto>, GetBookByIdHandler>()
    .BuildServiceProvider();
Dispatcher dispatcher = new(services);
await dispatcher.SendAsync(new UpdateBookDescription(dune, "Desert planet, spice, politics."));
BookDto viaCeremony = await dispatcher.QueryAsync<GetBookById, BookDto>(new GetBookById(dune));

Dictionary<Guid, Book> books = new() { [dune] = new Book(dune, "Dune", "") };
BookCatalog catalog = new(books);
catalog.UpdateDescription(dune, "Desert planet, spice, politics.");
Book direct = catalog.Get(dune);

Bu pull request'i onaylar mısın? Yeni kod hangi problemi çözüyor? Okuma ve yazma gerçekten ayrıldı mı? Cevap vermeden önce çıktıya bak:

Ceremony: Dune - Desert planet, spice, politics.
Direct:   Dune - Desert planet, spice, politics.
Ceremony types: 10
Direct   types: 2

Davranış birebir aynı. İki tip on tip oldu, ama okuma ve yazma hâlâ aynı tabloya, aynı şekle bakıyor: BookDto, Book'un adı değiştirilmiş kopyası. Bu CQRS değil, CQRS'in kelimeleri. Serinin son yazısı bu farkı ayırt etmekle ilgili: CQRS ve event sourcing ne zaman gereksiz, hangi sinyal hangi seviyeyi haklı çıkarır ve en sık görülen dört anti-pattern neye benzer.

Önce Soru: Hangi Problemi Çözüyoruz?

01. yazıda CQRS'i üç seviyede tanımladık: aynı veritabanında ayrı code path (seviye 1), aynı veritabanında ayrı read model (seviye 2) ve ayrı bir read store (seviye 3). 04. yazıdan itibaren de event sourcing'i ayrı bir karar olarak işledik: state yerine olanları saklamak. Bu dört seçenek bir merdiven değil, birbirinden bağımsız sorulara verilmiş cevaplar. Her biri belli bir acıyı giderir ve hepsinin bedeli var:

SeçimGiderdiği acıÖdediğin bedel
CQRS seviye 1Liste ekranı için aggregate yüklemek, N+1, gereksiz change trackingNeredeyse yok: iki ayrı class
CQRS seviye 2Okuma anındaki pahalı join ve hesaplamalarRead model'in şemasını ve güncellenmesini yönetmek
CQRS seviye 3Okuma yükünün ayrı ölçeklenmesi, başka bir teknoloji ihtiyacıEventual consistency, outbox, idempotent projector, ikinci bir altyapı
Event sourcingGeçmişin ve niyetin kaybolması, "neden" sorusuna cevap verememekVersioning, projection, GDPR (General Data Protection Regulation), ekip deneyimi, sorgulama zorluğu

Martin Fowler'ın 2011'deki CQRS yazısındaki uyarı bugün de geçerli: çoğu sistem CRUD (Create, Read, Update, Delete) modeline daha iyi uyar ve CQRS'in eklediği karmaşıklık, işe yaradığı nadir durumlar dışında riski artırır. Fowler'ın önerisi de aynı: CQRS'i bütün sisteme değil, ihtiyaç duyan bounded context'lere uygula. Microsoft Learn'deki Event Sourcing pattern sayfası (Mart 2026 güncellemesi) daha da açık: çoğu sistem ve bir sistemin çoğu parçası için geleneksel veri yönetimi yeterli.

Gereksiz Olduğu Durumlar

Microsoft Learn'ün "bu pattern uygun olmayabilir" listesi ile Fowler'ın uyarılarını Bookstore'a uygularsak beş sinyal çıkıyor.

Düz CRUD

Catalog'daki yazar kartı, kategori ağacı, kampanya takvimi: okunan şekil yazılan şekille aynı, iş kuralı neredeyse yok. Açılıştaki kod tam bunun örneği. Burada CQRS'in seviye 1'i bile çoğu zaman gereksiz; Entity Framework Core'un (EF Core) AsNoTracking'i ve bir Select projection'ı yeter (EF Core serisinin "EF Core'da Performans Temelleri: N+1, Projection, AsNoTracking ve Compiled Query" yazısı).

MVP ve kısa ömürlü sistemler

Kampanya için iki haftada çıkacak bir açılış sayfası ya da ürün-pazar uyumunu deneyen bir prototip. Event tasarımı, versioning stratejisi ve projection altyapısı yatırımı geri dönmeden sistem emekliye ayrılır. Önce öğren, sonra modelle.

Statik referans verisi

Ülke listesi, kargo bölgeleri, KDV (Katma Değer Vergisi) oranları. Nadiren değişir ve değişim geçmişi iş için bir şey ifade etmez; ifade ediyorsa da bir ValidFrom kolonu yeter. Event sourcing burada saklanacak bir hikâye bulamaz.

Gerçek zamanlı, tutarlı görünüm şartı

"Kullanıcı kaydettiği anda bütün ekranlarda yeni değeri görmeli ve aradaki bir saniye bile kabul edilemez" diyen bir gereksinim, seviye 3'ü ve Async projection'ları baştan eler. Seviye 1 ve 2 aynı transaction'da güncel kalır; event sourcing'de de 07. yazıdaki Inline projection bu yüzden var. Ama ayrı bir read store ile anlık tutarlılık birlikte satın alınamaz.

Deneyimsiz ekip

Event sourcing test etme, hata ayıklama ve işletme alışkanlıklarını değiştirir: 08. yazıdaki sessiz rename hatası, 07. yazıdaki boş kalan read model, 06. yazıdaki Türkçe culture alias'ı. Bunları ilk kez production'da öğrenen bir ekip, kazancı görmeden bedeli öder. Seçici başlamak (bir context, bir aggregate) ve ekibin deneyimini oradan büyütmek, bütün sisteme birden yaymaktan daha güvenli.

Seçici Uygulama: Bookstore'un Haritası

Bu seri boyunca Bookstore'un her yerine CQRS ya da event sourcing koymadık. Microsoft Learn'ün de önerdiği gibi karar bütün sistem için değil, parça parça verildi:

Bounded contextSeçimNeden
CatalogCRUD, liste ve arama için seviye 1Kural az; arama yükü artarsa ayrı bir arama index'i ilerideki System Design serisinin konusu
OrderingCQRS seviye 1 ve 2 (02–03)Müşterinin "Siparişlerim" ekranı ve satış raporları aggregate'e uymuyor
InventoryEvent sourcing (04–09)Sayım farkı, kayıp nüsha ve kampanyadaki yoğun rezervasyon: geçmiş ve concurrency iş için birinci sınıf
PaymentsCRUD ve Anti-Corruption LayerAsıl defter ödeme sağlayıcısında; generic subdomain
ShippingCRUD ve Anti-Corruption LayerDurum kargo firmasından geliyor; bizim kararımız az
NotificationHiçbiriGönder ve unut; kaydı log'da

Bu tablo bugünün kararı. Sinyal değişirse karar da değişir: Catalog'da fiyat değişikliklerinin geçmişi bir gün denetim konusu olursa, önce bir audit tablosu, gerçekten gerekirse event sourcing konuşulur. Bu tür kararların gerekçesini bir ADR (Architecture Decision Record) ile yazmak, ilerideki Architecture Decision'lar serisinin konusu.

Karar Akışı

Yukarıdaki sinyalleri bir akışa dökünce şöyle görünüyor. Her bounded context için ayrı ayrı sorulmalı:

10-decision-tree-tr.png

İlk soru en çok eleyen soru: okuma ekranları write modelden gerçekten farklı bir şekil istemiyorsa, CQRS'in hiçbir seviyesine ihtiyaç yok. İkinci soru, seviye atlamadan önce ölçmeyi zorunlu kılıyor: aynı tablolardan iyi yazılmış bir sorgu (ve belki bir index) yetiyorsa, ayrı bir read model eklemek yalnızca bakımı artırır. Seviye 3'e geçmenin gerekçesi teknoloji merakı değil, ayrı ölçeklenmesi gereken ölçülmüş bir okuma yükü ya da başka bir motorun (tam metin arama, document store) gerçekten gerektiği bir sorgu olmalı. Event sourcing sorusu ise CQRS'ten bağımsız: Inventory'de event sourcing var, ama read model'ler aynı PostgreSQL'de duruyor.

Sık Düşülen Tuzaklar: Dört Anti-Pattern

"CQRS framework"ü kurup tek veritabanında kalmak

Açılıştaki kod. Command, query, handler, dispatcher ve DTO (Data Transfer Object) katmanları kurulmuş ama okuma tarafı yazma tarafıyla aynı modeli, aynı tabloyu, aynı şekli kullanıyor. Ortaya çıkan şey CQRS'in maliyeti, ama kazancı değil. Mediator kullanmak kendi başına yanlış değil; Vertical Slice serisinin "Mediator'lı Slice: MediatR, Mediator (Source Generator) ve Wolverine Karşılaştırması" yazısında pipeline behavior'ların ne zaman değer kattığını konuşmuştuk. Sorun, dosya yapısını mimari karar sanmak. Basit bir test: read model'in write modelden farklı bir field'ı, farklı bir şekli ya da farklı bir kaynağı yoksa, ortada iki ayrı model yoktur.

Her yerde event sourcing

Bir context'te event sourcing işe yaradı diye bütün sisteme yaymak, en sık görülen ve en pahalı yanlış. Yazar kartı için event store, kategori ağacı için projection, ülke listesi için upcaster... Her biri 06–09'daki bütün bedelleri yeniden öder, ama saklayacak bir hikâyesi yoktur. Bookstore'un haritasında tek bir event-sourced context olması bir eksiklik değil, bir karar.

State-change event'leri: StockItemUpdated

Event sourcing'in dilini koruyup ruhunu kaybetmenin yolu, event'leri "son hâl şu" diye yazmak:

// Anti-pattern: "ne oldu" yerine "son hâl ne" diyen event
public sealed record StockItemUpdated(StockItemId StockItemId, int OnHand, int Reserved, DateTimeOffset At);

Aynı günü iki şekilde kaydettim ve depo müdürünün sorusunu sordum: Eylül'de kaç nüsha sevk edildi, kaç tanesi hasarlı çıktı?

// Eylül'de aynı gün: 10 giriş, Ada'ya 2 sevk, 1 hasarlı, sayımda 1 fazla
StockEvent[] businessEvents =
[
    new StockReceived(dune, 10, sep),
    new StockReserved(dune, ada, 2, sep.AddHours(1)),
    new ReservedStockShipped(dune, ada, 2, sep.AddHours(2)),
    new StockAdjusted(dune, -1, AdjustmentReason.Damaged, sep.AddHours(3)),
    new StockAdjusted(dune, 1, AdjustmentReason.CountCorrection, sep.AddHours(4))
];

// Aynı gün, state-change event'leriyle
StockItemUpdated[] stateEvents =
[
    new(dune, 10, 0, sep), new(dune, 10, 2, sep.AddHours(1)), new(dune, 8, 0, sep.AddHours(2)),
    new(dune, 7, 0, sep.AddHours(3)), new(dune, 8, 0, sep.AddHours(4))
];

int damaged = businessEvents.OfType<StockAdjusted>().Where(a => a.Reason == AdjustmentReason.Damaged).Sum(a => -a.Delta);
int shipped = businessEvents.OfType<ReservedStockShipped>().Sum(s => s.Quantity);
Console.WriteLine($"Business events: shipped={shipped}, damaged={damaged}");

IEnumerable<string> deltas = stateEvents.Zip(stateEvents.Skip(1), (a, b) => $"{b.OnHand - a.OnHand:+0;-0;0}");
Console.WriteLine($"State events:    OnHand changes {string.Join(" ", deltas)}, reason unknown");
Business events: shipped=2, damaged=1
State events:    OnHand changes 0 -2 -1 +1, reason unknown

State event'leriyle yalnızca sayının nasıl değiştiğini biliyorsun; −1'in hasar mı, kayıp mı, yoksa yanlış bir sevk mi olduğunu bilemiyorsun. Microsoft Learn'ün Event Sourcing sayfası da tam bu ayrımı yapıyor: "iki koltuk rezerve edildi" diyen bir event, "kalan koltuk 42 oldu" diyen event'ten değerlidir. OrderUpdated, StockItemUpdated, CustomerChanged gibi adlar, event store'u iş anlamı olmayan bir değişiklik log'una çevirir; bu durumda bir audit tablosuyla aynı yerdesin, ama event sourcing'in bütün bedelini ödüyorsun.

Event'i integration contract olarak dışarı açmak

İç event'ler bir aggregate'in kararlarını ve evolve'unu besler; şekilleri o aggregate'in iç modeline göre değişir (08. yazıda OutOfStock'un ReservationRejected olması gibi). Bu event'leri olduğu gibi başka bir context'e ya da servise yayınlarsan, her iç refactoring bir dış sözleşme kırılmasına dönüşür. Doğrusu, dışarıya ayrı, küçük ve sürümlü bir sözleşme açmak ve iç event'i ona çevirmek:

// Dışarıya açılan sözleşme: küçük, sürümlü, iç modelden bağımsız
public sealed record StockAvailabilityChanged(Guid BookId, int Available, DateTimeOffset At);

public static class StockContractTranslator
{
    // İç event + güncel state -> public event; her iç event dışarı çıkmaz
    public static StockAvailabilityChanged? Translate(StockEvent internalEvent, StockState stateAfter) => internalEvent switch
    {
        StockReceived or StockReserved or ReservationReleased or StockAdjusted =>
            new StockAvailabilityChanged(stateAfter.BookId.Value, stateAfter.Available, internalEvent.At),
        _ => null
    };
}
StockEvent[] history =
[
    new StockItemRegistered(dune, duneBook, at),
    new StockReceived(dune, 5, at),
    new StockReserved(dune, new OrderId(Guid.CreateVersion7()), 2, at.AddMinutes(5))
];
StockState state = StockDecider.InitialState;
foreach (StockEvent e in history)
{
    state = StockDecider.Evolve(state, e);
    StockAvailabilityChanged? published = StockContractTranslator.Translate(e, state);
    Console.WriteLine($"{e.GetType().Name,-20} -> {(published is null ? "(not published)" : JsonSerializer.Serialize(published))}");
}
// Karşılaştırma için: iç event olduğu gibi yayınlansaydı
Console.WriteLine($"Internal StockReserved: {JsonSerializer.Serialize<object>(history[^1])}");
StockItemRegistered  -> (not published)
StockReceived        -> {"BookId":"01a1b859-4b5d-7dbc-8edf-92e38d04abd6","Available":5,"At":"2026-09-01T09:00:00+00:00"}
StockReserved        -> {"BookId":"01a1b859-4b5d-7dbc-8edf-92e38d04abd6","Available":3,"At":"2026-09-01T09:05:00+00:00"}
Internal StockReserved: {"OrderId":{"Value":"01a1b859-4b5d-7b2e-b5be-081e6b0cd498"},"Quantity":2,"StockItemId":{"Value":"01a1b859-4b5d-731f-869e-c7b20bea9bc5"},"At":"2026-09-01T09:05:00+00:00"}

Catalog'un "stokta var mı" rozetine gereken tek şey kitabın kaç nüshasının müsait olduğu. İç event ise sipariş id'sini, stok kalemi id'sini ve rezervasyon ayrıntısını taşıyor; dışarıya açıldığı gün bunların hepsi başka bir ekibin bağımlılığı olur. Microsoft Learn de event sourcing event'lerinin genelde düşük seviyeli olduğunu ve ayrı integration event'leri gerekebileceğini söylüyor. Bu StockAvailabilityChanged'in V1 namespace'inde durması tesadüf değil; Domain-Driven Design (DDD) serisindeki Published Language fikri. Integration event'i güvenilir şekilde taşımak (outbox, idempotent consumer) ise Messaging ve Event-Driven Architecture serisinin konusu.

Seriden Sonra: Devam Rotası

On yazıda iki ayrı fikri ve aralarındaki ilişkiyi gördük. CQRS, okuma ve yazmanın farklı modellere ihtiyaç duyduğu yerde bu modelleri ayırmaktı ve üç seviyesi vardı. Event sourcing, state yerine olanları saklamaktı; decider ile saf bir karar fonksiyonu, Marten ile PostgreSQL üzerinde bir event store, projection'larla soruya göre şekillenmiş read model'ler, versioning ve snapshot ile zamanla değişen bir geçmiş, crypto-shredding ve DCB (Dynamic Consistency Boundary) ile tek stream'e sığmayan sorunlar. Okuma için önerdiğim sıra: önce Greg Young'ın 2010 tarihli CQRS Documents'ı ve Martin Fowler'ın CQRS bliki yazısı (kısa ve uyarılarıyla), sonra Microsoft Learn'deki CQRS ve Event Sourcing pattern sayfaları (özellikle "uygun olmayabilir" listeleri), Jérémie Chassaing'in 2021 tarihli Functional Event Sourcing Decider yazısı, Greg Young'ın Versioning in an Event Sourced System kitabı ve en sonda dcb.events. Marten'in dokümantasyonu ise sürüm notlarıyla birlikte okunmalı; bu seri boyunca gördüğümüz varsayılanların çoğu Marten 9 ile değişti.

Küçük Bir Deneme

★☆☆ Kendi projende okuma ve yazma için aynı tipi kullanan üç "query"yi bul. Her biri için karar akışının ilk iki sorusunu cevapla: hangisi gerçekten ayrı bir modele ihtiyaç duyuyor?

★★☆ Bir OrderUpdated event'i yayınlayan bir sistemi düşün (ya da kendi sisteminde bul). Bu event'i iş niyetini taşıyan üç ya da dört event'e böl ve her biri için 07. yazıdaki gibi bir read model sorusu yaz: hangi soruya artık cevap verebiliyorsun?

★★★ Bookstore haritasındaki Catalog satırını tartış: fiyat değişikliklerinin geçmişi bir yasal düzenleme yüzünden denetlenebilir olmak zorunda kalsaydı, karar akışında hangi kutuya giderdin? Event sourcing ile "fiyat geçmişi tablosu"nu bir ADR (Architecture Decision Record) taslağında karşılaştır.

CQRS ve Event Sourcing serisinin sonuna geldik. Tek bir modelin okuma ve yazmada zorlanmasından başladık, CQRS'in seviyelerini, event sourcing'in decider'ını, event store'unu, projection'larını, versioning'ini ve zor konularını gördük ve bu yazıda hepsini ne zaman kullanmayacağımızla tarttık. Inventory'nin event'leri artık güvenle yazılıyor; bir sonraki adım, onları ve integration event'lerini context'ler arasında kaybetmeden taşımak. O yolculuk ilerideki Messaging ve Event-Driven Architecture serisinde, outbox ile başlıyor.