Transactional Outbox Pattern, dağıtık sistemlerde (mikroservislerde) veri tutarlılığını sağlamak için kullanılan en güvenilir tasarım desenidir.

Bu desenin temel amacı, "Veritabanı güncellemesi" ile "Mesajın kuyruğa (Kafka vb.) gönderilmesi" işlemlerini tek bir atomik birim haline getirmektir.


1. Sorun: Dual Write (İkili Yazma) Problemi

Mikroservislerde genellikle bir iş akışı şu şekildedir:

  1. Veritabanına bir kayıt at (Örn: Orders tablosuna sipariş ekle).
  2. Diğer servisleri haberdar etmek için bir mesaj yayınla (Örn: Kafka'ya OrderCreated mesajı gönder).

Problem şurada başlar: Bu iki işlem farklı dünyalara aittir.

  • Veritabanı işlemi başarılı olur ama tam mesaj gönderilecekken ağ koparsa veya uygulama çökerse? Veri kaydedilir ama diğer servislerin haberi olmaz.
  • Mesaj gider ama veritabanı işlemi bir hata (constraint vb.) nedeniyle geri alınırsa (rollback)? Bu sefer de var olmayan bir veri için "hayalet mesaj" (ghost message) göndermiş olursunuz.

2. Çözüm: Outbox Tablosu

Outbox pattern, bu iki işlemi birbirine zincirler. Doğrudan Kafka'ya mesaj göndermek yerine, mesajı veritabanındaki teknik bir tabloya (Outbox tablosu) yazarız.

Sistemin İşleyişi:

  1. Atomik İşlem: Uygulama, ana veriyi (Order) ve mesajı (Outbox) aynı veritabanı işlemi (Transaction) içinde kaydeder. ACID özellikleri sayesinde ya ikisi de kaydedilir ya da hiçbiri.
  2. Mesaj Aktarıcı (Message Relay): Arka planda çalışan ayrı bir süreç, Outbox tablosunu sürekli kontrol eder.
  3. Kesin Gönderim: Aktarıcı, tablodaki mesajı okur ve Kafka/RabbitMQ'ya gönderir. Gönderim başarılı olunca mesajı tablodan siler veya "gönderildi" olarak işaretler.

3. Neden Bu Kadar Önemli?

  • Veri Kaybı Yok: Veritabanı ayakta olduğu sürece mesajınız güvendedir. Uygulama o an çökse bile mesaj Outbox tablosunda bekler.
  • Garantili İletim: Aktarıcı süreç, mesajın kuyruğa ulaştığından emin olana kadar (ACK alana kadar) denemeye devam eder. Bu da bize At-Least-Once (En Az Bir Kez) teslimat garantisi sağlar.
  • Bağımsızlık: Mesaj kuyruğu (Kafka) o an kapalı olsa bile kullanıcıya "Hata" döndürmezsiniz. Sistem veriyi DB'ye yazar, Kafka gelince mesaj arka planda iletilir.

4. Uygulama Yöntemleri

Bu deseni hayata geçirmek için iki ana yol vardır:

  1. Polling Publisher (Sorgulama): Java tarafında @Scheduled ile belirli aralıklarla Outbox tablosunu sorgulayan bir kod yazmak. (Basittir ama veritabanını yorabilir).
  2. Transaction Log Tailing (CDC - Debezium): Veritabanının "değişim günlüklerini" (PostgreSQL'de WAL, MySQL'de Binlog) takip eden bir araç kullanmak. En performanslı ve profesyonel yöntem budur; veritabanına ek sorgu yükü getirmez.

Transactional Outbox Pattern'in çalışma mantığını, bir yazılım mimarı gözüyle en ince ayrıntısına kadar ve Java (Spring Boot) örnekleriyle inceleyelim.


1. Mimari Bileşenler

Bu deseni kurmak için 3 ana yapıya ihtiyacınız vardır:

  1. Business Table: Asıl iş verinizin olduğu tablo (Örn: orders).
  2. Outbox Table: Gönderilecek mesajların kuyruğu (Örn: outbox_events).
  3. Message Relay: Bu tabloyu izleyip mesajları Kafka'ya uçuran tetikleyici.

2. Java ile Uygulama Adımları

A. Veritabanı Şeması ve Entity Yapısı

Outbox tablosu, mesajın içeriğini (JSON) ve durumunu saklamalıdır.

@Entity
@Table(name = "outbox_events")
public class OutboxEvent {
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    private String aggregateType; // "ORDER", "USER" gibi
    private String aggregateId;   // İlgili kaydın ID'si
    private String type;          // "OrderCreated", "OrderCancelled"

    @Column(columnDefinition = "TEXT")
    private String payload;       // Mesajın JSON hali

    private String status;        // PENDING, PROCESSED, FAILED
    private LocalDateTime createdAt;
}

B. Servis Katmanı: Atomik Yazma

Buradaki en kritik nokta @Transactional kullanımıdır. Eğer sipariş kaydedilir ama outbox tablosuna yazarken hata çıkarsa, Spring tüm işlemi geri alır (rollback). Böylece tutarsızlık engellenir.

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final OutboxRepository outboxRepository;
    private final ObjectMapper objectMapper;

    @Transactional
    public void placeOrder(OrderRequest request) {
        // 1. İş kuralını işle ve siparişi kaydet
        Order order = new Order(request.getProductId(), request.getQuantity());
        orderRepository.save(order);

        // 2. Outbox mesajını hazırla
        try {
            String jsonPayload = objectMapper.writeValueAsString(order);

            OutboxEvent event = OutboxEvent.builder()
                .aggregateType("ORDER")
                .aggregateId(order.getId().toString())
                .type("ORDER_CREATED")
                .payload(jsonPayload)
                .status("PENDING")
                .createdAt(LocalDateTime.now())
                .build();

            // 3. AYNI TRANSACTION içinde outbox tablosuna yaz
            outboxRepository.save(event);

        } catch (JsonProcessingException e) {
            throw new RuntimeException("Mesaj dönüştürme hatası", e);
        }
    }
}

