Tek Veritabanında CQRS: Write Tarafında Aggregate, Read Tarafında Projection
Tek veritabanında CQRS, yazma tarafında aggregate'i EF Core'un change tracker'ıyla kaydedip okuma tarafında aynı tablolardan aggregate'e hiç dokunmadan projection yapmaktır. PlaceOrder ve CancelOrder command'larının ne döndürmesi gerektiği, ayrı ve düz bir read DbContext, SQL view üstünde Dapper ve query'nin state değiştirmediğini PostgreSQL'e read-only transaction ile söyletmek, gerçek SQL ve 25006 hatasıyla.
Sürüm notu: Bu yazıdaki kodlar C# 14, .NET 10, Entity Framework Core (EF Core) 10, Npgsql 10, Dapper 2.1 ve PostgreSQL 17 ile derlenip denenmiştir.
Önce Tahmin Et
Önceki yazıda CQRS'i (Command Query Responsibility Segregation) "bir object yerine iki" diye tanımlamış ve üç seviyesini çizmiştik. Bir ekip arkadaşın seviye 1'i uyguladığını söylüyor: "Siparişlerim" ekranı için artık ayrı bir query handler var. İşte o handler:
using Bookstore.Infrastructure;
using Bookstore.Ordering;
using Bookstore.Ordering.Queries;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Before;
public sealed class GetCustomerOrdersHandler(OrderingDbContext db)
{
public async Task<IReadOnlyList<CustomerOrderRow>> HandleAsync(GetCustomerOrders query, CancellationToken ct)
{
CustomerId customerId = new(query.CustomerId);
List<Order> orders = await db.Orders
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.PlacedAt)
.ToListAsync(ct);
// Pazarlama ekibi "son görülme" istemişti
await db.Database.ExecuteSqlAsync($"""
INSERT INTO ordering.customer_activity (customer_id, last_seen_at) VALUES ({query.CustomerId}, now())
ON CONFLICT (customer_id) DO UPDATE SET last_seen_at = excluded.last_seen_at
""", ct);
return [.. orders
.Skip((query.Page - 1) * query.PageSize)
.Take(query.PageSize)
.Select(o => new CustomerOrderRow(
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))];
}
}
Bu class adında "query" geçen ama CQRS'in iki temel sözünü de bozan bir handler. Hangi iki söz? Bu handler'ın çalıştırdığı SQL (Structured Query Language), Ada'nın 20 siparişini göstermek için veritabanından kaç satır ister? Ve "son görülme" kaydı, bir cache ya da bir crawler aynı GET isteğini tekrarladığında ne olur?
Seviye 1'in Sözleri
Seviye 1'de veritabanı tek, tablolar aynı; ayrılan şey yazma ve okuma yolları. Bu ayrımın iki sözü var. Birincisi: query state değiştirmez. İkincisi: query, write modelini (aggregate'i ve onun change tracker'ını) kullanmaz; ekranın istediği şekli doğrudan veriden üretir. Yukarıdaki handler ikisini de bozuyor. EF Core'un ürettiği SQL'e bakalım:
-- @ada='0195f0a0-0000-7000-9000-00000000000a'
SELECT o.id, o.customer_id, o.placed_at, o.status, o.version, o0.id, o0.book_id, o0.order_id, o0.quantity, o0.title, o0.unit_price
FROM ordering.orders AS o
LEFT JOIN ordering.order_items AS o0 ON o.id = o0.order_id
WHERE o.customer_id = @ada
ORDER BY o.placed_at DESC, o.id
LIMIT yok: müşterinin bütün siparişleri, bütün satırlarıyla join'lenip geliyor ve sayfalama bellekte yapılıyor. Sipariş satırları owned entity olarak tanımlandığı için EF Core onları her sorguda otomatik ekliyor; aggregate'in tutarlılık sınırı okuma tarafında da "her şeyi getir" demek. Üstüne bir de INSERT var: bir GET isteği veritabanına yazıyor. Bu yazıda aynı ekranı doğru kuracağız ve ikinci sözü yalnızca code review'a bırakmayıp veritabanına da söyleteceğiz. Örnekler Bookstore'un Ordering context'i üzerinde, PostgreSQL'de; serinin ilerleyen yazılarında event store olarak da PostgreSQL kullanacağımız için motoru baştan sabitliyoruz.
Write Tarafı: Command, Aggregate ve SaveChanges
Write tarafı, DDD (Domain-Driven Design) serisinden tanıdığın düzen: command bir record, handler aggregate'i oluşturur ya da yükler, davranışını çağırır ve kaydeder.
using Bookstore.Infrastructure;
using Bookstore.Ordering;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Ordering.Commands;
public sealed record PlaceOrderLine(Guid BookId, int Quantity);
public sealed record PlaceOrder(Guid CustomerId, IReadOnlyList<PlaceOrderLine> Lines);
public sealed class PlaceOrderHandler(OrderingDbContext db, TimeProvider clock)
{
// Yeni siparişin id'sini döndürür; ekran verisini değil
public async Task<OrderId> HandleAsync(PlaceOrder command, CancellationToken ct)
{
Guid[] bookIds = [.. command.Lines.Select(l => l.BookId).Distinct()];
Dictionary<Guid, Book> books = await db.Books.AsNoTracking()
.Where(b => bookIds.Contains(b.Id))
.ToDictionaryAsync(b => b.Id, ct);
CartLine[] lines = [.. command.Lines.Select(l => books.TryGetValue(l.BookId, out Book? book)
? new CartLine(new BookId(book.Id), book.Title, book.ListPrice, l.Quantity)
: throw new DomainException("order.unknown-book", $"Book {l.BookId} is not in the catalog."))];
Order order = Order.Place(new CustomerId(command.CustomerId), lines, clock);
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return order.Id;
}
}
public sealed record CancelOrder(OrderId OrderId, CancellationReason Reason);
public sealed class CancelOrderHandler(OrderingDbContext db)
{
// false: sipariş yok; kural ihlali DomainException olarak yukarı çıkar
public async Task<bool> HandleAsync(CancelOrder command, CancellationToken ct)
{
Order? order = await db.Orders.SingleOrDefaultAsync(o => o.Id == command.OrderId, ct);
if (order is null)
return false;
order.Cancel(command.Reason);
await db.SaveChangesAsync(ct);
return true;
}
}
PlaceOrderHandler kitapların adını ve fiyatını Catalog tablosundan okuyor. Bu bir query mi? Hayır; karar vermek için gereken veriyi okumak command'ın parçası. CQRS command handler'ın hiç okuma yapmamasını söylemez, okuma modelini döndürmemesini söyler. Kitap adı ve birim fiyat siparişe o anki hâliyle yazılıyor, böylece katalogda fiyat değişince eski siparişin tutarı değişmiyor.
EF Core tarafında aggregate'in mapping'i DDD serisinin "Aggregate'leri EF Core 10 ile Persist Etmek: Backing Field, Owned/Complex Type ve Concurrency" yazısındaki gibi: strongly typed id'ler için value converter, sipariş satırları OwnsMany ile ayrı bir tabloda, Version bir concurrency token. Aynı siparişi iki istek aynı anda iptal etmeye çalışırsa ikincisi DbUpdateConcurrencyException alır; ayrıntısı EF Core serisinin "EF Core'da Concurrency: Token, rowversion ve DbUpdateConcurrencyException" yazısında.
// Write tarafı: aggregate'i ve change tracker'ı bilen DbContext
public sealed class OrderingDbContext(DbContextOptions<OrderingDbContext> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<Book> Books => Set<Book>();
protected override void OnModelCreating(ModelBuilder model)
{
model.Entity<Book>().ToTable("books", "catalog");
model.Entity<Order>(order =>
{
order.ToTable("orders", "ordering");
order.Property(o => o.Id).HasConversion(id => id.Value, value => new OrderId(value));
order.Property(o => o.CustomerId).HasConversion(id => id.Value, value => new CustomerId(value));
order.Property(o => o.Status).HasConversion<string>().HasMaxLength(20);
order.Property(o => o.Version).IsConcurrencyToken();
order.HasIndex(o => new { o.CustomerId, o.PlacedAt });
order.OwnsMany(o => o.Items, item =>
{
item.ToTable("order_items", "ordering");
item.WithOwner().HasForeignKey("OrderId");
item.Property<int>("Id");
item.HasKey("Id");
item.Property(i => i.BookId).HasConversion(id => id.Value, value => new BookId(value));
item.Property(i => i.Title).HasMaxLength(200);
item.Property(i => i.UnitPrice).HasPrecision(10, 2);
});
order.Navigation(o => o.Items).HasField("_items");
});
}
}
Command Ne Döndürmeli?
Meyer'in katı CQS'ine (Command-Query Separation) göre command hiçbir şey döndürmez. Pratikte üç makul cevap var. Void: iptal gibi, istemcinin zaten bildiği bir kaynağı değiştiren command'lar. Yeni id: oluşturan command'lar, çünkü istemci kaynağın adresini bilmek zorunda. Sonuç: işlemin bir iş kuralına takılıp takılmadığını anlatan küçük bir değer (bir sonuç kodu ya da yeni versiyon numarası). Döndürmemesi gereken şey ise ekran DTO'su (Data Transfer Object): "siparişin son hâli" bir okuma işi ve onu command'dan döndürmeye başladığın gün read modelin şekli write tarafına sızar.
HTTP (Hypertext Transfer Protocol) tarafında bu üç cevap tanıdık status code'lara oturur. Command'lar POST, query'ler GET:
using Bookstore.Ordering;
using Bookstore.Ordering.Commands;
using Bookstore.Ordering.Queries;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Web;
public static class OrderingEndpoints
{
public static void MapOrdering(this IEndpointRouteBuilder app)
{
// Command'lar: POST, sonuç olarak yalnızca id ya da status code
app.MapPost("/orders", async (PlaceOrder command, PlaceOrderHandler handler, CancellationToken ct) =>
{
OrderId id = await handler.HandleAsync(command, ct);
return Results.Created($"/orders/{id.Value}", new { orderId = id.Value });
});
app.MapPost("/orders/{orderId:guid}/cancel", async (Guid orderId, CancelOrderHandler handler, CancellationToken ct) =>
await handler.HandleAsync(new CancelOrder(new OrderId(orderId), CancellationReason.CustomerRequest), ct)
? Results.NoContent()
: Results.NotFound());
// Query'ler: GET, state'e dokunmaz
app.MapGet("/customers/{customerId:guid}/orders", (Guid customerId, int? page, GetCustomerOrdersHandler handler, CancellationToken ct) =>
handler.HandleAsync(new GetCustomerOrders(customerId, page ?? 1, 20), ct));
app.MapGet("/reports/daily-sales", (DateOnly from, DateOnly to, GetDailySalesHandler handler, CancellationToken ct) =>
handler.HandleAsync(new GetDailySales(from, to), ct));
}
}
public sealed class DomainExceptionHandler(IProblemDetailsService problems) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, CancellationToken ct)
{
(int status, string code)? mapped = exception switch
{
DomainException domain => (StatusCodes.Status409Conflict, domain.Code),
DbUpdateConcurrencyException => (StatusCodes.Status409Conflict, "order.concurrency"),
_ => null
};
if (mapped is not { } m)
return false;
context.Response.StatusCode = m.status;
return await problems.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = context,
ProblemDetails = { Status = m.status, Title = exception.Message, Type = m.code }
});
}
}
Kural ihlali bir DomainException olarak yukarı çıkıyor ve ASP.NET Core'un .NET 8 ile gelen IExceptionHandler'ı onu RFC (Request for Comments) 9457'deki problem details biçiminde 409'a çeviriyor. Status code'ların ve ProblemDetails'in ayrıntısı ASP.NET Core Web API (Application Programming Interface) serisinin "ASP.NET Core'da Status Code ve Response: Ok, CreatedAtAction ve ProblemDetails" yazısında. Gerçek koşu:
POST /orders -> 201, Location: /orders/01a1b83a-3456-78e1-9e26-3f9b88b1b76c
POST cancel -> 204
POST cancel again -> 409 {"type":"order.cancelled","title":"Order is already cancelled.","status":409,"traceId":"0HNPT49CK0LT5:00000008"}
Sipariş id'si Guid v7 olduğu için zamana göre sıralı; ilk 48 bit oluşturulma anının milisaniyesi. İstemci ekranı yenilemek isterse Location'daki adrese ya da listeye bir GET atar.
Read Tarafı: Aynı Tablolara Düz Bir EF Core Modeli
Okuma tarafının aggregate'e ihtiyacı yok. Value converter'lar, backing field'lar, concurrency token, hiçbiri bir listeyi doldurmak için gerekli değil. Bu yüzden read tarafına aynı tablolara bakan ayrı ve düz bir DbContext veriyoruz:
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Ordering.Queries;
// Read tarafının kendi, düz modeli: aynı tablolar, domain tipi yok
public sealed class OrderRow
{
public Guid Id { get; init; }
public Guid CustomerId { get; init; }
public DateTimeOffset PlacedAt { get; init; }
public string Status { get; init; } = "";
public List<OrderItemRow> Items { get; init; } = [];
}
public sealed class OrderItemRow
{
public int Id { get; init; }
public Guid OrderId { get; init; }
public string Title { get; init; } = "";
public decimal UnitPrice { get; init; }
public int Quantity { get; init; }
}
public sealed class OrderingReadDbContext : DbContext
{
public OrderingReadDbContext(DbContextOptions<OrderingReadDbContext> options) : base(options) =>
ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
public DbSet<OrderRow> Orders => Set<OrderRow>();
protected override void OnModelCreating(ModelBuilder model)
{
model.Entity<OrderRow>().ToTable("orders", "ordering");
model.Entity<OrderItemRow>().ToTable("order_items", "ordering");
model.Entity<OrderRow>().HasMany(o => o.Items).WithOne().HasForeignKey(i => i.OrderId);
}
// Read model'den kayıt yapılmaz
public override int SaveChanges(bool acceptAllChangesOnSuccess) =>
throw new InvalidOperationException("OrderingReadDbContext is read-only.");
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken ct = default) =>
throw new InvalidOperationException("OrderingReadDbContext is read-only.");
}
İki ayrıntı önemli. Context, constructor'da tracking'i kapatıyor; her sorgu AsNoTracking gibi davranıyor. SaveChanges'in iki overload'u da exception fırlatıyor, yani biri yanlışlıkla read context'e bir entity ekleyip kaydetmeye kalkarsa çalışma anında duruyor. Migration'lar yalnızca write context'ten üretilir; read context şemanın sahibi değil, kullanıcısı. Bedeli belli: bir kolonun adı değişirse iki mapping'i birden güncellemek gerekir.
Handler, ekranın istediği her şeyi SQL'de hesaplatan bir projection:
using Dapper;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace Bookstore.Ordering.Queries;
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 sealed class GetCustomerOrdersHandler(OrderingReadDbContext db)
{
public async Task<IReadOnlyList<CustomerOrderRow>> HandleAsync(GetCustomerOrders query, CancellationToken ct) =>
await Build(query).ToListAsync(ct);
// Ekranın istediği kolonlar SQL'de hesaplanır; entity materialize edilmez
public IQueryable<CustomerOrderRow> Build(GetCustomerOrders query) =>
db.Orders
.Where(o => o.CustomerId == query.CustomerId)
.OrderByDescending(o => o.PlacedAt)
.Skip((query.Page - 1) * query.PageSize)
.Take(query.PageSize)
.Select(o => new CustomerOrderRow(
o.Id, o.PlacedAt, o.Status,
o.Items.Sum(i => i.Quantity),
o.Items.Sum(i => i.UnitPrice * i.Quantity),
o.Items.OrderBy(i => i.Id).Select(i => i.Title).First()));
}
-- @query_CustomerId='0195f0a0-0000-7000-9000-00000000000a'
-- @p2='20'
-- @p='0'
SELECT o.id, o.placed_at, o.status, (
SELECT COALESCE(sum(o0.quantity), 0)::int
FROM ordering.order_items AS o0
WHERE o.id = o0.order_id), (
SELECT COALESCE(sum(o1.unit_price * o1.quantity::numeric), 0.0)
FROM ordering.order_items AS o1
WHERE o.id = o1.order_id), (
SELECT o2.title
FROM ordering.order_items AS o2
WHERE o.id = o2.order_id
ORDER BY o2.id
LIMIT 1)
FROM ordering.orders AS o
WHERE o.customer_id = @query_CustomerId
ORDER BY o.placed_at DESC
LIMIT @p2 OFFSET @p
Sayfalama artık veritabanında (LIMIT/OFFSET), adet, tutar ve ilk kitap adı correlated subquery'lerle hesaplanıyor, hiçbir entity materialize edilmiyor. Bu SQL'in ayrıntılı eleştirisini ve projection'ın performans tarafını EF Core serisinin "EF Core'da Performans Temelleri: N+1, Projection, AsNoTracking ve Compiled Query" ile "EF Core ve Mimari: Repository, CQRS'in Okuma Tarafı ve Dapper'a Geçiş" yazılarında yapmıştık; burada önemli olan, query'nin Order sınıfının varlığından bile haberdar olmaması. Ada'nın ekranı:
2026-11-04 16:00 Placed qty=3 29.70 EUR Emma
2026-11-03 14:00 Placed qty=3 40.50 EUR The Hobbit
2026-11-02 14:00 Cancelled qty=1 11.20 EUR Middlemarch
2026-11-02 09:00 Placed qty=3 32.30 EUR Dune
Read Tarafı: SQL View ve Dapper
Back-office'teki günlük satış ekranı başka bir okuma ihtiyacı: siparişler gün gün gruplanacak, iptaller hariç tutulacak. Bunu LINQ (Language Integrated Query) ile yazmak mümkün ama Greg Young'ın "Thin Read Layer" dediği katmanın veritabanına bağlı olmasında sakınca yok. Rapor mantığını bir view'a koyuyoruz:
CREATE VIEW ordering.daily_sales AS
SELECT (o.placed_at AT TIME ZONE 'UTC')::date AS day,
count(DISTINCT o.id) AS orders,
sum(i.quantity) AS copies,
sum(i.unit_price * i.quantity) AS revenue
FROM ordering.orders o
JOIN ordering.order_items i ON i.order_id = o.id
WHERE o.status <> 'Cancelled'
GROUP BY 1;
Dapper bu view'dan okuyor ve satırları doğrudan record'a map ediyor:
public sealed record GetDailySales(DateOnly From, DateOnly To);
public sealed record DailySalesRow(DateOnly Day, long Orders, long Copies, decimal Revenue);
public sealed class GetDailySalesHandler(NpgsqlDataSource readSource)
{
// Rapor SQL'i veritabanındaki view'da; Dapper yalnızca okur
private const string Sql = """
SELECT day, orders, copies, revenue
FROM ordering.daily_sales
WHERE day BETWEEN @From AND @To
ORDER BY day
""";
public async Task<IReadOnlyList<DailySalesRow>> HandleAsync(GetDailySales query, CancellationToken ct)
{
await using NpgsqlConnection connection = await readSource.OpenConnectionAsync(ct);
IEnumerable<DailySalesRow> rows = await connection.QueryAsync<DailySalesRow>(
new CommandDefinition(Sql, query, cancellationToken: ct));
return [.. rows];
}
}
2026-11-02 orders=2 copies=4 revenue=46.30
2026-11-03 orders=2 copies=5 revenue=61.60
2026-11-04 orders=1 copies=3 revenue=29.70
2 Kasım'da Ada'nın Middlemarch siparişi iptal edildiği için o günün satırında iki sipariş görünüyor. View hâlâ seviye 1: her sorguda tablolardan yeniden hesaplanıyor, ayrıca saklanan bir şey yok. Okuma maliyeti büyüdüğünde bu view'ı materialized view'a ya da denormalize bir tabloya çevirmek seviye 2'ye geçmek demek; o da bir sonraki yazının konusu. Dapper'ın parametre, mapping ve connection yönetimi ayrıntıları Dapper serisinde; burada record'un primary constructor'ının kolon adlarıyla eşleşmesi yeterli. Npgsql 10 date kolonunu DateOnly'ye çevirmeyi de sorunsuz yapıyor.
Query State Değiştirmez: Bunu Veritabanına Söylet
SaveChanges'i kapatmak iyi bir başlangıç ama tek kapı o değil. EF Core 7 ile gelen ExecuteUpdate, change tracker'ı ve SaveChanges'i hiç kullanmadan doğrudan UPDATE gönderir; Dapper ve ham SQL de override'dan habersizdir. Asıl garantiyi veritabanından alıyoruz. Read tarafının connection'ı, PostgreSQL oturumunu açarken default_transaction_read_only ayarını açıyor:
string writeConnection = builder.Configuration.GetConnectionString("Ordering")!;
// Read tarafı: aynı veritabanı, ama oturum seviyesinde salt okunur transaction'lar
NpgsqlDataSource readSource = new NpgsqlDataSourceBuilder(
writeConnection + ";Options=-c default_transaction_read_only=on").Build();
Npgsql'in Options connection string parametresi bu -c ayarlarını oturum başında sunucuya iletir. Şimdi read tarafından üç farklı yoldan yazmayı deneyelim:
// ExecuteUpdate SaveChanges'i hiç çağırmaz; override onu durduramaz
try
{
await readDb.Orders.Where(o => o.CustomerId == Setup.Ada)
.ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, "Paid"));
}
catch (PostgresException ex)
{
Console.WriteLine($"ExecuteUpdate: {ex.SqlState} {ex.MessageText}");
}
// "Siparişlerim" sorgusunun içinde son görülme zamanını yazmaya çalışan handler
try
{
await using NpgsqlConnection connection = await readSource.OpenConnectionAsync();
await connection.ExecuteAsync("""
INSERT INTO ordering.customer_activity (customer_id, last_seen_at) VALUES (@Ada, now())
ON CONFLICT (customer_id) DO UPDATE SET last_seen_at = excluded.last_seen_at
""", new { Setup.Ada });
}
catch (PostgresException ex)
{
Console.WriteLine($"Dapper: {ex.SqlState} {ex.MessageText}");
}
SaveChanges: OrderingReadDbContext is read-only.
ExecuteUpdate: 25006 cannot execute UPDATE in a read-only transaction
Dapper: 25006 cannot execute INSERT in a read-only transaction
25006, PostgreSQL'in read_only_sql_transaction hata kodu. Açılıştaki "son görülme" kaydı da read connection'ı üzerinden yapılsaydı aynı hatayı alırdı. Bu ayar bir güvenlik sınırı değil, bir korkuluk: kod SET TRANSACTION READ WRITE diyerek onu aşabilir. Gerçek sınır istiyorsan read tarafına yalnızca SELECT yetkisi olan ayrı bir veritabanı rolü ver; ileride okuma yükünü bir read replica'ya taşıdığında da read connection zaten doğru yere bakıyor olur. "Son görülme" gibi bir ihtiyaç gerçekse, o ayrı bir command'dır: istemci ekran açıldığında POST /customers/{id}/activity gönderir ya da bu bilgi request log'larından üretilir.
Sık Düşülen Tuzaklar
Read tarafında repository ve aggregate
Açılıştaki handler'ın asıl hatası bu. Repository aggregate'i bütün hâliyle getirmek için var; okuma tarafında kullanıldığında sayfalama belleğe, hesaplama C#'a kayar ve aggregate'e ekran için getter'lar eklenir. Query handler'ın bağımlılıkları bir read context, bir NpgsqlDataSource ya da bir view olmalı; IOrderRepository değil.
Query handler'da yan etki
"Son görülme", "görüntülenme sayısı", "okundu olarak işaretle"... Hepsi masum görünen yazma işlemleri. GET'in safe olduğunu varsayan her katman (HTTP cache, tarayıcı ön yüklemesi, retry yapan bir istemci) bu yazmayı beklenmedik sayıda tekrarlar. Yazma bir command'dır, kendi endpoint'i ve kendi handler'ı olur.
Read ve write için aynı DTO
CustomerOrderRow'u command'ın girdisi olarak da kullanmak cazip: alanlar zaten orada. Bir süre sonra Status alanını istemcinin gönderdiği bir sipariş güncelleme isteği ortaya çıkar ve CRUD (Create, Read, Update, Delete) geri gelmiş olur. Command'lar niyeti (CancelOrder), query sonuçları ekranı anlatır; aynı record'u paylaşmazlar.
Mediator pipeline'ını CQRS sanmak
Command ve query'leri aynı IRequest<T> arayüzünden geçirip aynı pipeline behavior'larına (transaction, validation) sokmak, ayrımı isimden ibaret bırakır. Query'yi transaction'a sokan bir behavior, read-only connection'la çalışan bir handler'ı da bozar. Ayrımı tiplerle ve bağımlılıklarla yap: write handler'ı OrderingDbContext, read handler'ı read context ya da NpgsqlDataSource alsın.
Ne Zaman Kullanma
Seviye 1'in maliyeti düşük ama sıfır değil: ikinci bir DbContext, ikinci bir mapping ve iki tarafın aynı şemaya bağlı kalması. Ekranın gösterdiği şey ile aggregate'in tuttuğu şey aynıysa (Catalog'da bir kategori listesi, adı ve sırası) tek bir DbContext ve AsNoTracking bir sorgu yeterli; ayrı read context gereksiz bir katman. Read context'i, okuma ekranları çoğalmaya ve aggregate'e ekran için getter eklemeye başladığında ayır. Read-only connection ise neredeyse her zaman ucuz bir kazanç; tek bir connection string farkı.
Küçük Bir Deneme
★☆☆ Demo'daki read connection string'inden Options=-c default_transaction_read_only=on kısmını çıkar ve programı tekrar çalıştır. Hangi iki satır değişiyor, customer_activity tablosunda ne görüyorsun?
★★☆ GetOrderDetails(Guid OrderId) query'sini yaz: sipariş başlığı ve bütün satırlar, tek round trip'te. Önce read context ile Include kullanarak, sonra Dapper'ın QueryMultipleAsync'iyle yaz ve iki yolun ürettiği SQL'i karşılaştır.
★★★ Read tarafı için ayrı bir PostgreSQL rolü oluştur (CREATE ROLE bookstore_reader LOGIN, şemalara USAGE, tablolara ve view'a yalnızca SELECT). Read connection'ı bu rolle aç ve SET TRANSACTION READ WRITE ile korkuluğu aşmayı dene. Bu kez hangi hata kodunu alıyorsun?
Liste ekranımız artık her istekte üç correlated subquery çalıştırıyor. Kampanya döneminde dakikada yüzlerce kez açılan bir ekranda bu hesap pahalılaşırsa, hesabı okuma anından yazma anına taşımak gerekir. Bir sonraki yazıda tam bunu yapacağız: ayrı bir read model tablosu, onu domain event'lerinden besleyen bir projector, aynı transaction ile asenkron güncelleme arasındaki fark ve read-your-own-writes sorunuyla yaşamak.