Nivel 3 · 25 min
Outbox Pattern
El Outbox Pattern resuelve uno de los problemas más difíciles en sistemas distribuidos: publicar eventos a un message broker de forma confiable como parte de una transacción de base de datos. Elimina el problema del dual-write — el riesgo de guardar en la DB pero fallar al publicar (o viceversa).
El Problema del Dual-Write
El approach ingenuo: (1) guardar Order en DB, (2) publicar OrderPlaced en Kafka. Estas son dos operaciones separadas. Si la app falla entre los pasos 1 y 2, el pedido se guarda pero el evento nunca se publica — los servicios downstream nunca saben del pedido. Si invertís el orden (publicar primero), el evento se publica pero el pedido nunca se guarda. Sin importar el orden, hay una ventana de inconsistencia. El problema del dual-write: no podés commitear atómicamente a dos sistemas separados (DB + message broker) sin 2PC.
Transactional Outbox
La solución del Outbox Pattern: en la misma transacción de DB que guarda la Order, también escribís el evento OrderPlaced a una tabla outbox. Como ambas escrituras están en la misma transacción de DB, son atómicas — ambas commitean o ambas hacen rollback. El esquema de la tabla outbox: (id, aggregate_type, aggregate_id, event_type, payload, created_at, published_at). Un proceso relay separado lee eventos no publicados del outbox y los publica a Kafka. Tras la publicación exitosa, los marca como publicados. La DB es la única fuente de verdad tanto para datos de negocio como para eventos no publicados.
Estrategias de Relay y Garantías
Dos estrategias de relay: Polling relay — un thread de background hace poll de la tabla outbox para eventos no publicados en un schedule (simple, pero agrega carga a la DB y latencia). Relay basado en CDC (Change Data Capture) — Debezium captura inserciones en la tabla outbox desde el WAL de PostgreSQL y las publica a Kafka con latencia casi cero y sin carga adicional a la DB. El Outbox garantiza entrega at-least-once — si el relay publica pero falla antes de marcar como publicado, al reiniciar volverá a publicar. Los consumidores deben ser idempotentes. Esto es intencional — at-least-once + consumidores idempotentes es el patrón correcto de sistemas distribuidos.
Code example
// Atómico: guardar orden + escribir outbox en misma transacción
@Transactional
void createOrder(CreateOrderCommand cmd) {
Order order = Order.from(cmd);
orderRepository.save(order);
OutboxEvent event = OutboxEvent.builder()
.aggregateType("Order")
.aggregateId(order.getId())
.eventType("OrderPlaced")
.payload(json.write(new OrderPlaced(order)))
.build();
outboxRepository.save(event);
// transacción única: ambos o ninguno
}