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

Ayrı Read Model: Materialized View, Senkronizasyon ve Eventual Consistency ile Yaşamak

Mert Özen 7 Kas 2026 14 dk 3 görüntülenme
Ayrı Read Model: Materialized View, Senkronizasyon ve Eventual Consistency ile Yaşamak

Ayrı read model, ekranın satırını domain event'lerinden önceden hesaplayıp saklamaktır; aynı transaction'da güncellenirse tutarlılık anlık kalır, ayrı bir veritabanında asenkron güncellenirse eventual consistency ile yaşamak gerekir. Inline projection, PostgreSQL materialized view, checkpoint'li asenkron projector, read-your-own-writes için position ile bekleme, idempotent update ve sıfırdan rebuild, gerçek çıktılarla.

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

Kampanya haftası yaklaşıyor ve "Siparişlerim" ekranının her açılışta çalıştırdığı üç correlated subquery profiler'da üst sıralara çıktı. Ekip hesabı okuma anından yazma anına taşımaya karar verdi: ekran artık ayrı bir veritabanındaki hazır bir tablodan okuyor, bu tabloyu da arka planda çalışan bir projector dolduruyor. İlk test şöyle:

        PlaceOrderResult placed = await place.HandleAsync(new PlaceOrder(Setup.Ada, [(Setup.Dune, 1), (Setup.Emma, 2)]), default);
        CustomerOrdersPage now = await list.HandleAsync(Setup.Ada, minPosition: null, TimeSpan.Zero, default);
        Console.WriteLine($"async: position={placed.Position}, rows right after the command={now.Rows.Count}");
async: position=1, rows right after the command=0

Ada siparişini verdi, 201 Created aldı, "Siparişlerim" sayfasına yönlendirildi ve listesi boş. Bu bir bug mı? Değilse, Ada'ya "siparişin kayboldu" hissini yaşatmamak için ne yapardın? Bir de şunu düşün: projector aynı event'i iki kez işlerse ya da read model tablosunu yanlışlıkla silersen ne olur?

Hesabı Yazma Anına Taşımak: Denormalize Read Model

Önceki yazıdaki seviye 1'de liste her okumada tablolardan yeniden hesaplanıyordu. Seviye 2 ve 3'te ekranın satırı önceden hesaplanmış hâliyle saklanır; okuma tek tablodan, join'siz, index'li bir SELECT'e iner. Microsoft Learn bu fikri ayrı bir pattern olarak da anlatır: Materialized View pattern, sorgu için gereken veriyi önceden hazırlanmış görünümlerde tutar ve bu görünümlerin kaynak veriden tamamen yeniden üretilebildiği için atılabilir, özel bir cache olduğunu vurgular. Bookstore'un read model tablosu:

CREATE SCHEMA read;

CREATE TABLE read.customer_order_summaries (
    order_id     uuid PRIMARY KEY,
    customer_id  uuid NOT NULL,
    placed_at    timestamptz NOT NULL,
    status       text NOT NULL,
    item_count   int NOT NULL,
    total        numeric(10, 2) NOT NULL,
    first_title  text NOT NULL,
    last_version int NOT NULL
);
CREATE INDEX ON read.customer_order_summaries (customer_id, placed_at DESC);

CREATE TABLE read.projection_checkpoints (name text PRIMARY KEY, position bigint NOT NULL);
INSERT INTO read.projection_checkpoints VALUES ('customer_order_summaries', 0);

last_version ve projection_checkpoints birazdan işe yarayacak. Tabloyu neyin besleyeceği ise asıl soru. Cevap, DDD (Domain-Driven Design) serisinin "Domain Event'ler: Raise, Topla, SaveChanges'te Dispatch Et" yazısında Order'a eklediğimiz domain event'ler. Burada her event'e aggregate'in o andaki sürümünü (Version) ekliyoruz:

// Domain event'ler: geçmiş zaman, immutable; Version aggregate'in o andaki sürümü
public interface IOrderEvent
{
    OrderId OrderId { get; }
    int Version { get; }
    DateTimeOffset At { get; }
}

public sealed record OrderedBook(BookId BookId, string Title, int Quantity);
public sealed record OrderPlaced(OrderId OrderId, CustomerId CustomerId, IReadOnlyList<OrderedBook> Books, decimal Total, DateTimeOffset At, int Version) : IOrderEvent;
public sealed record OrderCancelled(OrderId OrderId, CancellationReason Reason, DateTimeOffset At, int Version) : IOrderEvent;
public sealed record OrderShipped(OrderId OrderId, DateTimeOffset At, int Version) : IOrderEvent;

