Fin ve Tech: Event-Driven Architecture ile Ödeme Sistemleri

Fin ve Tech serisinin bu bölümünde, modern ödeme sistemlerinin omurgasını oluşturan mimariye odaklanıyoruz: Event-Driven Architecture.
Bir ödeme işlemi düşünün — fraud kontrolü, banka yetkilendirmesi, para çekimi, muhasebe kaydı, bildirim ve mutabakat. Tüm bu adımlar birbirinden bağımsız sistemlerde, asenkron olarak gerçekleşiyor.
Bu yazıda, gerçek bir ödeme sistemini .NET 8 · MassTransit · RabbitMQ · PostgreSQL ile nasıl inşa edeceğimizi ve bunu yaparken 4 kritik production pattern’ı nasıl uygulayacağımızı inceleyeceğiz. Projenin kaynak koduna GitHub üzerinden ulaşabilirsiniz: mehmetoya/payment-event-driven-architecture
Event-Driven Architecture Nedir?
EDA (Event-Driven Architecture), servislerin birbirleriyle doğrudan konuşmak yerine olaylar (event) yayınlayıp dinlediği mimari yaklaşımdır.
Klasik alternatifler neden yetersiz kalır?
- RPC/HTTP: Bir servis düştüğünde tüm akış durur.
- Direkt DB bağlantısı: Tight coupling ve ölçekleme sorunu yaşatır.
EDA bu sorunları ortadan kaldırır. Servisler birbirine bağlı değil, olaylar üzerinden gevşek bağlıdır (loosely coupled).
📌 Bu yazıda öğrenecekleriniz:
- Outbox Pattern ile dual-write sorununun çözümü
- Saga Pattern ile dağıtık transaction yönetimi
- Idempotent Consumer ile tekrar-teslim güvencesi
- Dead Letter Queue ile kalıcı hata yönetimi
Mimari Genel Bakış
Sistemimiz 7 mikroservisten oluşuyor. Her servisin kendi veritabanı var (Database per Service pattern) ve aralarındaki iletişim tamamen RabbitMQ üzerinden event-driven:
User/Merchant
│
▼
┌──────────────────┐ ┌──────────────────────────┐
│ PaymentService │──────▶ │ RabbitMQ (Event Bus) │
│ (Producer) │ │ │
│ + Outbox Table │ │ PaymentCreatedEvent │──▶ FraudService
└──────────────────┘ │ PaymentAuthorizedEvent │──▶ NotificationService
│ │ PaymentCapturedEvent │──▶ LedgerService
▼ │ PaymentFailedEvent │──▶ ReconciliationService
Payment DB │ PaymentSettledEvent │──▶ AnalyticsService
└──────────────────────────┘
│
▼
SagaService (Orchestrator)
Payment Flow: Initiated → Authorized → Captured → Settled
Event Contracts: Servisler Arası Sözleşme
EDA’nın temeli iyi tanımlanmış event’lerdir. Tüm event’leri ayrı bir Contracts projesi olarak tutuyoruz — bu, tüm servisler için ortak “dil” görevi görür:
// PaymentEDA.Contracts/Events/PaymentEvents.cs
public interface IPaymentEvent
{
Guid PaymentId { get; }
Guid CorrelationId { get; } // Saga için
DateTime OccurredAt { get; }
}
public record PaymentCreatedEvent : IPaymentEvent
{
public Guid PaymentId { get; init; }
public Guid CorrelationId { get; init; }
public Guid MerchantId { get; init; }
public Guid UserId { get; init; }
public decimal Amount { get; init; }
public Currency Currency { get; init; }
public PaymentMethod Method { get; init; }
public DateTime OccurredAt { get; init; } = DateTime.UtcNow;
}
İki önemli tasarım kararı: record kullanıyoruz — immutable ve value equality sağlar. CorrelationId, aynı ödeme akışına ait tüm event’leri birbirine bağlayan kilit alan.
Pattern 1: Outbox Pattern
Problem: Dual-Write
PaymentService bir ödeme oluşturduğunda iki şey yapması gerekiyor: veritabanına kaydet ve RabbitMQ’ya event yayınla. Peki ya veritabanı başarılı ama RabbitMQ başarısız olursa?
// ❌ YANLIŞ: Dual-write problemi
await _db.Payments.AddAsync(payment);
await _db.SaveChangesAsync(); // ✅ DB kaydedildi
await _publishEndpoint.Publish(event); // ❌ RabbitMQ çöktü!
// Sonuç: DB'de kayıt var ama event yok → tutarsız sistem
Çözüm: Tek Transaction, İki Yazma
Event’i veritabanına, domain değişikliğiyle aynı transaction içinde bir OutboxMessages tablosuna yazıyoruz. Ayrı bir background service bu tabloyu poll ederek event’leri RabbitMQ’ya iletir.
// PaymentService.cs — Atomik yazma
public async Task<Payment> CreatePaymentAsync(...)
{
var payment = Payment.Create(...);
var outboxMessage = new OutboxMessage
{
EventType = typeof(PaymentCreatedEvent).AssemblyQualifiedName!,
Payload = JsonSerializer.Serialize(new PaymentCreatedEvent { ... })
};
// Tek transaction → ya ikisi de kaydedilir ya da hiçbiri
await using var tx = await _db.Database.BeginTransactionAsync();
_db.Payments.Add(payment);
_db.OutboxMessages.Add(outboxMessage);
await _db.SaveChangesAsync();
await tx.CommitAsync(); // ✅ Atomik
return payment;
}
Background service her 5 saniyede bir işlenmemiş mesajları bulur ve yayınlar. ProcessedAt == null olan kayıtlar henüz iletilmemiş demektir — RetryCount ise geçici hata toleransını sağlar.
💡 Neden At-Least-Once?
Outbox Pattern, at-least-once delivery garantisi verir. Background service ProcessedAt’i güncelleyemeden çöktüyse, mesaj tekrar gönderilir. Bu yüzden consumer’ların idempotent olması gerekir — 3. pattern’da bunu çözeceğiz.
Pattern 2: Saga Pattern
Problem: Dağıtık Transaction
Ödeme akışı birden fazla servisi kapsıyor: fraud kontrolü, yetkilendirme, para çekimi, mutabakat. Hepsi ayrı servis, ayrı veritabanı. Birinde hata oluşursa ne olur? Klasik ACID transaction yok — compensating transaction yazmak zorundayız.
Çözüm: Saga State Machine
MassTransit’in SagaStateMachine’i ile ödemenin tüm yaşam döngüsünü tek bir state machine’de yönetiyoruz. Her event, bir state geçişini tetikliyor:
Initial ──[PaymentCreated]──▶ Created
Created ──[FraudPassed]──▶ FraudCleared ──[Authorized]──▶ Authorized
Authorized ──[Captured]──▶ Captured ──[Settled]──▶ Settled (Final)
Any ──[PaymentFailed]──▶ Failed
// PaymentStateMachine.cs — State geçişleri (özet)
Initially(
When(PaymentCreated)
.Then(ctx => {
ctx.Saga.PaymentId = ctx.Message.PaymentId;
ctx.Saga.Amount = ctx.Message.Amount;
})
.Publish(ctx => new FraudCheckRequested { ... })
.TransitionTo(Created));
During(Created,
When(FraudCheckCompleted, ctx => !ctx.Message.IsSuspicious)
.TransitionTo(FraudCleared),
When(FraudCheckCompleted, ctx => ctx.Message.IsSuspicious)
.Publish(ctx => new PaymentFailedEvent {
ErrorCode = "FRAUD_DETECTED",
IsRetryable = false
})
.TransitionTo(Failed));
Saga state’i PostgreSQL’e persist ediliyor. /saga/{correlationId} endpoint’inden mevcut durumu her an sorgulayabilirsiniz:
{
"paymentId": "a1b2c3...",
"currentState": "FraudCleared",
"fraudCheckPassed": true,
"fraudRiskScore": 15.0,
"createdAt": "2024-01-15T10:30:00Z"
}
Pattern 3: Idempotent Consumer
Problem: At-Least-Once Delivery
RabbitMQ, aynı mesajı birden fazla kez teslim edebilir — ağ kesilmesi, consumer restart veya acknowledgment timeout durumlarında. Peki FraudService aynı ödeme için iki kez fraud kontrolü yaparsa?
Çözüm: MessageId ile Tekil Kontrol
Her mesajın MassTransit tarafından atanan benzersiz bir MessageId’si var. Consumer bu ID’yi işlemeden önce kontrol eder:
// PaymentCreatedConsumer.cs
public async Task Consume(ConsumeContext<PaymentCreatedEvent> context)
{
var messageId = context.MessageId ?? Guid.NewGuid();
var alreadyProcessed = await _db.FraudCheckResults
.AnyAsync(f => f.MessageId == messageId);
if (alreadyProcessed)
{
_logger.LogWarning("Duplicate message {Id}. Skipping.", messageId);
return; // Sessizce atla
}
var (isSuspicious, score, reason) = await _fraudService.AnalyzeAsync(...);
_db.FraudCheckResults.Add(new FraudCheckResult
{
PaymentId = message.PaymentId,
MessageId = messageId, // ← Kaydediyoruz
IsSuspicious = isSuspicious,
RiskScore = score
});
await _db.SaveChangesAsync();
}
Veritabanı tarafında UNIQUE index ile de güvence altına alıyoruz:
// FraudDbContext.cs
entity.HasIndex(f => f.MessageId).IsUnique();
⚠️ Önemli Not
Sadece uygulama seviyesinde kontrol yeterli değil. Race condition durumlarında iki thread aynı anda AnyAsync yapıp false dönebilir. DB’deki UNIQUE index bu senaryoyu da kapatır — ikinci insert exception fırlatır ve idempotent olarak işlenir.
Pattern 4: Dead Letter Queue (DLQ)
Problem: Kalıcı Hatalar
Consumer bazen geçici (transient) değil, kalıcı (permanent) hatalarla karşılaşır: schema değişikliği, geçersiz veri, üçüncü parti servis kesintisi. Sonsuz retry hem gereksiz yük oluşturur hem de akışı bloklar.
Çözüm: Retry + DLQ Kombinasyonu
MassTransit’te her consumer için retry politikası ve DLQ yapılandırması ConsumerDefinition içinde yapılır:
// PaymentCreatedConsumerDefinition.cs
protected override void ConfigureConsumer(...)
{
// Exponential backoff: 2s → 5s → 30s
endpointConfigurator.UseMessageRetry(r =>
r.Exponential(3,
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(5)));
// Retry bittikten sonra _error queue'ya taşı
endpointConfigurator.UseDeadLetterQueueDeadLetterTransport();
endpointConfigurator.UseDeadLetterQueueErrorTransport();
// Gecikmeli yeniden deneme: 1dk → 5dk → 15dk
endpointConfigurator.UseScheduledRedelivery(r =>
r.Intervals(
TimeSpan.FromMinutes(1),
TimeSpan.FromMinutes(5),
TimeSpan.FromMinutes(15)));
}
Başarısız mesajlar fraud-service-payment-created_error kuyruğuna taşınır. RabbitMQ Management UI’dan bu mesajları inceleyebilir, düzeltebilir ve yeniden kuyruğa alabilirsiniz.
Message ──▶ Queue
│
[Hata]
│
Retry #1 (2s)
Retry #2 (5s)
Retry #3 (10s)
│
[Max retry]
│
▼
fraud-service-payment-created_error
(Manuel inceleme + requeue)
Çalıştırma: Docker Compose
Tek komutla tüm sistemi ayağa kaldırabilirsiniz:
git clone https://github.com/your-username/PaymentEDA.git
cd PaymentEDA
docker-compose up --build
# Servisler:
# Payment API → http://localhost:5001/swagger
# RabbitMQ UI → http://localhost:15672 (guest/guest)
# Analytics → http://localhost:5006/analytics/summary
# Saga State → http://localhost:5007/saga/{correlationId}
Test etmek için:
curl -X POST http://localhost:5001/api/payments \
-H "Content-Type: application/json" \
-d '{
"merchantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"userId": "4fa85f64-5717-4562-b3fc-2c963f66afa7",
"amount": 1500.00,
"currency": "TRY",
"method": "CreditCard",
"description": "Order #12345"
}'
Pattern Özeti
| Pattern | Problem | Çözüm | Dosya |
|---|---|---|---|
| Outbox | Dual-write riski | Atomik DB + event yazımı | OutboxPublisherService.cs |
| Saga | Dağıtık state yönetimi | State machine + compensate | PaymentStateMachine.cs |
| Idempotent Consumer | Tekrar teslim riski | MessageId unique check | PaymentCreatedConsumer.cs |
| Dead Letter Queue | Kalıcı hata yönetimi | _error queue + retry | ConsumerDefinition.cs |
Sık Yapılan Yanlışlar
❌ “HTTP çağrıları yeterli, neden EDA?”
✅ Bir servis düştüğünde tüm zincir durur — EDA servisleri izole eder
❌ “Outbox yerine sadece retry koyarız”
✅ Retry, mesajın hiç yayınlanamaması durumunu çözmez
❌ “RabbitMQ exactly-once garantisi verir”
✅ At-least-once verir — idempotency consumer’ın sorumluluğundadır
❌ “DLQ’ya düşen mesajları görmezden geliriz”
✅ DLQ bir uyarı mekanizmasıdır; düzenli izlenmesi gerekir
Sonuç: EDA Bir Tercih Değil, Bir Zorunluluk
Event-Driven Architecture ilk bakışta karmaşık görünebilir.
Ancak gerçekte:
- Daha izole ve bağımsız servisler
- Daha az kaskad hata (cascading failure)
- Daha kolay ölçekleme
- Daha güçlü hata toleransı
anlamına gelir.
Ödeme sistemlerinde tutarlılık opsiyonel değildir.
Outbox, Saga, Idempotent Consumer ve DLQ — bu dört pattern, production’da bu tutarlılığın temelidir. Her zaman olduğu gibi kaynaklara mutlaka göz atılmasını tavsiye ediyorum. ⬇️
Kaynaklar
- mehmetoya/payment-event-driven-architecture — Kaynak kod
- MassTransit Documentation — masstransit.io
- RabbitMQ Official Docs — rabbitmq.com
- Microsoft: Saga Pattern in Microservices
- Chris Richardson — microservices.io (Outbox Pattern)
- .NET 8 Worker Services Documentation

