En pocas palabras
Patrón que guarda el mensaje a publicar en una tabla outbox dentro de la transacción de negocio; un proceso aparte lo lee y lo entrega al broker.
Qué problema resuelve
Dual write: actualizar la base de datos y publicar en el broker son dos operaciones que pueden fallar por separado, dejando estado y eventos inconsistentes.
Cuándo utilizarlo
Servicios que persisten estado y notifican a otros mediante eventos. Ejemplo: registrar un desembolso y avisar a contabilidad y notificaciones.
De la idea a la práctica
Funcionamiento, ejemplos y decisiones para pasar al siguiente nivel.
El problema del dual write
1. BEGIN; UPDATE creditos SET estado='DESEMBOLSADO'; COMMIT; ✔
2. kafka.send("credito-desembolsado") ✘ timeout
El crédito queda desembolsado, pero contabilidad nunca se entera. Invertir el orden tampoco sirve: el evento sale y la transacción hace rollback. Sin transacciones distribuidas no hay forma de que ambas cosas sean atómicas, salvo que se conviertan en una sola escritura local.
Cómo funciona
- En la misma transacción se modifica el agregado y se inserta una fila en
outbox. - Un relay lee las filas nuevas y las publica en el broker.
- Tras confirmar la publicación, marca o elimina la fila.
Hay dos formas de implementar el relay:
| Variante | Funcionamiento | Ventajas / costes |
|---|---|---|
| Polling publisher | Un job consulta outbox cada pocos ms o segundos |
Sencillo; carga y latencia en la base de datos |
| Log tailing (CDC) | Debezium lee el WAL/binlog y emite cada inserción | Baja latencia, sin consultas; otra pieza que operar |
Ejemplo en PostgreSQL
CREATE TABLE outbox (
id uuid PRIMARY KEY,
aggregate_type text NOT NULL, -- 'credito'
aggregate_id text NOT NULL, -- se usa como clave de partición
type text NOT NULL, -- 'CreditoDesembolsado'
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
BEGIN;
UPDATE creditos SET estado = 'DESEMBOLSADO' WHERE id = '42';
INSERT INTO outbox (id, aggregate_type, aggregate_id, type, payload)
VALUES (gen_random_uuid(), 'credito', '42', 'CreditoDesembolsado',
'{"creditoId":"42","monto":1500000,"moneda":"COP"}');
COMMIT;
Un relay por polling que admite varias réplicas sin duplicar trabajo:
SELECT * FROM outbox
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
Con Debezium, la transformación io.debezium.transforms.outbox.EventRouter enruta cada fila al topic correspondiente a aggregate_type y usa aggregate_id como clave del mensaje.
Orden y duplicados
- Usa el id del agregado como clave de partición del topic: los eventos de un mismo crédito llegan en orden.
- El relay puede publicar y caerse antes de marcar la fila: el evento saldrá dos veces. Incluye el
iddel evento en las cabeceras y deduplica en el consumidor (tablaprocessed_eventso inbox pattern).
Antipatrones frecuentes
- Publicar en el broker «después del commit» con reintentos en memoria: se pierden eventos si el proceso muere.
- Tabla outbox sin limpieza: crece sin límite y degrada el relay. Programa un purgado de filas publicadas.
- Usar el outbox como fuente de verdad o historial de eventos: es un buffer, no event sourcing.
Implementación con Spring y polling
@Service
class OriginacionService {
@Transactional
public void aprobar(String solicitudId) {
var solicitud = repo.findById(solicitudId).orElseThrow();
solicitud.aprobar();
outbox.save(OutboxEvent.de("solicitud", solicitudId, "SolicitudAprobada",
json.write(new SolicitudAprobada(solicitudId, Instant.now()))));
}
}
@Component
class OutboxRelay {
@Scheduled(fixedDelay = 500)
@Transactional
public void publicar() {
List<OutboxEvent> pendientes = jdbc.query("""
SELECT * FROM outbox WHERE published_at IS NULL
ORDER BY created_at LIMIT 100 FOR UPDATE SKIP LOCKED""", mapper);
for (var e : pendientes) {
var registro = new ProducerRecord<>(e.topic(), e.aggregateId(), e.payload());
registro.headers().add("event-id", e.id().toString().getBytes());
kafka.send(registro).get(); // esperar la confirmación del broker
jdbc.update("UPDATE outbox SET published_at = now() WHERE id = ?", e.id());
}
}
}
FOR UPDATE SKIP LOCKED permite varias réplicas del relay sin publicar dos veces el mismo lote. Esperar la confirmación del broker antes de marcar la fila garantiza que no se pierdan eventos.
Configuración de Debezium con Outbox Event Router
{
"name": "outbox-originacion",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "pg-originacion",
"database.dbname": "originacion",
"table.include.list": "public.outbox",
"plugin.name": "pgoutput",
"transforms": "outbox",
"transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
"transforms.outbox.route.by.field": "aggregate_type",
"transforms.outbox.table.field.event.key": "aggregate_id",
"transforms.outbox.route.topic.replacement": "${routedByValue}-eventos"
}
}
Con CDC, la tabla outbox puede vaciarse inmediatamente después de insertar (Debezium lee del WAL), lo que evita que crezca.
Polling o CDC
| Criterio | Polling | CDC (Debezium) |
|---|---|---|
| Piezas nuevas | Ninguna (código del servicio) | Kafka Connect y conector |
| Latencia | Intervalo de polling | Milisegundos |
| Carga en la base de datos | Consultas periódicas | Lectura del WAL, slot de replicación |
| Orden global | Por created_at (cuidado con transacciones concurrentes) |
Orden de commit |
| Adecuado para | Un servicio, volumen moderado | Varios servicios, plataforma de eventos |
Inbox en el consumidor
CREATE TABLE inbox (event_id uuid PRIMARY KEY, procesado_en timestamptz NOT NULL DEFAULT now());
BEGIN;
INSERT INTO inbox (event_id) VALUES ($1) ON CONFLICT DO NOTHING; -- 0 filas = ya procesado
-- si se insertó: aplicar el efecto (asiento contable, notificación…)
COMMIT;
Outbox en el productor + inbox en el consumidor dan, en la práctica, un procesamiento efectivo de una sola vez. Ver idempotencia.
Criterios de implementación
Diseña el payload como contrato público y versiónalo (Avro, Protobuf o JSON Schema). Monitoriza el lag del outbox: filas pendientes y edad de la más antigua. Documenta la decisión en un ADR y combínalo con una saga cuando el proceso involucre varios servicios.
Continúa en las fuentes
Documentación oficial para verificar detalles y explorar más.