Outbox Pattern

Cómo garantizar la publicación confiable de eventos en sistemas distribuidos usando transactional outbox.

Qué problema resuelve

En sistemas basados en eventos, un microservicio necesita hacer dos cosas cuando procesa una operación: guardar datos en su base de datos y publicar un evento para notificar a otros servicios. El problema es que estas son dos operaciones en dos sistemas diferentes (base de datos y broker de mensajes), y no hay una transacción atómica que las cubra a ambas.

El problema del dual write

Servicio de Pedidos:
  1. Guardar pedido en PostgreSQL  ✅
  2. Publicar "PedidoCreado" en Kafka  ❌ (fallo de red)

Resultado: El pedido existe en la base de datos,
pero ningún otro servicio se enteró.

Este escenario se llama dual write (escritura dual) y puede causar:

  • Datos guardados, evento perdido: El pedido existe pero el inventario nunca se reservó
  • Evento publicado, datos no guardados: Otros servicios reaccionan a un pedido que no existe
  • Inconsistencia silenciosa: El sistema parece funcionar pero los datos entre servicios divergen

Intentar resolver esto con “publicar primero, guardar después” solo invierte el problema. Y usar transacciones distribuidas (2PC) introduce latencia y puntos de fallo.

Escenarios reales donde ocurre

EscenarioConsecuencia sin Outbox
Crear pedido + notificar inventarioStock no se reserva, se vende producto agotado
Procesar pago + notificar envíosPago cobrado pero envío nunca se programa
Actualizar perfil + notificar serviciosDatos desincronizados entre servicios
Completar saga paso N + publicar eventoSaga se queda atascada en paso intermedio

Cómo funciona

El Outbox Pattern resuelve el dual write usando una sola transacción de base de datos para ambas operaciones. En lugar de publicar el evento directamente al broker, el servicio escribe el evento en una tabla outbox dentro de la misma transacción que guarda los datos de negocio.

Flujo básico

┌─────────────────────────────────────────────┐
│  Transacción de base de datos (atómica)     │
│                                             │
│  1. INSERT INTO pedidos (...)               │
│  2. INSERT INTO outbox (evento, payload...) │
│                                             │
│  COMMIT                                     │
└─────────────────────────────────────────────┘

         │  (proceso separado)

┌─────────────────────┐     ┌──────────────┐
│  Relay / Publisher   │────►│  Kafka /     │
│  (lee tabla outbox)  │     │  RabbitMQ    │
└─────────────────────┘     └──────────────┘

La clave es que ambas escrituras (datos de negocio + evento) ocurren en la misma transacción de base de datos. Si la transacción falla, ninguna de las dos se persiste. Si tiene éxito, ambas se persisten. No hay ventana de inconsistencia.

Estructura de la tabla outbox

CREATE TABLE outbox (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  aggregate_type  VARCHAR(255) NOT NULL,  -- "Pedido", "Pago"
  aggregate_id    VARCHAR(255) NOT NULL,  -- ID del agregado
  event_type      VARCHAR(255) NOT NULL,  -- "PedidoCreado"
  payload         JSONB NOT NULL,         -- datos del evento
  created_at      TIMESTAMP NOT NULL DEFAULT NOW(),
  published_at    TIMESTAMP NULL          -- NULL = pendiente
);

-- Índice para el publisher
CREATE INDEX idx_outbox_pending
  ON outbox (created_at)
  WHERE published_at IS NULL;

Ejemplo de uso en código

// Dentro de una transacción de base de datos
BEGIN;

  -- 1. Operación de negocio
  INSERT INTO pedidos (id, cliente_id, total, estado)
  VALUES ('ped-001', 'cli-789', 150.00, 'confirmado');

  INSERT INTO lineas_pedido (pedido_id, producto_id, cantidad, precio)
  VALUES ('ped-001', 'prod-42', 2, 75.00);

  -- 2. Evento en la tabla outbox (misma transacción)
  INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
  VALUES (
    'Pedido',
    'ped-001',
    'PedidoConfirmado',
    '{"pedidoId": "ped-001", "clienteId": "cli-789", "total": 150.00}'
  );

COMMIT;
-- Ambas escrituras son atómicas: o se guardan las dos, o ninguna

Estrategias de publicación

Hay dos formas principales de leer la tabla outbox y publicar los eventos al broker:

Polling Publisher (Publicador por sondeo)