Order.Place, Cancel ve MarkShipped bu event'leri kendi listesine ekliyor. Event'ler, read modelin ihtiyaç duyduğu bilgiyi (kitap adları, adetler, toplam) taşıyor; projector'ın orders tablosuna dönüp sorgu atması gerekmiyor.

Seçenek 1: Aynı Transaction'da Projection

En basit yol, read model tablosunu write veritabanında tutmak ve command'ın transaction'ı içinde güncellemek. DDD serisinde domain event'leri SaveChangesInterceptor ile dağıtmıştık; burada kısa tutmak için aynı işi SaveChangesAsync override'ı yapıyor. Event'ler önce bir event log tablosuna (ordering.order_events) yazılıyor, sonra kayıtlı inline projection'lar aynı transaction'da çalışıyor:

    public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken ct = default)
    {
        Order[] changed = [.. ChangeTracker.Entries<Order>().Select(e => e.Entity).Where(o => o.Events.Count > 0)];
        IOrderEvent[] events = [.. changed.SelectMany(o => o.Events)];
        StoredEvent[] stored = [.. events.Select(StoredEvent.From)];
        OrderEvents.AddRange(stored);

        // Sipariş satırları, event log ve (varsa) inline projection tek transaction'da
        await using IDbContextTransaction tx = await Database.BeginTransactionAsync(ct);
        int written = await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
        foreach (IOrderEvent e in events)
            foreach (IInlineProjection projection in inlineProjections)
                await projection.ApplyAsync(Database.GetDbConnection(), tx.GetDbTransaction(), e, ct);
        await tx.CommitAsync(ct);

        foreach (Order order in changed)
            order.ClearEvents();
        LastAppendedPosition = stored.Length > 0 ? stored.Max(s => s.Position) : null;
        return written;
    }

Projection'ın kendisi, her event'i bir SQL (Structured Query Language) komutuna çeviren küçük bir class:

using System.Data.Common;
using Bookstore.Infrastructure;
using Bookstore.Ordering;
using Dapper;

namespace Bookstore.ReadModel;

// Bir event'i read.customer_order_summaries tablosuna uygular; aynı event iki kez gelirse etkisiz kalır
public sealed class OrderSummaryProjection : IInlineProjection
{
    public Task<int> ApplyAsync(DbConnection connection, DbTransaction transaction, IOrderEvent e, CancellationToken ct) => e switch
    {
        OrderPlaced placed => connection.ExecuteAsync(new CommandDefinition("""
            INSERT INTO read.customer_order_summaries
                (order_id, customer_id, placed_at, status, item_count, total, first_title, last_version)
            VALUES (@OrderId, @CustomerId, @PlacedAt, 'Placed', @ItemCount, @Total, @FirstTitle, @Version)
            ON CONFLICT (order_id) DO NOTHING
            """,
            new
            {
                OrderId = placed.OrderId.Value, CustomerId = placed.CustomerId.Value, PlacedAt = placed.At,
                ItemCount = placed.Books.Sum(b => b.Quantity), placed.Total, FirstTitle = placed.Books[0].Title, placed.Version
            }, transaction, cancellationToken: ct)),
        OrderCancelled cancelled => SetStatus(connection, transaction, cancelled, "Cancelled", ct),
        OrderShipped shipped => SetStatus(connection, transaction, shipped, "Shipped", ct),
        _ => Task.FromResult(0)
    };

    // last_version koruması: tekrar gelen ya da eski bir event satırı değiştirmez
    private static Task<int> SetStatus(DbConnection connection, DbTransaction transaction, IOrderEvent e, string status, CancellationToken ct) =>
        connection.ExecuteAsync(new CommandDefinition("""
            UPDATE read.customer_order_summaries
            SET status = @Status, last_version = @Version
            WHERE order_id = @OrderId AND last_version < @Version
            """,
            new { Status = status, e.Version, OrderId = e.OrderId.Value }, transaction, cancellationToken: ct));
}

Hangi modda çalışacağı DI (dependency injection) kaydına bağlı:

if (mode == "inline")
    // Seviye 2: projection, command'ın transaction'ında çalışır
    builder.Services.AddSingleton<IInlineProjection>(sp => sp.GetRequiredService<OrderSummaryProjection>());
else
    // Ayrı read DB: projection arka planda, event log'dan beslenir
    builder.Services.AddHostedService<ProjectorWorker>();
