Volver al blog
• 8 min de lectura

Outbox pattern: integra sin perder mensajes

Si actualizas la base de datos y luego notificas a sistemas externos, hazlo con outbox. Guardar en DB y luego mandar falla en el peor momento: guardaste, no enviaste, nadie sabe, y ya no puedes reconstruir.

Arquitectura Mensajería Patrones Backend Integración
Outbox pattern: integra sin perder mensajes
Foto por Markus Winkler

Hay un bug silencioso que acecha a casi todo sistema que integra una base de datos con un broker de mensajes. Se manifiesta así:

  1. Guardas algo en la DB.
  2. Mandas un mensaje a Kafka, SQS o RabbitMQ.
  3. La red falla justo ahí.
  4. Nadie recibe el mensaje.
  5. La DB dice que el registro existe.
  6. El resto del sistema no sabe nada.

Y lo peor: no hay error visible. El registro quedó en tu base de datos, todo parece correcto, pero el evento que debía disparar el siguiente paso nunca llegó. Esto no es un escenario hipotético — ocurre exactamente en el momento más inconveniente: alto tráfico, picos de carga, ventanas de mantenimiento del broker.

La solución tiene nombre: Outbox Pattern.


El problema raíz: dos sistemas, una ilusión de atomicidad

Cuando haces esto:

// El anti-patrón clásico
orderRepository.save(order);         // escribe en Postgres
kafkaTemplate.send("orders", event); // escribe en Kafka

Estás tratando dos sistemas completamente independientes como si fueran uno solo. No hay transacción que los una. Si el proceso muere entre esas dos líneas, si la red al broker se corta, si Kafka está saturado y rechaza el mensaje — tu DB quedó actualizada y tu broker no sabe nada.

El reintento en memoria tampoco salva:

// El anti-patrón con reintento
orderRepository.save(order);
try {
    kafkaTemplate.send("orders", event);
} catch (Exception e) {
    retryQueue.add(event); // se pierde si el proceso muere
}

Si el proceso muere después del save y antes de que el reintento se persista, el mensaje se pierde igualmente. Los reintentos en memoria no son durables.


La solución: el Outbox Pattern

La idea central es elegante: usar la misma transacción de base de datos para registrar el cambio de negocio y el mensaje que debe enviarse.

┌────────────────────────────────────────┐
│           Transacción única            │
│                                        │
│  INSERT orders (...)                   │
│  INSERT outbox (event_type, payload)   │
└────────────────────────────────────────┘

  Worker independiente

  Lee outbox → publica en broker → marca como enviado

Si la transacción falla, ninguno de los dos registros queda. Si la transacción tiene éxito, ambos quedan — y el worker garantiza que el mensaje llegará al broker eventualmente.

La tabla outbox

CREATE TABLE outbox (
    id          BIGSERIAL PRIMARY KEY,
    event_type  VARCHAR(100)  NOT NULL,
    payload     JSONB         NOT NULL,
    created_at  TIMESTAMPTZ   NOT NULL DEFAULT NOW(),
    sent_at     TIMESTAMPTZ,
    status      VARCHAR(20)   NOT NULL DEFAULT 'pending'
);

CREATE INDEX idx_outbox_pending ON outbox (created_at)
    WHERE status = 'pending';

El código de negocio

@Transactional
public Order createOrder(CreateOrderRequest request) {
    Order order = orderRepository.save(new Order(request));

    OutboxEvent event = OutboxEvent.builder()
        .eventType("OrderCreated")
        .payload(toJson(new OrderCreatedEvent(order)))
        .build();

    outboxRepository.save(event);

    return order; // commit: ambos registros quedan o ninguno
}

No hay Kafka en el código de negocio. El servicio solo escribe en su propia base de datos. La publicación al broker es responsabilidad del worker.

El worker publicador

@Scheduled(fixedDelay = 1000)
@Transactional
public void publishPendingEvents() {
    List<OutboxEvent> pending = outboxRepository.findPendingWithLock(100);

    for (OutboxEvent event : pending) {
        try {
            kafkaTemplate.send(topicFor(event.getEventType()), event.getPayload())
                .get(5, TimeUnit.SECONDS); // espera confirmación del broker

            event.markSent();
            outboxRepository.save(event);

        } catch (Exception e) {
            log.error("Failed to publish outbox event", Map.of(
                "eventId", event.getId(),
                "eventType", event.getEventType()
            ));
            // no lanza — el evento queda en pending y se reintentará
        }
    }
}

El SELECT ... FOR UPDATE SKIP LOCKED en findPendingWithLock evita que múltiples instancias del worker procesen el mismo evento:

SELECT * FROM outbox
WHERE status = 'pending'
ORDER BY created_at
LIMIT :limit
FOR UPDATE SKIP LOCKED;

Garantías que obtienes

Al menos una entrega (at-least-once delivery): el worker reintentará hasta confirmar que el broker recibió el mensaje. Esto significa que el consumidor puede recibir el mismo evento más de una vez si el worker publica pero falla antes de marcar el evento como enviado. Diseña tus consumidores para ser idempotentes.

