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. ⬇️
Kısa sonuç
Ödeme akışlarında ölçeklenebilirlik kadar güvenilirlik ve mutabakat da tasarımın parçasıdır. Bu mimariyi onboarding ve KYC kararlarıyla birlikte düşünmek için dijital onboarding ve KYC yazısına ve doğru risk skorlama yazısına geçebilirsiniz.
İlgili yazılar
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