inline: position=1, rows right after the command=1
  Placed    qty=3 32.30 EUR Dune

Satır, command döner dönmez orada. Bu seviye 2: ayrı bir read modeli var ama tutarlılık hâlâ anlık, çünkü sipariş satırları, event log ve read model aynı commit'te diske gidiyor. Bedeli de bu cümlede gizli. Her sipariş kaydı artık bir tablo daha güncelliyor; yazma latency'si artıyor, popüler bir read model satırını güncelleyen eşzamanlı command'lar aynı satırda kilit için yarışabiliyor. Projection'daki bir hata (bir constraint ihlali, bir SQL hatası) siparişin kendisini de geri alıyor: okuma tarafının bir sorunu yazma tarafını durduruyor. Ve read model, write veritabanının ölçeğine ve teknolojisine bağlı kalıyor.

PostgreSQL'in Hazır Cevabı: Materialized View

Projection kodu yazmadan seviye 2'ye geçmenin bir yolu daha var. Günlük satış raporu gibi toplu bir görünüm için PostgreSQL'in materialized view'ı, sorgunun sonucunu diskte saklar:

CREATE MATERIALIZED VIEW read.daily_sales AS
SELECT (o.placed_at AT TIME ZONE 'UTC')::date AS day,
       count(DISTINCT o.id)                   AS orders,
       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;
orders in view before refresh: 1
55000 cannot refresh materialized view "read.daily_sales" concurrently / Create a unique index with no WHERE clause on one or more columns of the materialized view.
orders in view after refresh: 2

View oluşturulduktan sonra verilen sipariş, REFRESH MATERIALIZED VIEW çalışana kadar görünmüyor. CONCURRENTLY seçeneği yenileme sırasında okumaları bloklamaz ama view'da WHERE'siz bir unique index ister; hata mesajı bunu açıkça söylüyor. Materialized view'ın tazeliği tamamen sana kalmış: bir zamanlanmış job dakikada bir yenileyebilir. Bir rapor ekranı için bu çoğu zaman yeterli, bir müşterinin kendi siparişini aradığı ekran için değil. SQL Server tarafında karşılığı indexed view'dır; o her değişiklikte senkron güncellenir ama kısıtları çoktur.

Seçenek 2: Ayrı Read Store ve Asenkron Projector

Seviye 3'te read model ayrı bir veritabanında yaşar ve command'ın transaction'ından tamamen çıkar. Write tarafı yalnızca kendi tablolarını ve event log'u yazar; bir projector event log'u kendi hızında okuyup read store'u günceller.

03-read-model-flow-tr.png

// Write DB'deki event log'u checkpoint'ten itibaren okuyup ayrı read DB'ye uygular
public sealed class OrderSummaryProjector(WriteDatabase write, ReadDatabase read, OrderSummaryProjection projection)
{
    private const string Name = "customer_order_summaries";

    public async Task<int> RunOnceAsync(int batchSize, CancellationToken ct)
    {
        await using NpgsqlConnection readConnection = await read.Source.OpenConnectionAsync(ct);
        await using NpgsqlTransaction tx = await readConnection.BeginTransactionAsync(ct);
        long checkpoint = await readConnection.ExecuteScalarAsync<long>(
            "SELECT position FROM read.projection_checkpoints WHERE name = @Name FOR UPDATE", new { Name }, tx);

        await using NpgsqlConnection writeConnection = await write.Source.OpenConnectionAsync(ct);
        (long Position, string Type, string Data)[] batch = [.. await writeConnection.QueryAsync<(long, string, string)>(
            "SELECT position, type, data::text FROM ordering.order_events WHERE position > @checkpoint ORDER BY position LIMIT @batchSize",
            new { checkpoint, batchSize })];
        if (batch.Length == 0)
            return 0;

        foreach ((long _, string type, string data) in batch)
            await projection.ApplyAsync(readConnection, tx, StoredEvent.Deserialize(type, data), ct);

        // Read model ve checkpoint aynı transaction'da: ya ikisi birden ilerler ya hiçbiri
        await readConnection.ExecuteAsync(
            "UPDATE read.projection_checkpoints SET position = @Position WHERE name = @Name",
            new { batch[^1].Position, Name }, tx);
        await tx.CommitAsync(ct);
        return batch.Length;
    }

