Blog'a Dön

Buğra Şıkel

Monolitik CRUD Mimariden Event Sourcing’e Geçiş: State Mutasyonlarını Immutable Loglara Kademeli Taşıma Rehberi

Monolitik CRUD Mimariden Event Sourcing'e Geçiş: State Mutasyonlarını Immutable Loglara Kademeli Taşıma Rehberi

Giriş

Gece 03:42’de çalan bir PagerDuty alarmı ile uyandığınızı hayal edin. Veri tabanında bir siparişin durumu Canceled olarak güncellenmiş, ancak müşteri hizmetleri paneline düşen bir log yok. Neden iptal edildi? Stok mu yetmedi, ödeme mi reddedildi, yoksa müşteri mi vazgeçti? Klasik CRUD (Create, Read, Update, Delete) mimarilerinde, UPDATE ve DELETE operasyonları doğası gereği yıkıcıdır. Sadece sistemin o anki durumunu (state) bilirsiniz, o duruma nasıl geldiğini değil. Bu, audit gereksinimleri katı olan finansal sistemlerde veya saniyede 10.000+ transaction işleyen e-ticaret altyapılarında kabul edilemez bir veri kaybıdır.

Bir e-ticaret platformunun ödeme (checkout) domainini 4TB’lık monolitik bir PostgreSQL veritabanından, Event Sourcing tabanlı bir mimariye taşırken edindiğim production tecrübeleri, bu geçişin bir big bang (tek seferde geçiş) yaklaşımıyla yapılamayacağını kanıtladı. Sistemin kilitleme (lock contention) oranlarını %84 oranında azaltan ve okuma p99 gecikmesini 420ms’den 18ms’ye düşüren bu mimari dönüşüm, dikkatli bir orkestrasyon gerektirir. Bu rehberde, state mutasyonlarını immutable (değiştirilemez) loglara kademeli olarak nasıl taşıyacağınızı, geri dönüş stratejilerini ve production ortamında karşılaşacağınız darboğazları inceleyeceğiz.

İçindekiler

  • Mevcut Durum ve Hedef State: Neden Değişim Gerekiyor?
  • Faz 1: Transactional Outbox ile Çift Yazma (Dual-Write) Problemini Çözme
  • Faz 2: Projeksiyonlar (Read Models) ve CQRS Entegrasyonu
  • Faz 3: Source of Truth’un Değişimi (The Flip)
  • Trade-Off Analizi: Hangi Senaryoda Hangi Mimari?
  • Pratik Öneriler ve Production Notları
  • Sık Sorulan Sorular
  • Sonuç

Mevcut Durum ve Hedef State: Neden Değişim Gerekiyor?

Mevcut Durum: Yıkıcı State Mutasyonları

Geleneksel mimaride bir entity üzerinde yapılan her değişiklik, önceki verinin üzerine yazılır. Örneğin, bir kullanıcının cüzdan bakiyesini güncelleyen şu SQL sorgusunu ele alalım:

UPDATE wallets SET balance = balance - 150.00, updated_at = NOW() WHERE user_id = 9812;

Bu komut çalıştığı anda, cüzdanın önceki bakiyesi tarih olur. 150.00 birimlik düşüşün nedeni (abonelik yenileme, ürün satın alma, ceza kesintisi) domain katmanından veritabanına aktarılmaz. Log tabloları (audit tables) ile bu açık kapatılmaya çalışılsa da, log tablosu ile ana tablo arasındaki senkronizasyon genellikle uygulama katmanındaki hatalar yüzünden bozulur. Ayrıca yoğun trafik altında, aynı satırı güncellemeye çalışan thread’ler Row-Level Lock (satır bazlı kilitleme) oluşturarak CPU bekleme sürelerini (iowait) artırır.

Hedef Durum: Immutable Event Stream

Event Sourcing’de state güncellenmez; state’i değiştiren *olaylar* (events) append-only (sadece ekleme yapılabilen) bir loga sırayla yazılır. Aynı cüzdan işlemi Event Store’a şu şekilde yansır:

  • WalletCreated { UserId: 9812, InitialBalance: 0, Timestamp: 1698140000 }
  • FundsDeposited { UserId: 9812, Amount: 500.00, Timestamp: 1698145000 }
  • FundsWithdrawn { UserId: 9812, Amount: 150.00, Reason: "Order_1029", Timestamp: 1698200000 }

Mevcut bakiye, bu olayların baştan sona oynatılmasıyla (replay) hesaplanır (Fold/Reduce operasyonu). Bu yapı, lock-free yazma (sadece insert), %100 audit edilebilirlik ve geçmişin herhangi bir anına dönme (Time-Travel Debugging) kapasitesi sunar.

