Mehmet Oya

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

May 05, 2026
8 minutes

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


Kaynaklar