    public async Task<long> CheckpointAsync(CancellationToken ct)
    {
        await using NpgsqlConnection connection = await read.Source.OpenConnectionAsync(ct);
        return await connection.ExecuteScalarAsync<long>(
            "SELECT position FROM read.projection_checkpoints WHERE name = @Name", new { Name });
    }
}

public sealed class ProjectorWorker(OrderSummaryProjector projector, ILogger<ProjectorWorker> logger) : BackgroundService
{
    public static TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds(500);

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            try
            {
                if (await projector.RunOnceAsync(batchSize: 100, ct) == 0)
                    await Task.Delay(PollInterval, ct);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                // Bir projection hatası worker'ı öldürmesin; logla, bekle, aynı checkpoint'ten tekrar dene
                logger.LogError(ex, "Projection failed; retrying");
                await Task.Delay(PollInterval, ct);
            }
        }
    }
}

Üç karar dikkat istiyor. Birincisi, event log write tarafında siparişle aynı transaction'da yazılıyor; "sipariş kaydedildi ama event kayboldu" diye bir ara durum yok. Bu, transactional outbox'ın çekirdek fikri. Event'leri bir message broker'a taşıyan relay, teslimat garantileri ve broker tarafındaki ayrıntılar ilerideki Messaging ve Event-Driven Architecture serisinin "Transactional Outbox ve Inbox" yazısının konusu; burada projector log'u doğrudan okuyor. İkincisi, read model güncellemesi ve checkpoint aynı read DB (database) transaction'ında ilerliyor: projector bir batch'in ortasında çökerse ikisi birden geri alınır ve aynı noktadan tekrar başlar. Üçüncüsü, worker bir hatada ölmüyor; loglayıp aynı checkpoint'ten tekrar deniyor. Bir event'i hiç işleyemiyorsa (bozuk veri gibi) bu döngü sonsuza kadar sürer; gerçek sistemde bu event'i bir kenara ayırıp alarm üreten bir mekanizma gerekir.

Okuma tarafı artık write veritabanını hiç tanımıyor. Read store bir PostgreSQL tablosu olmak zorunda da değil: aynı projector satırı bir document veritabanına, jsonb kolonlu bir tabloya ya da bir arama index'ine yazabilir. Microsoft Learn'deki CQRS (Command Query Responsibility Segregation) pattern sayfası da read store için bir document veritabanını örnek verir.

Read-Your-Own-Writes

Açılıştaki boş liste bir bug değil, eventual consistency'nin kendisi: commit ile projector'ın event'i uyguladığı an arasında bir pencere var; bu koşuda yaklaşık 200 milisaniye; üst sınırı projector'ın 500 milisaniyelik bekleme aralığı belirliyor. Sorun pencerenin varlığı değil, kullanıcının kendi yaptığı değişikliği o pencerede görmemesi. Buna read-your-own-writes (kendi yazdığını okumak) sorunu denir ve birkaç bilinen çözümü var.

İlki, command'ın bir konum döndürmesi ve query'nin o konuma kadar beklemesi. PlaceOrderResult siparişin id'sinin yanında event log'daki Position'ı da döndürüyor; query handler checkpoint o noktaya gelene kadar, bir üst sınıra kadar bekliyor:

public sealed record CustomerOrderRow(Guid OrderId, DateTime PlacedAt, string Status, int ItemCount, decimal Total, string FirstTitle);
public sealed record CustomerOrdersPage(IReadOnlyList<CustomerOrderRow> Rows, bool MayBeStale);

public sealed class GetCustomerOrdersHandler(ReadDatabase read, OrderSummaryProjector projector, TimeProvider clock)
{
    // minPosition verilirse read model o noktaya gelene kadar en fazla timeout kadar bekler
    public async Task<CustomerOrdersPage> HandleAsync(Guid customerId, long? minPosition, TimeSpan timeout, CancellationToken ct)
    {
        bool caughtUp = minPosition is null;
        long deadline = clock.GetTimestamp() + (long)(timeout.TotalSeconds * clock.TimestampFrequency);
        while (!caughtUp && clock.GetTimestamp() < deadline)
        {
            caughtUp = await projector.CheckpointAsync(ct) >= minPosition;
            if (!caughtUp)
                await Task.Delay(50, ct);
        }

        await using NpgsqlConnection connection = await read.Source.OpenConnectionAsync(ct);
        IEnumerable<CustomerOrderRow> rows = await connection.QueryAsync<CustomerOrderRow>("""
            SELECT order_id, placed_at, status, item_count, total, first_title
            FROM read.customer_order_summaries
            WHERE customer_id = @customerId
            ORDER BY placed_at DESC
            LIMIT 20
            """, new { customerId });
        return new CustomerOrdersPage([.. rows], MayBeStale: !caughtUp);
    }
}
with minPosition=1: rows=1, stale=False, waited ~200 ms
  Cancelled qty=3 32.30 EUR Dune