Un proceso periódico consulta la tabla outbox buscando eventos no publicados, los envía al broker y los marca como publicados.

┌──────────────┐                    ┌──────────┐
│   Outbox     │  SELECT * FROM     │ Polling  │
│   Table      │◄─── outbox WHERE ──│ Publisher│
│              │  published_at NULL  │          │
└──────────────┘                    └────┬─────┘

                                   Publicar evento


                                   ┌──────────┐
                                   │  Broker  │
                                   └──────────┘

Ventajas: Simple de implementar, no requiere infraestructura adicional. Desventajas: Latencia (depende del intervalo de polling), carga en la base de datos.

Implementación típica del polling:

// Cada 500ms (configurable)
loop {
  eventos = SELECT * FROM outbox
            WHERE published_at IS NULL
            ORDER BY created_at
            LIMIT 100
            FOR UPDATE SKIP LOCKED;  -- evita conflictos entre publishers

  for evento in eventos {
    broker.publish(evento.event_type, evento.payload);

    UPDATE outbox
    SET published_at = NOW()
    WHERE id = evento.id;
  }
}

Change Data Capture (CDC)

Un conector CDC (como Debezium) monitorea el log de transacciones de la base de datos y captura automáticamente las inserciones en la tabla outbox.

┌──────────────┐                    ┌──────────┐
│  PostgreSQL  │  WAL / binlog      │ Debezium │
│  Transaction │──────────────────► │  (CDC)   │
│  Log         │                    └────┬─────┘
└──────────────┘                         │
                                    Publicar evento


                                    ┌──────────┐
                                    │  Kafka   │
                                    └──────────┘

Ventajas: Latencia muy baja (casi real-time), no genera carga adicional con queries. Desventajas: Requiere infraestructura adicional (Debezium, Kafka Connect), más complejo de operar.

Comparación de estrategias

AspectoPollingCDC
Latencia100ms - 5s (configurable)< 100ms (casi real-time)
ComplejidadBaja (un cron o proceso simple)Alta (Debezium + Kafka Connect)
Carga en BDQueries periódicosLee del WAL, sin queries extra
InfraestructuraMínimaDebezium, Kafka Connect, Zookeeper
EscalabilidadLimitada por la BDAlta (CDC es muy eficiente)
Recomendado paraVolumen bajo-medioVolumen alto, baja latencia

Garantías de entrega

El Outbox Pattern garantiza at-least-once delivery (entrega al menos una vez). Esto significa que un evento puede publicarse más de una vez. Por eso, los consumidores deben ser idempotentes.

Escenario de duplicación:
  1. Publisher lee evento de outbox
  2. Publisher publica evento en Kafka  ✅
  3. Publisher falla antes de marcar como publicado  ❌
  4. Publisher se reinicia y vuelve a publicar el mismo evento

Solución: El consumidor detecta el duplicado por el ID del evento
y lo ignora (Idempotency Pattern).

Limpieza de la tabla outbox

La tabla outbox crece continuamente. Necesitas una estrategia de limpieza:

-- Opción 1: Borrar eventos publicados hace más de 7 días
DELETE FROM outbox
WHERE published_at IS NOT NULL
  AND published_at < NOW() - INTERVAL '7 days';

-- Opción 2: Particionado por fecha (más eficiente)
-- Crear particiones mensuales y dropear particiones antiguas
CREATE TABLE outbox (
  ...
) PARTITION BY RANGE (created_at);

-- Opción 3: Mover a tabla de archivo antes de borrar
INSERT INTO outbox_archive SELECT * FROM outbox
WHERE published_at < NOW() - INTERVAL '30 days';

Ventajas

  • Consistencia garantizada: Datos y eventos se escriben en la misma transacción, eliminando el dual write
  • Sin transacciones distribuidas: No necesitas 2PC ni XA transactions
  • Auditoría natural: La tabla outbox funciona como log de todos los eventos publicados
  • Resiliencia: Si el broker está caído, los eventos se acumulan y se publican cuando vuelva
  • Desacoplamiento temporal: El servicio no depende de la disponibilidad del broker
  • Compatible con cualquier base de datos relacional: Solo necesitas una tabla adicional

Trade-offs / Desventajas

  • Latencia adicional: Los eventos no se publican instantáneamente (especialmente con polling)
  • Complejidad operacional: Necesitas mantener el publisher o el conector CDC
  • Tabla outbox crece: Necesitas una estrategia de limpieza periódica
  • At-least-once: Los consumidores deben manejar duplicados (idempotencia obligatoria)
  • Acoplamiento con la base de datos: CDC depende de features específicas del motor (WAL, binlog)
  • Overhead en escritura: Cada operación escribe una fila adicional en la tabla outbox

