Mehmet Oya

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

PatternProblemÇözümDosya
OutboxDual-write riskiAtomik DB + event yazımıOutboxPublisherService.cs
SagaDağıtık state yönetimiState machine + compensatePaymentStateMachine.cs
Idempotent ConsumerTekrar teslim riskiMessageId unique checkPaymentCreatedConsumer.cs
Dead Letter QueueKalıcı hata yönetimi_error queue + retryConsumerDefinition.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

← Older Fin ve Tech: PCI DSS Nedir? Ödeme Sistemlerinde... Newer → Fin ve Tech: Enterprise Core Banking Architecture...
Related articles
Fin ve Tech: Enterprise Core Banking Architecture — Ölçek, Hız ve Güvenlik İçin Tasarlandı Fin ve Tech: Dijital Onboarding ve KYC — Bir Müşteriyi Saniyeler İçinde Tanımak Fin ve Tech: Onboarding'de Doğru Mimari ve Doğru Risk Skorlama