Süre dolarsa handler yine de cevap veriyor ama MayBeStale bayrağını kaldırıyor; ekran "listen birkaç saniye içinde güncellenecek" diyebilir. HTTP (Hypertext Transfer Protocol) tarafında bu konum bir header ya da query string parametresi olarak taşınır. Tek bir siparişin detay ekranı için global konum yerine sipariş başına Version da aynı işi görür.

Diğer stratejiler şunlar:

  • Optimistic UI (user interface): istemci, command başarılı dönünce siparişi listeye kendisi ekler ve "işleniyor" etiketiyle gösterir. Angular tarafında bu, component'teki listeyi tutan signal'e geçici bir satır eklemek ve read model yakalayınca o satırı sunucudan geleniyle değiştirmek demek.
  • Write tarafından okumak: kullanıcının az önce oluşturduğu tek kayıt için (siparişin onay sayfası gibi) read model yerine aggregate'in kaydedildiği tablodan okumak. Liste için değil, tek kayıt için makul.
  • Ekranı yeniden tasarlamak: "Siparişin alındı" onay sayfası çoğu zaman pencereden daha uzun sürer; kullanıcı listeye döndüğünde projector çoktan yetişmiştir. En ucuz çözüm bazen UX (user experience) tarafındadır.

Idempotent Update ve Rebuild

Bir projector'ın hayatında iki şey kesin: aynı event'i bir gün ikinci kez görecek ve read modelin bir gün sıfırdan kurulması gerekecek. Bizim projector at-least-once çalışıyor: bir batch'i uygulayıp checkpoint'i commit edemeden çökerse aynı event'ler tekrar gelir. Aynı siparişin bütün event'lerini ikinci kez uygulayalım:

replayed OrderPlaced: rows affected=0
replayed OrderCancelled: rows affected=0

OrderPlaced için ON CONFLICT DO NOTHING, durum değişiklikleri için last_version < @Version koşulu tekrarı etkisiz bırakıyor. item_count = item_count + 1 gibi artıran bir update ise her tekrarda sayıyı bozardı; projection'ı "son değeri yaz" biçiminde ve sürümle korunmuş yazmak bu yüzden önemli.

Rebuild ise read modelin en büyük avantajı: event log saklandığı sürece read model atılabilir bir cache. 300 sipariş ve 100 iptalden sonra tabloyu boşaltıp checkpoint'i başa alıyoruz:

        await using NpgsqlConnection connection = await read.Source.OpenConnectionAsync();
        const string Snapshot = "SELECT string_agg(order_id || status || total || last_version, ',' ORDER BY order_id) FROM read.customer_order_summaries";
        string before = await connection.ExecuteScalarAsync<string>(Snapshot) ?? "";

        // Rebuild: read model'i boşalt, checkpoint'i başa al, projector baştan oynatsın
        Stopwatch watch = Stopwatch.StartNew();
        await connection.ExecuteAsync("""
            TRUNCATE read.customer_order_summaries;
            UPDATE read.projection_checkpoints SET position = 0 WHERE name = 'customer_order_summaries';
            """);
        while (await projector.CheckpointAsync(default) < last)
            await Task.Delay(20);
        watch.Stop();

        string after = await connection.ExecuteScalarAsync<string>(Snapshot) ?? "";
        long rows = await connection.ExecuteScalarAsync<long>("SELECT count(*) FROM read.customer_order_summaries");
        Console.WriteLine($"rebuild: events={last}, rows={rows}, identical={before == after}, took ~{watch.ElapsedMilliseconds / 100 * 100} ms");
rebuild: events=402, rows=301, identical=True, took ~500 ms

402 event'ten 301 satır yeniden üretildi ve içerik, rebuild öncesiyle birebir aynı. Read modelin şeklini değiştirmek (yeni bir kolon, farklı bir sıralama) de aynı yoldan geçer: yeni tabloyu yarat, projector'ı baştan oynat, hazır olunca ekranı yeni tabloya çevir. Bu demoda süre yaklaşık yarım saniye; milyonlarca event'le saatler sürebilir ve bu süre boyunca ekranın eski tablodan okumaya devam etmesi gerekir.

Sık Düşülen Tuzaklar

