CQRS Nedir? CQS'ten Command Query Responsibility Segregation'a
CQRS, tek bir modeli yazma (command) ve okuma (query) için iki ayrı modele bölmektir; Meyer'in method seviyesindeki CQS ilkesini Greg Young model seviyesine taşıdı. Aggregate'in liste ekranına neden uymadığını gerçek bir derleme hatası ve sayımla gösteriyor, CQRS'in üç seviyesini, Event Sourcing ve MediatR'dan farkını ve Fowler'ın 'yalnız gerektiği yerde' uyarısını işliyoruz.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'un Ordering tarafında her şey tek bir OrderService'te toplanmış: sipariş veren de, iptal eden de, müşterinin "Siparişlerim" ekranını dolduran da aynı class. DDD (Domain-Driven Design) serisinde Order aggregate'ini invariant'ları koruyacak şekilde kurmuştuk; sipariş satırları private bir listede duruyor, dışarıya yalnızca davranış açık. Şimdi ekibe yeni bir iş geldi: müşterinin son siparişlerini 20'şer satırlık sayfalarla gösteren bir liste. Her satırda tarih, durum, kaç kitap alındığı, toplam tutar ve ilk kitabın adı olacak.
using Bookstore.Ordering;
namespace Bookstore.Before;
public interface IOrderRepository
{
Order? Find(OrderId id);
IReadOnlyList<Order> FindByCustomer(CustomerId customerId);
void Add(Order order);
}
public sealed record OrderListItem(Guid OrderId, DateTimeOffset PlacedAt, string Status, int ItemCount, decimal Total, string FirstTitle);
// Hem yazan hem okuyan tek service
public sealed class OrderService(IOrderRepository orders, TimeProvider clock)
{
public OrderId PlaceOrder(CustomerId customerId, IReadOnlyList<CartLine> lines)
{
Order order = Order.Place(customerId, lines, clock);
orders.Add(order);
return order.Id;
}
public void CancelOrder(OrderId id, CancellationReason reason) =>
(orders.Find(id) ?? throw new KeyNotFoundException($"Order {id.Value} not found.")).Cancel(reason);
// "Siparişlerim" ekranı: aggregate'leri yükle, bellekte sayfala, DTO'ya çevir
public IReadOnlyList<OrderListItem> GetCustomerOrders(CustomerId customerId, int page, int pageSize) =>
[.. orders.FindByCustomer(customerId)
.OrderByDescending(o => o.PlacedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(o => new OrderListItem(
o.Id.Value, o.PlacedAt, o.Status.ToString(),
o.Items.Sum(i => i.Quantity),
o.Items.Sum(i => i.UnitPrice * i.Quantity),
o.Items[0].Title))];
}
İki soru. Bu kod derlenir mi; derlenmiyorsa onu derletmenin en kısa yolu ne ve o yolun bedeli ne olur? Diyelim ki derledin: Ada'nın 120 siparişi varken ilk sayfayı göstermek için belleğe kaç aggregate ve kaç sipariş satırı gelir?
Tek Model İki İşe Koşulunca
Derleyicinin cevabı net:
Before.cs(35,19): error CS1061: 'Order' does not contain a definition for 'Items' and no accessible extension method 'Items' accepting a first argument of type 'Order' could be found
Before.cs(36,19): error CS1061: 'Order' does not contain a definition for 'Items' and no accessible extension method 'Items' accepting a first argument of type 'Order' could be found
Before.cs(37,19): error CS1061: 'Order' does not contain a definition for 'Items' and no accessible extension method 'Items' accepting a first argument of type 'Order' could be found
Order satırlarını dışarı vermiyor, çünkü aggregate'in işi ekran beslemek değil; kuralları korumak. DDD serisinin "Aggregate Tasarımı: Consistency Boundary, Küçük Aggregate'ler ve Id ile Referans" yazısında aggregate'i bir consistency boundary olarak tanımlamıştık: bir siparişte aynı kitaptan en fazla 10 adet, kargoya verilmiş sipariş iptal edilemez gibi kurallar onun içinde yaşar. Derletmenin en kısa yolu ise herkesin bildiği şey, bir getter açmak:
// Liste ekranı için açıldı
public IReadOnlyList<OrderItem> Items => _items;
Kod derleniyor. 120 siparişi olan Ada için ilk sayfayı isteyelim ve repository'nin belleğe ne getirdiğini sayalım:
using Bookstore.Before;
using Bookstore.Demo;
using Bookstore.Ordering;
using Bookstore.Ordering.Commands;
CountingOrderRepository repository = new();
OrderService service = new(repository, TimeProvider.System);
CustomerId ada = new(Guid.CreateVersion7());
string[] titles = ["Dune", "Emma", "The Hobbit", "Middlemarch"];
Random random = new(42);
// Ada'nın 120 siparişi, her birinde 1-4 satır
for (int i = 0; i < 120; i++)
{
CartLine[] lines = [.. Enumerable.Range(0, random.Next(1, 5))
.Select(n => new CartLine(new BookId(Guid.CreateVersion7()), titles[(i + n) % titles.Length], 12.5m, random.Next(1, 3)))];
service.PlaceOrder(ada, lines);
}
IReadOnlyList<OrderListItem> page = service.GetCustomerOrders(ada, page: 1, pageSize: 20);
Console.WriteLine($"Rows on screen: {page.Count}");
Console.WriteLine($"Aggregates loaded: {repository.AggregatesLoaded}, items loaded: {repository.ItemsLoaded}");
Rows on screen: 20
Aggregates loaded: 120, items loaded: 305
20 satırlık bir ekran için 120 aggregate ve 305 sipariş satırı yüklendi. Gerçek bir veritabanında bu, her aggregate'in satırlarıyla birlikte (EF Core'da Include ile) çekilmesi, change tracker'ın bütün bu object'leri izlemesi ve sayfalamanın bellekte yapılması demek. Sorun yalnızca performans da değil. Ekran büyüdükçe Order'a Total, FirstTitle, belki kargo takip numarası için başka bir context'e ait bir alan eklenecek; repository'ye FindByCustomer, FindByCustomerPaged, FindAwaitingShipment gibi okuma method'ları dolacak. Greg Young, CQRS (Command Query Responsibility Segregation) üzerine 2010'da yayımladığı CQRS Documents'ta bu belirtileri sıralar: sayfalama ve sıralama parametreleriyle şişen repository'ler, DTO (Data Transfer Object) üretmek için iç state'i açan getter'lar, okuma senaryosu için önceden yüklenen ilişkiler ve tek bir ekran DTO'su için birden fazla aggregate yüklemek. Hepsinin kökü aynı: aynı model hem kural işletmeye hem de ekran beslemeye koşuluyor ve bu iki işin ihtiyaçları birbirine benzemiyor.
CQS: Method Seviyesindeki Kural
Fikrin kökü Bertrand Meyer'in (Eiffel dilinin tasarımcısı) 1988 tarihli Object-Oriented Software Construction kitabındaki CQS (Command-Query Separation) ilkesi: bir method ya state'i değiştirir (command) ya da bir değer döndürür (query), ikisini birden yapmaz. Soruyu sormak cevabı değiştirmemeli. Bu ilkeyi, istisnalarıyla birlikte (Stack<T>.Pop, HashSet<T>.Add'in bool dönmesi, ConcurrentDictionary.GetOrAdd) SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) ve Design Principles serisinin, GRASP'i (General Responsibility Assignment Software Patterns) da işleyen "Daha Az Bilinen Prensipler: Separation of Concerns, Least Astonishment, Fail Fast, CQS ve GRASP" yazısında işlemiştik; burada tekrar etmeyeceğim.
Aynı ayrımı her gün HTTP (Hypertext Transfer Protocol) seviyesinde de kullanıyorsun. RFC (Request for Comments) 9110 GET'i "safe" bir method olarak tanımlar: istemci GET ile sunucudaki state'i değiştirmeyi talep etmez. Cache'ler, tarayıcının ön yüklemesi ve crawler'lar bu sözleşmeye güvenir. ASP.NET Core'da bir MapGet endpoint'inin içinde sipariş güncellemek, CQS ihlalinin protokol seviyesindeki karşılığıdır.
CQS'in sınırı şu: tek bir object'in method'larından bahseder. Order üzerinde Cancel() void döner, Status property'si de bir şey değiştirmez; CQS açısından her şey yolunda. Ama yukarıdaki sorun hâlâ ortada, çünkü sorun method seviyesinde değil, model seviyesinde.
Greg Young'ın Adımı: Bir Object Yerine İki
CQRS uzun süre "daha yüksek seviyede CQS" olarak konuşuldu; Greg Young CQRS Documents'ta bu karışıklığın ardından ayrı bir pattern olarak kabul edildiğini anlatır. Tanımı şaşırtıcı derecede sade: önceden tek olan object'i ikiye ayırırsın, birinde command'lar, diğerinde query'ler durur. Young'ın kendi örneğinde CustomerService, CustomerWriteService ve CustomerReadService olarak ikiye bölünür. Udi Dahan 2009'daki "Clarified CQRS" yazısında, Martin Fowler da 2011'deki CQRS bliki yazısında aynı fikri, kaynağının Young olduğunu belirterek anlatır.
Ayrımın değeri, iki tarafın ihtiyaçlarının gerçekten farklı olmasından gelir. Young'ın listesine göre command tarafı tutarlı veriyle transaction işlemek ister ve normalize bir şemayı sever; query tarafı çoğu sistemde eventual consistency'yi kaldırabilir, join'i az, denormalize bir şemayı sever ve çoğu web sisteminde toplam işin çok büyük kısmını o yapar. Young'ın okuma tarafı için önerdiği şey bir "Thin Read Layer": domain'i tamamen atlayıp doğrudan veritabanından ekranın istediği DTO'yu üreten ince bir katman. Bu katmanın veritabanı üreticisine bağlı olması ya da stored procedure kullanması da onun gözünde sorun değil. Command tarafında ise repository'ler neredeyse yalnızca id ile aggregate getirmeye iner.
.NET'in kendisi de "aynı veri, okumaya göre ayrı şekil" fikrini kullanır. .NET 8 ile gelen System.Collections.Frozen namespace'indeki FrozenDictionary<TKey, TValue>, oluşturulması normal bir Dictionary'den pahalı olan ama lookup için optimize edilmiş, değiştirilemeyen bir collection'dır. Veriyi Dictionary ile kurar, okumaya hazır olunca ToFrozenDictionary() ile dondurursun. CQRS aynı takası uygulama seviyesinde yapar: yazma için bir model, okuma için başka bir model.
Ayrılmış Hâli: Command ve Query Tarafı
Bookstore'da ilk adım, OrderService'i ikiye bölmek. Write tarafında command'lar niyeti adlandıran record'lar; "alanı güncelle" yerine "siparişi iptal et" diyen, Young'ın task-based UI (user interface) dediği ekranlarla doğal olarak eşleşirler. Repository yalnızca aggregate yüklüyor ve ekliyor:
using Bookstore.Ordering;
namespace Bookstore.Ordering.Commands;
// Write tarafı: niyeti adlandıran command'lar
public sealed record PlaceOrder(CustomerId CustomerId, IReadOnlyList<CartLine> Lines);
public sealed record CancelOrder(OrderId OrderId, CancellationReason Reason);
// Repository yalnızca aggregate yükler ve ekler; liste sorgusu yok
public interface IOrderRepository
{
Order? Find(OrderId id);
void Add(Order order);
}
public sealed class OrderCommandHandler(IOrderRepository orders, TimeProvider clock)
{
public OrderId Handle(PlaceOrder command)
{
Order order = Order.Place(command.CustomerId, command.Lines, clock);
orders.Add(order);
return order.Id;
}
public void Handle(CancelOrder command) =>
(orders.Find(command.OrderId) ?? throw new KeyNotFoundException($"Order {command.OrderId.Value} not found."))
.Cancel(command.Reason);
}
Read tarafında ise Order hiç geçmiyor. Query de bir record, sonucu ekranın istediği şekilde bir DTO:
namespace Bookstore.Ordering.Queries;
// Read tarafı: ekranın istediği şekil, domain logic yok
public sealed record GetCustomerOrders(Guid CustomerId, int Page, int PageSize);
public sealed record CustomerOrderRow(
Guid OrderId, DateTimeOffset PlacedAt, string Status, int ItemCount, decimal Total, string FirstTitle);
public interface ICustomerOrderQueries
{
Task<IReadOnlyList<CustomerOrderRow>> HandleAsync(GetCustomerOrders query, CancellationToken ct);
}
Command tarafını çalıştıralım; aynı siparişi iki kez iptal etmeye çalışınca kuralı aggregate koruyor:
// Command tarafı: void ya da yeni id döner, kural ihlali DomainException
OrderCommandHandler commands = new(new InMemoryOrderRepository(), TimeProvider.System);
OrderId placed = commands.Handle(new PlaceOrder(ada, [new CartLine(new BookId(Guid.CreateVersion7()), "Dune", 12.5m, 1)]));
commands.Handle(new CancelOrder(placed, CancellationReason.CustomerRequest));
try
{
commands.Handle(new CancelOrder(placed, CancellationReason.CustomerRequest));
}
catch (DomainException ex)
{
Console.WriteLine($"{ex.Code}: {ex.Message}");
}
order.cancelled: Order is already cancelled.
Birkaç ayrıntıya dikkat et. PlaceOrder handler'ı yeni siparişin id'sini döndürüyor; CQS'in katı yorumunda bu bile bir ihlal, ama istemcinin oluşturduğu kaynağın adresini bilmesi gerekiyor ve id'yi command'ı gönderen taraf üretmediği sürece pratik çözüm bu. Command DTO döndürmüyor; "siparişin son hâli" bir okuma işi. ICustomerOrderQueries'in henüz bir implementasyonu yok, çünkü onu nasıl yazacağın CQRS'in hangi seviyesini seçtiğine bağlı. Repository'deki FindByCustomer da gitti; okuma ihtiyacı artık write modelin derdi değil.
CQRS'in Üç Seviyesi
Microsoft Learn'deki CQRS pattern sayfası iki yaklaşım tanımlar: aynı data store üzerinde ayrı modeller ve ayrı data store'lar. Pratikte araya bir basamak daha girer ve şöyle üç seviye ortaya çıkar:

Seviye 1'de veritabanı ve tablolar aynı; ayrılan şey code path. Command tarafı aggregate'i EF Core'un change tracker'ıyla yükleyip kaydeder, query tarafı aynı tablolardan AsNoTracking ile projection yapar ya da Dapper ile ham SQL (Structured Query Language) çalıştırır. Tutarlılık anlık, ek altyapı yok. Bu seviye CQRS'in en ucuz hâli ve çoğu ekip için en çok işe yarayanı; serinin bir sonraki yazısının konusu.
Seviye 2'de okuma için ayrı bir model, yani ayrı bir şekil var ama hâlâ aynı veritabanında: bir SQL view, bir materialized view ya da command ile aynı transaction'da güncellenen denormalize bir tablo. Pahalı join'ler okuma anından yazma anına taşınır, tutarlılık yine anlıktır.
Seviye 3'te read model ayrı bir store'da yaşar: bir document veritabanı, bir arama index'i ya da ayrı bir PostgreSQL. Write tarafı her değişiklikte event yayınlar, bir projector bu event'leri okuyup read store'u günceller. Okuma tarafını bağımsız ölçekleyebilir, farklı bir teknoloji seçebilirsin; karşılığında eventual consistency, event'in kaybolmaması için outbox, aynı event iki kez gelirse bozulmayan bir projector ve yeni bir operasyon yükü gelir. Microsoft Learn de ayrı store'larda Transactional Outbox ve idempotent consumer kullanılmasını önerir. Seviye 2 ve 3, serinin üçüncü yazısında.
Her seviye bir öncekinin üstüne gelir ve her biri bir bedel öder. Seviye atlamanın gerekçesi teknoloji merakı değil, ölçülmüş bir ihtiyaç olmalı: yavaş bir sorgu, kilitlenen tablolar, ayrı ölçeklenmesi gereken bir okuma yükü.
CQRS Ne Değildir
Greg Young, 2010'daki "CQRS, Task Based UIs, Event Sourcing agh!" yazısını tam da bu karışıklık için yazmıştı. Ona göre CQRS bir mimari değil, mimari seçenekler açan küçük bir pattern. Eventual consistency değil, eventing ya da messaging değil, event sourcing de değil. Bunlar CQRS'in yanında sık görülen ama ayrı kararlar.
Event Sourcing, state yerine olanları saklamaktır ve bu serinin dördüncü yazısından itibaren konumuz. CQRS ile sık birlikte anılmasının sebebi, event'lerin read model'leri beslemek için doğal bir kaynak olması; ama event sourcing olmadan CQRS, CQRS olmadan event sourcing (pratikte nadir de olsa) mümkün.
MediatR da CQRS değil. MediatR bir mediator implementasyonu: bir request object'ini handler'ına ulaştırır ve command ile query arasında hiçbir ayrım yapmaz, ikisi de IRequest<T>. Yukarıdaki kod hiçbir library kullanmadan CQRS uyguluyor. Mediator'lı ve mediator'sız seçenekleri Vertical Slice Architecture serisinin "Mediator'lı Slice: MediatR, Mediator (Source Generator) ve Wolverine Karşılaştırması" yazısında, mediator'ın gerçekten gerekip gerekmediğini de Design Patterns serisinin "Mediator Pattern: Object'ler Arası Konuşmayı Merkezileştirmek ve MediatR'a Gerçekten İhtiyacım Var mı?" yazısında tartışmıştık.
İki veritabanı da şart değil. "CQRS kuruyoruz" cümlesi çoğu zaman "ikinci bir veritabanı ve bir message broker ekliyoruz" diye anlaşılıyor; oysa seviye 1, tek veritabanında ve sıfır yeni altyapıyla tam bir CQRS.
Sık Düşülen Tuzaklar
Bütün sisteme CQRS uygulamak
Fowler'ın bliki yazısındaki en net uyarı bu: CQRS'i bir sistemin bütününe değil, belirli parçalarına, DDD'deki bir bounded context gibi bir sınırın içine uygula. Bookstore'da yazma kuralları zengin, okuma ekranları çeşitli olan Ordering iyi bir aday; kitap ve yazar kayıtlarını yöneten Catalog admin ekranları için CRUD (Create, Read, Update, Delete) yeterli.
Command'dan okuma modeli döndürmek
PlaceOrder sonrası ekranı yenilemek için handler'ın OrderDetailsDto döndürmesi masum görünür. Bir süre sonra command handler'ları ekranın ihtiyaçlarına göre şekillenir ve okuma tarafının sorumluluğu yazma tarafına geri sızar. Command id, versiyon ya da sonuç kodu döndürsün; ekran veriyi query'den okusun.
Read tarafında iş kuralını tekrar hesaplamak
Liste ekranında toplam tutarı "liste fiyatı çarpı adet" diye yeniden hesaplamak, kampanya indirimini kaçırmanın en kısa yolu. Kural write tarafında bir kez işletilir, sonucu (UnitPrice ve toplam) kaydedilir; read tarafı yalnızca kaydedileni gösterir.
Seviye 3 ile başlamak
Henüz yavaş bir sorgun yokken ayrı read store, broker ve projector kurmak, eventual consistency'nin bütün UX (user experience) ve operasyon sorunlarını karşılığında hiçbir şey almadan satın almak demek. Seviye 1 ile başla, ölçtüğün bir sorun seni seviye 2'ye taşısın.
Ne Zaman Kullanma
Fowler 2011'de CQRS'e çok temkinli yaklaşılması gerektiğini, karşılaştığı vakaların çoğunun iyi gitmediğini ve sistemlerin büyük kısmına CRUD modelinin daha iyi oturduğunu yazar; CQRS'i karmaşık domain'lerin ve okuma ile yazma yükünün çok farklı olduğu yüksek performanslı sistemlerin küçük bir azınlığına uygun bulur. Microsoft Learn de domain ve iş kuralları basitse, CRUD tarzı bir arayüz ve veri erişimi yetiyorsa pattern'in uygun olmayabileceğini söyler. Bookstore özelinde: Notification context'inin şablon yönetimi, Catalog'un kategori listesi, ödeme sağlayıcısına yalnızca istek ileten Payments adapter'ı için ayrım bir şey kazandırmaz. Ekip üç kişiyken iki modeli senkron tutmanın bakım maliyeti de hesaba girmeli. Seviye 1 bile, okuma ve yazma ekranlarının birbirinin aynısı olduğu yerde gereksiz bir ikinci class demektir.
Küçük Bir Deneme
★☆☆ Eski OrderService'e "kargoya verilmeyi bekleyen siparişler" ekranını ekle. Repository'ye hangi method gelmek zorunda kalıyor, Order'a hangi getter açılıyor? Aynı ekranı ayrılmış hâlde ICustomerOrderQueries benzeri yeni bir query interface'i olarak tasarla ve iki tasarımda değişen dosya sayısını karşılaştır.
★★☆ PlaceOrder handler'ı id yerine hiçbir şey döndürmesin: id'yi command'ı oluşturan istemci (Guid.CreateVersion7() ile) üretsin. Bu, tekrar gönderilen aynı command'ı tanımayı nasıl kolaylaştırır, hangi yeni riski getirir?
★★★ Bookstore'un altı context'ini (Catalog, Ordering, Inventory, Payments, Shipping, Notification) "CQRS yok, seviye 1, seviye 2, seviye 3" diye sınıflandır. Her biri için Mimariye Giriş serisindeki sayıları (günde ~800 sipariş, kampanyada dakikada ~120 sipariş, arama için saniyede ~200 istek) kullanarak tek cümlelik bir gerekçe yaz.
Bir sonraki yazıda seviye 1'i gerçek bir PostgreSQL veritabanında kuracağız: PlaceOrder command'ı aggregate ve SaveChanges ile, "Siparişlerim" ekranı EF Core projection'ı ile, günlük satış ekranı Dapper ve ham SQL ile; query tarafının state değiştirmediğini de yalnızca kod disipliniyle değil, veritabanının kendisine söyleterek garanti edeceğiz.