Faz 1: Transactional Outbox ile Çift Yazma (Dual-Write) Problemini Çözme

Geçişin en kritik noktası, veriyi hem mevcut CRUD tablolarına hem de yeni Event Store / Message Broker (örn: Kafka, RabbitMQ) yapısına yazmaktır. Ancak db.save(entity) ve kafka.publish(event) işlemlerini arka arkaya çağırmak, distributed sistemlerde “Dual-Write” problemine yol açar. Veritabanına yazıp Kafka’ya gönderirken network koptuğunda, sistem tutarsız duruma (split-brain) düşer.

Bu riski sıfıra indirmek için Transactional Outbox Pattern uygulanmalıdır. Domain entity’si güncellenirken, domain’in ürettiği event’ler aynı ACID transaction içinde bir Outbox tablosuna yazılır.

C# / Entity Framework Core ile Outbox İmplementasyonu

public async Task ProcessOrderPaymentAsync(Guid orderId, decimal amount)
{
    // 1. Transaction başlat (Snapshot izolasyon seviyesi tavsiye edilir)
    using var transaction = await _dbContext.Database.BeginTransactionAsync(IsolationLevel.ReadCommitted);
    
    try 
    {
        // 2. Mevcut CRUD mutasyonu (Legacy sistemi kırmamak için)
        var order = await _dbContext.Orders.FindAsync(orderId);
        order.Status = OrderStatus.Paid;
        
        // 3. Domain Event'i oluştur (Hedef mimari için)
        var paymentProcessedEvent = new OrderPaymentProcessedEvent {
            OrderId = orderId,
            Amount = amount,
            ProcessedAt = DateTime.UtcNow
        };
        
        // 4. Outbox Envelope yapısına sar
        var outboxMessage = new OutboxMessage {
            MessageId = Guid.NewGuid(),
            AggregateType = "Order",
            AggregateId = orderId.ToString(),
            EventType = paymentProcessedEvent.GetType().Name,
            Payload = JsonSerializer.Serialize(paymentProcessedEvent),
            CreatedAt = DateTime.UtcNow
        };
        
        // 5. İkisini aynı anda kaydet
        _dbContext.Orders.Update(order);
        _dbContext.OutboxMessages.Add(outboxMessage);
        
        await _dbContext.SaveChangesAsync();
        await transaction.CommitAsync();
    }
    catch
    {
        await transaction.RollbackAsync();
        throw;
    }
}

Bu aşamadan sonra, arka planda çalışan ayrı bir process (örneğin Debezium tabanlı bir CDC – Change Data Capture aracı veya basit bir Polling worker) OutboxMessages tablosunu okuyup event’leri Kafka’ya basar ve ardından logu tablodan siler (veya processed_at flag’i atar). Kafka entegrasyonumuzda Debezium (PostgreSQL WAL okuyucusu) kullanarak, tablo poll etme maliyetini ortadan kaldırdık ve veritabanı CPU tüketiminde %14’lük kalıcı bir düşüş sağladık.

Faz 2: Projeksiyonlar (Read Models) ve CQRS Entegrasyonu

Event’ler Kafka topic’lerine (veya EventStoreDB’ye) akmaya başladığında, yazma (Write/Command) ve okuma (Read/Query) modellerini ayırma (CQRS) aşamasına geçilir. Event Sourcing mimarisinde, aggregate’in son durumunu getirmek için yüzlerce event’i anlık replay etmek, read latency’i artırır. Bu yüzden event’leri dinleyip, okuma için optimize edilmiş veritabanlarına (Elasticsearch, Redis veya MongoDB) yazan Projections (Projeksiyonlar) inşa edilir.

Eventual Consistency (Nihai Tutarlılık) Gerçeği

Bu faza geçildiğinde, sistem katı tutarlılıktan (strong consistency), nihai tutarlılığa (eventual consistency) geçer. Command işlenip DB’ye yazıldıktan sonra, Kafka üzerinden Event Handler’a ulaşıp Read Model’in güncellenmesi arasında bir replikasyon gecikmesi (lag) oluşur. Production ortamımızda Kafka 3.4.0 cluster’ımızda bu lag değeri p99 için 45ms seviyelerindedir. 45ms’lik bir gecikme insan gözüyle fark edilemese de, otomasyon testleri ve ardışık API çağrıları yapan client’lar için race condition’lara sebep olabilir. Bunu aşmak için API yanıtlarına x-correlation-id ve x-expected-version header’ları ekleyerek, client’ın read model güncellenene kadar beklemesini veya retry yapmasını sağladık.

Faz 3: Source of Truth’un Değişimi (The Flip)