Sin pérdida de mensajes: si la transacción de negocio tuvo éxito, el evento existe en outbox. El worker lo publicará eventualmente, aunque el proceso muera y reinicie.

Desacoplamiento del broker: tu código de negocio no depende de que Kafka esté disponible. Si el broker está caído, los eventos se acumulan en outbox y se publican cuando vuelve.


Variante: Transactional Outbox con CDC

En lugar de un worker que hace polling, puedes usar Change Data Capture (CDC) con herramientas como Debezium. El CDC lee el log de transacciones de Postgres (WAL) y publica los cambios directamente:

Postgres WAL → Debezium → Kafka Connect → Kafka topic

Ventajas del CDC:

  • Latencia menor (reacciona al log, no a un poll periódico).
  • No necesitas código de worker — es infraestructura.
  • Escala mejor para volúmenes altos.

Desventajas:

  • Mayor complejidad operativa (Debezium + Kafka Connect son piezas más).
  • Requiere acceso al WAL del motor de base de datos.
  • Más difícil de testear localmente.

Para la mayoría de los casos, el worker con polling es suficiente y más simple de operar.


Lo que no es el Outbox Pattern

No es un sistema de colas de propósito general. La tabla outbox es transitoria — los eventos se deben publicar y limpiar. Si la acumulas indefinidamente porque el worker falla, tienes un problema de operación, no un feature.

No elimina la necesidad de idempotencia en los consumidores. At-least-once significa duplicados posibles. El consumidor debe poder recibir el mismo OrderCreated dos veces sin crear dos órdenes.

No reemplaza un broker de mensajes. El outbox es el puente confiable hacia el broker, no un reemplazo. Sigue necesitando Kafka, SQS o lo que uses para la distribución a múltiples consumidores.


El anti-patrón que parece solución

// "Lo mando directo y si falla, reintento"
@Transactional
public Order createOrder(CreateOrderRequest request) {
    Order order = orderRepository.save(order);

    try {
        kafkaTemplate.send("orders", event).get();
    } catch (Exception e) {
        // "esto casi nunca pasa"
        log.warn("Kafka send failed, will retry later...");
        inMemoryRetryQueue.add(event); // durable hasta el próximo restart
    }

    return order;
}

El comentario "esto casi nunca pasa" es exactamente la firma de un bug que aparece en producción un viernes a las 11pm. Los sistemas distribuidos fallan de formas inesperadas y en el peor momento. La cola en memoria se pierde con el proceso.


Cuándo aplicarlo

El Outbox Pattern aplica cada vez que necesitas:

  • Publicar un evento de dominio como consecuencia de un cambio en la DB (OrderCreated, PaymentProcessed, UserRegistered).
  • Notificar a un servicio externo (webhook, API) después de una transacción.
  • Sincronizar datos entre sistemas donde la consistencia eventual es aceptable pero la pérdida de mensajes no lo es.

Si solo actualizas tu propia DB sin notificar a nadie afuera, no necesitas outbox. Si cruzas la frontera de tu proceso para notificar, lo necesitas.


Alternativas open source

No tienes que implementar el outbox desde cero. Hay librerías que resuelven el problema por ti, con soporte para transacciones, reintentos, limpieza y múltiples brokers.

spring-outbox

Integración nativa con Spring Boot y Spring Data. La idea es simple: anotas tus entidades o eventos de dominio y la librería se encarga de persistir el outbox en la misma transacción y de publicar en el broker.

  • Soporte para Spring Events y Spring Messaging.
  • El outbox queda en tu propia DB (misma fuente que tu modelo de negocio).
  • Configuración mínima si ya usas Spring Boot.

Útil cuando el equipo vive en el ecosistema Spring y quiere adoptar el patrón sin construir la infraestructura de cero.

transaction-outbox

Librería Java de propósito general, no acoplada a Spring. Soporta múltiples bases de datos (Postgres, MySQL, H2, DynamoDB) y múltiples mecanismos de ejecución (threads, Guava, RxJava, reactor).

  • El outbox se integra en cualquier transacción JDBC, jOOQ, Hibernate o Spring.
  • El worker puede correr como parte del mismo proceso o externamente.
  • Soporte para reintentos con backoff configurable y dead-letter queue para eventos que no se pueden procesar.
  • Buena opción cuando el stack no es estándar Spring o cuando necesitas más control sobre el comportamiento del worker.

Ambas librerías resuelven el problema central: la coordinación transaccional entre el cambio de negocio y el registro en outbox. La elección depende de si ya estás en Spring Boot (spring-outbox es más ergonómico) o si necesitas más flexibilidad de stack (transaction-outbox).


Cierre

La regla es simple: si actualizas DB y luego notificas afuera, hazlo con outbox.

“Guardar en DB y luego mandar” parece inocente. Es el tipo de código que funciona perfectamente en desarrollo, en staging bajo carga baja, y falla silenciosamente en producción cuando más duele. El sistema que perdió el mensaje no sabe que lo perdió. El sistema que debía recibirlo no sabe que debía recibirlo. Nadie lanza una excepción.

El Outbox Pattern no es complejidad innecesaria. Es el precio mínimo de hacer integración confiable entre sistemas.