Skip to content
Kafka Is FunProgress

Enterprise Architecture · Enterprise

Outbox Pattern (Zero Dual-Write Risk)

The Outbox Pattern solves the dual-write problem. Write to DB and outbox table in one ACID transaction. A Debezium CDC connector reads the outbox and publishes to Kafka. Guarantees no orphaned DB writes without events.

OrderService.java (Outbox pattern)java
@Service
@Slf4j
public class OrderService {

    private final OrderRepository orderRepo;
    private final OutboxRepository outboxRepo;

    @Transactional  // Single ACID transaction — no dual-write
    public Order createOrder(CreateOrderRequest req) {
        // 1. Business write
        Order order = orderRepo.save(Order.from(req));

        // 2. Outbox entry in SAME transaction
        OutboxEvent outboxEvent = OutboxEvent.builder()
            .aggregateId(order.getId())
            .aggregateType("Order")
            .eventType("OrderCreated")
            .payload(objectMapper.writeValueAsString(new OrderCreatedEvent(order)))
            .status(OutboxStatus.PENDING)
            .build();
        outboxRepo.save(outboxEvent);

        return order;
        // Debezium CDC reads outbox table changes and publishes to Kafka
        // No manual Kafka call here = no dual-write problem
    }
}

// Outbox entity
@Entity
@Table(name = "outbox_events")
public class OutboxEvent {
    @Id @GeneratedValue private UUID id;
    private String aggregateId;
    private String aggregateType;
    private String eventType;
    @Column(columnDefinition = "TEXT") private String payload;
    private OutboxStatus status;
    private Instant createdAt;
    private Instant processedAt;
}
Debezium connector config (JSON)json
{
  "name": "outbox-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "postgres",
    "database.dbname": "orders_db",
    "table.include.list": "public.outbox_events",
    "transforms": "outbox",
    "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
    "transforms.outbox.table.field.event.type": "event_type",
    "transforms.outbox.route.topic.replacement": "orders.${routedByValue}",
    "transforms.outbox.table.field.event.key": "aggregate_id"
  }
}