Sistem aylarca Faz 2’de, yani hem eski CRUD tablolarının hem de yeni Event Store + Read Model yapısının paralel çalıştığı durumda kalabilir. Bu dönem, yeni mimarinin veriyi doğru oluşturup oluşturmadığını kıyaslamak (Shadow Testing) için harika bir fırsattır.

Yeni sistemin doğruluğundan emin olunduğunda, artık eski CRUD tablolarına (örneğin orders) yazma işlemi durdurulur. Uygulama, yazma işlemleri için sadece event üretir (Append-only) ve okuma işlemleri için sadece Read Model’i kullanır.

Snapshotting (Anlık Görüntü Alma) Optimizasyonu

Bir aggregate’in yaşam döngüsünde 10.000’den fazla event biriktiğinde (örneğin popüler bir IoT cihazının telemetri verisi veya çok hareketli bir borsa cüzdanı), o aggregate’i belleğe yüklemek (hydration) için 10.000 event’i veritabanından çekip çalıştırmak ciddi bir I/O ve CPU maliyetidir. Geçiş sürecinde 3. ayın sonunda sepet (cart) aggregate’lerimizde load süreleri 2.1 saniyelere çıkmıştı.

Çözüm Snapshotting uygulamaktır. Kural basittir: Her 100 event’te bir, aggregate’in o anki state’i serilize edilip Snapshot tablosuna yazılır. Bir sonraki okumada sistem baştan başlamaz; son snapshot’ı alır (State version: 100) ve sadece 100. versiyondan sonraki (101, 102…) event’leri üstüne replay eder. Bu optimizasyon, load sürelerimizi p99’da 2.1 saniyeden 22ms’ye kalıcı olarak sabitledi.

Trade-Off Analizi: Hangi Senaryoda Hangi Mimari?

Event Sourcing’e geçiş pahalı ve mühendislik eforu yüksek bir süreçtir. Sistemin her parçasına uygulanmamalıdır.

Metrik / Kriter CRUD Mimari Event Sourcing & CQRS
Audit ve İzlenebilirlik Kayıp. (Ek loglama mekanizmaları gerektirir) Doğal. State mutasyonları %100 kayıt altındadır.
Okuma/Yazma Ölçeklenebilirliği Sınırlı. Okuma ve yazma aynı veri yapısını kilitler. Bağımsız ölçeklenebilir. (Read replica ayrı, Write log ayrı)
Tutarlılık Modeli (Consistency) Strong Consistency (Anında tutarlı) Eventual Consistency (Gecikmeli/Nihai tutarlı)
Domain Uygunluğu Master/Referans veriler (Ülke listesi, kategori isimleri) Core Business (Ödeme süreçleri, envanter, rezervasyon)
Mimari Karmaşıklık Düşük. Yeni başlayan ekipler için idealdir. Çok Yüksek. Versiyonlama, Upcasting, Snapshot yönetimi ister.

Eğer geçiş yapacağınız domain “Kullanıcı Profil Resmi Yükleme” gibi basit bir CRUD işlemi ise, Event Sourcing kullanmak aşırı mühendisliktir (over-engineering). Ancak “Sipariş Karşılama ve Depo Envanteri” gibi süreçlerin iç içe geçtiği bir domain’de, bu mimari hayat kurtarır.

Pratik Öneriler ve Production Notları

  • Feature Toggles ile Dark Launching: Phase 3’e geçerken, kod tabanına bir feature flag ekleyin (örn: UseEventSourcingForOrders=true). Eğer okuma modellerinde bir senkronizasyon sorunu yaşanırsa, saniyeler içinde config sunucusundan flag’i false’a çekerek eski CRUD okumalarına fallback yapın.
  • Dead Letter Queue (DLQ) Hayat Kurtarır: Read model güncelleyicileri (consumer’lar), formatı bozuk bir event (Poison Pill) okuduğunda crash olmamalıdır. Hata fırlatan event’ler DLQ Kafka topic’ine atılmalı, consumer kaldığı yerden devam etmelidir. Sistem durursa, lag dakikalar içinde milyonlarca mesaja ulaşabilir.
  • Idempotency (Birim Etkisizlik): Ağ dalgalanmalarından ötürü Kafka aynı event’i consumer’a iki kez teslim edebilir (At-least-once delivery). Consumer’larınızın veritabanına yazarken idempotent çalışması zorunludur. Bunu, işlenen Event ID’leri Redis veya DB tablosunda tutarak ve tekrar gelen aynı ID’yi pas geçerek sağlayabilirsiniz.
  • Kafka Partition Key Tasarımı: Bir siparişin (Order) event’lerinin her zaman doğru sırayla işlenmesi gerekir. OrderCreated gelmeden OrderShipped işlenemez. Kafka’da sıralılık sadece aynı Partition içinde garanti edilir. Bu yüzden event publish ederken, mesaj anahtarı (partition key) olarak daima Aggregate ID (örn: OrderId) kullanın.