3. Mesajı Kafka'ya İletme (Relay)

Mesajları veritabanından alıp Kafka'ya göndermek için iki popüler yol vardır:

Yöntem 1: Polling Publisher (Basit ama Veritabanını Yorar)

Bir "Worker" oluşturup her 500ms'de bir PENDING olan kayıtları sorgularız.

@Component
@RequiredArgsConstructor
@Slf4j
public class OutboxPoller {

    private final OutboxRepository outboxRepository;
    private final KafkaTemplate<String, String> kafkaTemplate;

    @Scheduled(fixedDelay = 500)
    @Transactional
    public void pollOutbox() {
        // İşlenmemiş mesajları çek
        List<OutboxEvent> events = outboxRepository.findTop10ByStatusOrderByCreatedAtAsc("PENDING");

        for (OutboxEvent event : events) {
            try {
                // Kafka'ya gönder
                kafkaTemplate.send("order-events-topic", event.getAggregateId(), event.getPayload())
                    .get(); // Onay (ACK) bekle

                // Başarılıysa durumu güncelle
                event.setStatus("PROCESSED");
                outboxRepository.save(event);

            } catch (Exception e) {
                log.error("Mesaj gönderilemedi: {}", event.getId(), e);
                // Burada status'ü FAILED yapıp bir "retry count" ekleyebilirsiniz
            }
        }
    }
}

Yöntem 2: Transaction Log Tailing (Debezium - Profesyonel)

Java kodunda @Scheduled kullanmak yerine, veritabanının Write-Ahead Log (WAL) dosyalarını dinleyen bir araç (Debezium) kullanılır.

  • Avantajı: Veritabanına ek SELECT sorgusu atmaz, çok daha hızlıdır (neredeyse real-time).
  • Akış: DB'ye INSERT yapıldığı an Debezium bunu yakalar ve doğrudan Kafka'ya basar.

4. Kritik Detaylar ve "Corner Cases"

  1. Sıralama (Ordering): Outbox tablosundan okurken mutlaka createdAt veya bir sequence_id ile sıralı okumalısınız. Siparişin "İptal" mesajı, "Oluşturuldu" mesajından önce giderse sistem karışır.
  2. Performance: outbox_events tablosu zamanla şişer. PROCESSED durumundaki eski kayıtları temizleyen bir "Cleanup Job" kurmanız gerekir.
  3. At-Least-Once Yan Etkisi: Eğer kafkaTemplate.send başarılı olur ama tam o sırada veritabanında status = 'PROCESSED' güncellemesi yapılamadan sistem çökerse, mesaj Kafka'ya tekrar gönderilir.
    • Çözüm: Alıcı tarafın (Consumer) mutlaka Idempotent olması gerekir (ilk başta konuştuğumuz konu).