

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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.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.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.
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.
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.
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.

Veritabanı işlemleri, web uygulamalarının temel yapı taşlarından biridir. Kullanıcıların bilgilerini kaydetmek, verileri güncellemek veya silmek için sık sık veritabanına erişim sağlarız. Ancak, bazen beklenmedik hatalar…

Bu blog yazısı, IoT ve endüstriyel otomasyon gibi alanlarda yüksek hacimli sensör verilerini işlemek için yapay zeka destekli asenkron kuyruk yönetimi stratejilerini inceliyor. Gecikmeyi azaltma, ölçeklenebilirliği artırma ve kaynak kullanımını optimize etme yöntemlerini keşfedin.