Read modeli başka servislerin source of truth'u yapmak

customer_order_summaries bir ekran için kurulmuş, gecikmeli bir kopya. Shipping ya da Payments onu "siparişin durumu" diye okumaya başlarsa, gecikme penceresinde yanlış karar verirler ve read modelin şeklini değiştirmek artık bir entegrasyon sözleşmesini kırmak olur. Başka bir context'in ihtiyacı varsa ona kendi event'ini ya da API'sini (Application Programming Interface) ver.

Commit sırası ile position sırasının aynı olduğunu sanmak

position bir sequence'ten geliyor; değer insert anında alınır ama satır ancak transaction commit olunca görünür. Eşzamanlı iki transaction'da position 6 olan önce commit olursa projector 6'yı işleyip checkpoint'i ilerletir; 5 bir milisaniye sonra commit olduğunda artık checkpoint'in gerisinde kalır ve hiç işlenmez. Demo tek yazıcıyla çalıştığı için bu olmadı. Gerçek sistemde ya yazmaları sıraya sokarsın (advisory lock gibi) ya da projector'ın yalnızca "artık boşluk kalmayacağından emin olunan" noktaya kadar okumasını sağlarsın. Serinin Marten yazılarında async daemon'ın bu sorunu "high water mark" adını verdiği mekanizmayla nasıl ele aldığını göreceğiz.

Projection'dan dış servis çağırmak

Projector'ın e-posta göndermesi ya da ödeme sağlayıcısını çağırması, rebuild'de aynı e-postaların yeniden gitmesi demek. Projection yalnızca read modeli günceller; yan etkiler ayrı bir consumer'ın işi.

Rebuild süresini ve gecikmeyi ölçmemek

Projector'ın ne kadar geride olduğunu (son event position'ı ile checkpoint farkı) bir metrik olarak yayınla. Kampanya zirvesinde gecikme saniyelerden dakikalara çıkarsa bunu müşteri şikâyetinden değil, dashboard'dan öğrenmelisin.

Ne Zaman Kullanma

Önceki yazıdaki projection, ölçülmüş bir performans sorunu yoksa yeterli. Seviye 2'nin bedeli yazma tarafına eklenen bir tablo ve aynı transaction'daki ek iş; seviye 3'ün bedeli ise ikinci bir veritabanı, bir worker, gecikme metriği, rebuild prosedürü ve her ekranda read-your-own-writes kararı. Bookstore'da günde ~800 sipariş için "Siparişlerim" ekranı seviye 1'de rahatça çalışır; seviye 2, kampanya zirvesinde dakikada ~120 siparişte ölçülmüş bir sorun çıkarsa gündeme gelir. Seviye 3 ise okuma tarafının gerçekten farklı bir teknolojiye (tam metin arama gibi) ya da bağımsız ölçeklenmeye ihtiyacı olduğunda anlamlı. Kullanıcının yaptığı değişikliği anında ve kesin görmesi gereken akışlarda (ödeme onayı, stok rezervasyonu) eventual consistency'li bir read modele dayanma.

Küçük Bir Deneme

★☆☆ ProjectorWorker.PollInterval'ı 5 saniyeye çıkar ve demoyu tekrar çalıştır. minPosition'lı sorgu 2 saniyelik sınırda ne döndürüyor? MayBeStale true geldiğinde ekranda ne gösterirdin?

★★☆ OrderShipped için projection'a bir test yaz: aynı event iki kez, sonra bir sürüm eski bir OrderCancelled uygulandığında satırın durumu ne olmalı? Sonra SetStatus'taki last_version < @Version koşulunu kaldır ve testin kırıldığını gör.

★★★ İki ayrı process'ten aynı anda sipariş veren bir yük testi kur ve ordering.order_events'teki position'ların commit sırasıyla aynı olmadığını göster. Event log'a her satırın transaction id'sini (pg_current_xact_id()) yaz ve projector'ı yalnızca pg_snapshot_xmin(pg_current_snapshot())'tan küçük transaction'ların event'lerini okuyacak şekilde değiştir.

Buraya kadar event'leri yalnızca read modeli beslemek için kullandık; write tarafının asıl verisi hâlâ orders tablosundaki son state. Bir sonraki yazıda soruyu tersine çeviriyoruz: event'lerin kendisi asıl veri olsa, state onlardan türetilse ne olur? Event Sourcing'e, Bookstore'da gerçekten aday olan tek yerle, stok defteriyle başlayacağız.