Sık Sorulan Sorular

Event Şeması (Formatı) Değiştiğinde Geçmişteki Event’leri Nasıl Yöneteceğiz?

Geçmiş event’ler immutable olduğu için değiştirilemez. Bunun için Upcasting deseni kullanılır. Örneğin, v1 CustomerCreated event’inde sadece FullName alanı vardı. v2’de ise FirstName ve LastName olarak ikiye ayrıldı. Event store’dan veriyi çekerken, araya giren bir “Upcaster” katmanı, v1 event’ini havada (memory’de) yakalayıp, boşluk karakterinden bölerek v2 formatına dönüştürür (map eder) ve aggregate’e güncel versiyonu teslim eder. Veritabanındaki orjinal v1 logu ise bozulmadan kalır.

Eventual Consistency Yüzünden Kullanıcı Arayüzünde (UI) Tutarsızlık Olur mu?

Evet, kullanıcı “Kaydet” butonuna basıp sayfayı yenilediğinde, read model henüz güncellenmediği için eski veriyi görebilir. Production’da bunu aşmak için iki yöntem uyguluyoruz: 1) Optimistic UI: Sunucudan 200 OK (Accepted) döndüğü an, UI’da state başarıyla değişmiş gibi gösterilir. 2) Polling / WebSockets: İstemci, command’i gönderdiğinde bir CommandId alır. API üzerinden bu ID’nin işlenip işlenmediğini (status endpoint’i) kısa aralıklarla sorgular veya SignalR/WebSocket ile backend’den “UI_Updated” push bildirimi bekler.

Event Store Boyutu Sürekli Büyüyecek, Disk Maliyeti Sorun Olmaz mı?

Append-only bir sistemde veritabanı sürekli büyüyecektir. Bizim mimarimizde aktif tutulan event store sadece son 1 yıllık veriyi sıcak (SSD) disklerde tutar. Aggregate root’lar snapshot ile korunduğu için, 1 yıldan eski olan ve mutasyon geçirmeyen event’ler (cold data), gecelik cron job’larla AWS S3 üzerinde Parquet formatında sıkıştırılarak arşivlenir. Bu sayede aktif operasyonel veritabanının boyutu kontrol altında tutulur.

Sonuç

Monolitik bir CRUD mimarisinden Event Sourcing tabanlı olay güdümlü bir yapıya geçiş, sadece bir teknoloji değişimi değil, veriye bakış açısının değişimidir. Veritabanını sistemin anlık durumunu tutan basit bir tahta parçası olarak değil, şirketin tüm davranışsal geçmişini barındıran dijital bir muhasebe defteri (ledger) olarak kurgulamak, audit, debug ve analitik süreçlerinde devasa bir avantaj sağlar.

Ancak unutulmamalıdır ki, bu geçiş bir maratondur. İşe, domain kompleksliğinin en yüksek ve transaction kaybının maliyetinin en büyük olduğu (örn: cüzdan, envanter, ödeme) kısıtlı bir bounded context’i seçerek başlayın. Transactional Outbox pattern ile entegrasyonu güvene alın, read modellerinizi ayrıştırın ve sistem metriklerini (consumer lag, lock süreleri) referans alarak The Flip aşamasını kademeli olarak gerçekleştirin.

Bunları da beğenebilirsiniz

jQuery AJAX Kullanarak Anlık Veri Çekme

Merhabalar, bu yazımda sizlere php, ajax, jquery kullanarak neler yapabileceğimizden bahsedeceğim. Ajax (Asynchronous JavaScript and XML), web geliştirme teknolojisidir. Ajax sayesinde, web sayfalarında dinamik içerik…

Devamını Oku

WebSocket Yatay Ölçeklemesinde Redis Pub/Sub mu NATS mı? Mesaj İletim Garantisi Karar Matrisi

WebSocket yatay ölçeklemesinde backplane seçimi: Redis 7.2 Pub/Sub ve NATS 2.10 JetStream karşılaştırması, p99 gecikme metrikleri ve karar çerçevesi.

Devamını Oku

Kritik Sistemlerde Teknik Borcu Azaltmak: Yapay Zeka Ajanları ile Mimari Refactoring

Bu blog yazısı, kritik sistemlerde biriken teknik borcun yarattığı zorlukları ve bu sorunla mücadelede yapay zeka ajanlarının repo düzeyinde mimari refactoring süreçlerine nasıl entegre edilebileceğini derinlemesine inceliyor. Teknik borcu azaltmak, sistem performansını artırmak ve geliştirme süreçlerini hızlandırmak için yenilikçi stratejiler sunulmaktadır.

Devamını Oku