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.
@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;
}{
"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"
}
}