Cuándo usar

  • Sistemas event-driven donde la confiabilidad de los eventos es crítica
  • Microservicios que necesitan notificar cambios a otros servicios de forma confiable
  • Cuando implementas el Saga Pattern y necesitas garantizar que los eventos de cada paso se publiquen
  • Sistemas financieros o de e-commerce donde perder un evento tiene impacto en el negocio
  • Cuando ya usas una base de datos relacional y quieres evitar transacciones distribuidas
  • Flujos donde la consistencia eventual es aceptable pero la pérdida de eventos no

Cuándo evitar

  • Sistemas que requieren publicación de eventos en tiempo real estricto (microsegundos)
  • Cuando usas una base de datos que no soporta transacciones ACID (algunos NoSQL)
  • Aplicaciones simples donde publicar directamente al broker es suficiente
  • Sistemas con muy bajo volumen de eventos donde la complejidad no se justifica
  • Cuando ya tienes Event Sourcing implementado (el event store ya es tu “outbox”)

Tecnologías e implementaciones comunes

CategoríaOpciones
CDCDebezium, Maxwell (MySQL), AWS DMS
BrokersApache Kafka, RabbitMQ, Amazon SNS/SQS, Azure Service Bus
Bases de datosPostgreSQL (WAL), MySQL (binlog), SQL Server (CT)
FrameworksEventuate Tram, MassTransit Outbox (.NET), Wolverine (.NET)
ConectoresKafka Connect, Debezium Server
LimpiezaCron jobs, pg_partman (particionado por fecha)

Relación con otros patrones

El Outbox Pattern es una pieza fundamental en la infraestructura de sistemas event-driven:

┌─────────────────────────────────────────────────┐
│         Outbox en el ecosistema                  │
│                                                  │
│  ┌──────────┐  necesita   ┌──────────────────┐  │
│  │  Saga    │ ──────────► │  Outbox Pattern  │  │
│  │ Pattern  │  publicar   │  (este patrón)   │  │
│  └──────────┘  eventos    └────────┬─────────┘  │
│                                    │             │
│                              entrega al          │
│                              menos una vez       │
│                                    │             │
│                                    ▼             │
│                            ┌──────────────┐      │
│                            │ Idempotency  │      │
│                            │   Pattern    │      │
│                            └──────────────┘      │
│                                                  │
│  ┌──────────────┐  usa     ┌──────────────┐     │
│  │ Event-Driven │ ────────►│    Outbox     │     │
│  │ Architecture │  para    │   (confiable) │     │
│  └──────────────┘  eventos └──────────────┘     │
└─────────────────────────────────────────────────┘
  • Saga Pattern: Cada paso de una saga necesita publicar eventos de forma confiable. El Outbox garantiza que ningún evento se pierda entre pasos
  • Idempotency Pattern: Como el Outbox garantiza at-least-once, los consumidores deben ser idempotentes para manejar duplicados
  • Event-Driven Architecture: El Outbox es la infraestructura que hace confiable la publicación de eventos en una arquitectura event-driven
  • Circuit Breaker: Si el publisher del outbox no puede conectar al broker, el circuit breaker evita reintentos infinitos
  • DDD: Los Domain Events que genera cada bounded context se publican de forma confiable a través del outbox

Errores comunes

ErrorConsecuenciaSolución
No limpiar la tabla outboxTabla crece sin límite, queries lentosCron de limpieza o particionado
Publisher sin idempotenciaEventos duplicados en el brokerConsumidores idempotentes + dedup
Payload demasiado grandeTabla outbox ocupa mucho espacioGuardar solo IDs, consumidor consulta detalles
Un solo publisherCuello de botella en alto volumenMúltiples publishers con SKIP LOCKED
No monitorear el lagEventos se acumulan sin detectarAlertas por tamaño de cola pendiente

Próximos pasos

Con la publicación confiable de eventos resuelta, el siguiente desafío es proteger tus servicios cuando las dependencias fallan. El Circuit Breaker es el patrón que evita que un servicio caído arrastre a todo el sistema. También explora el Idempotency Pattern para asegurar que los consumidores de tus eventos manejen duplicados correctamente.