Regresar
106

Event-driven architecture: pub/sub y sus trampas

Actualizado: 14/09/2026

El correo de bienvenida llegó dos veces. El cobro se hizo una sola vez. Los dos salieron del mismo evento, publicado una sola vez, en el mismo instante.

No hay contradicción ahí: es el comportamiento normal de un sistema de eventos y, si nadie lo previó, el resultado es una bandeja de entrada molesta hoy y un cobro duplicado el día que toque al otro servicio.

Los eventos resuelven un problema real de acoplamiento. Lo que traen a cambio son un puñado de garantías que dejan de cumplirse y que casi nunca aparecen en la propuesta inicial.

Qué se está desacoplando

La idea es sencilla: en lugar de que un servicio llame a los demás cuando ocurre algo, anuncia que ocurrió y sigue con lo suyo. Quien tenga interés, escucha.

A la izquierda, pedidos llama directamente a correo, inventario y factura. A la derecha, pedidos publica un evento en un bus y los tres servicios se suscriben a él
Lo que cambia no es cuántas piezas hay, es quién tiene que conocer a quién.

La ganancia concreta está en el cuarto servicio. Con llamadas directas, sumar un servicio de analítica obliga a modificar el de pedidos, desplegarlo y arriesgarse a romperlo. Con eventos, el nuevo servicio se suscribe y pedidos no se entera.

La pérdida está en la misma frase: pedidos no se entera. Tampoco se entera de que el correo falló, ni de que la factura nunca se generó.

Evento y comando

Un evento dice que algo ya ocurrió: pedido.confirmado. Va en pasado, no espera respuesta y a quien lo publica no le importa quién lo escuche.

Un comando pide que algo ocurra: enviar_correo. Va dirigido a alguien concreto y suele esperar confirmación.

Publicar comandos disfrazados de eventos es el origen de buena parte de los líos: si al publicar esperas que alguien haga algo concreto, no desacoplaste nada, solo escondiste la llamada.

Trampa 1: la entrega no ocurre exactamente una vez

Es la que produjo el correo duplicado del principio, y la que más sorprende porque contradice lo que uno supone por defecto.

GarantíaQué significaQué te toca resolver
Como mucho una vezPuede perderse, nunca se duplicaDetectar lo que no llegó
Al menos una vezNunca se pierde, puede llegar repetidoQue repetirlo no haga daño
Exactamente una vezNi se pierde ni se duplicaExiste, pero con condiciones y costo

La mayoría de los servicios gestionados entregan al menos una vez, y eso es lo sensato: prefieren repetir antes que perder. Un reintento tras un tiempo de espera agotado, un reinicio del consumidor en mitad del procesamiento o una confirmación que se pierde bastan para que el mismo mensaje se procese dos veces.

Así se ve una entrega duplicada, que en los registros no parece un error:

  • 10:04:01

    Pedidos publica pedido.confirmado con identificador 8841. Una sola vez.

  • 10:04:02

    El servicio de correo lo recibe y envía el mensaje al cliente. Correctamente.

  • 10:04:32

    Antes de confirmar que terminó, el proceso se reinicia por un despliegue. El bus nunca recibió la confirmación.

  • 10:05:02

    El bus hace lo correcto según su contrato: vuelve a entregar el mensaje, porque para él quedó sin procesar.

  • 10:05:03

    Se envía el segundo correo. Nadie registró un error, porque no lo hubo.

La solución no es evitar los duplicados, es que dejen de importar. Un consumidor idempotente produce el mismo resultado la primera vez y la quinta:

function alConfirmarPedido(array $evento, PDO $db): void
{
    // El identificador viaja EN el evento, no se genera al recibirlo
    $id = $evento['id'];

    // Insertar y fallar es más seguro que consultar y luego insertar:
    // entre la consulta y la inserción caben dos entregas simultáneas
    $stmt = $db->prepare(
        'INSERT INTO eventos_procesados (id) VALUES (?)
         ON CONFLICT (id) DO NOTHING'
    );
    $stmt->execute([$id]);

    if ($stmt->rowCount() === 0) {
        return;                 // ya se procesó: no es un error, es lo normal
    }

    enviarCorreo($evento['cliente']);
}

El detalle de la restricción de unicidad importa: comprobar primero y escribir después deja una ventana en la que dos entregas simultáneas pasan ambas la comprobación. Dejar que la base de datos rechace el duplicado cierra esa ventana.

No evites los duplicados. Haz que den igual.

Trampa 2: el orden tampoco está garantizado

Dos eventos publicados en orden pueden procesarse al revés. Con varios consumidores en paralelo es lo esperable, no una anomalía.

El caso que rompe: se publica pedido.confirmado y, un segundo después, pedido.cancelado. Si el segundo se procesa primero, el pedido queda cancelado y luego confirmado. Activo, cobrado y camino al cliente.

Hay tres formas de manejarlo, y la elección no es técnica sino de negocio:

  • Ordenar por clave. Varios servicios permiten garantizar orden dentro de una misma clave, por ejemplo todos los eventos de un pedido. Se paga con menos paralelismo.
  • Incluir un número de versión en el evento y descartar lo que llegue con una versión anterior a la ya aplicada.
  • Diseñar estados que no dependan del orden: un pedido cancelado no vuelve a confirmarse, sin importar en qué orden lleguen los mensajes.

La tercera es la más robusta y la que menos se elige, porque obliga a pensar el modelo antes de escribir el consumidor.

Trampa 3: nadie sabe quién escucha

Con llamadas directas, para saber qué pasa cuando se confirma un pedido basta con leer el servicio de pedidos. Con eventos, esa información no está en ninguna parte del código.

El contrato existe aunque no esté escrito

Quien publica cree que el evento es suyo y lo cambia libremente: renombra un campo, quita otro que ya no usa. Tres servicios dejan de funcionar, y el fallo no aparece al desplegar sino la próxima vez que se publique ese evento.

Un evento publicado es una interfaz pública con los mismos deberes que una API: se versiona, se documenta y se amplía sin quitar campos.

Borrar un consumidor no borra su suscripción

Cuando un servicio deja de existir pero su suscripción sigue viva, los mensajes se acumulan en una cola que nadie lee. Eso consume almacenamiento y, en los servicios gestionados, dinero. Suele descubrirse meses después, al revisar una factura.

El flujo completo no existe en ningún sitio

Ningún archivo describe la secuencia completa de lo que ocurre al confirmar un pedido. Está repartida entre servicios que no se mencionan entre sí, y reconstruirla exige leerlos todos y confiar en que no falta ninguno.

Es la contrapartida directa del desacoplamiento: lo que ganas en independencia lo pierdes en visibilidad.

Trampa 4: depurar algo que nadie vio entero

Cuando una operación falla a mitad de camino, la pregunta es dónde se detuvo. Y no hay una traza única que responda: hay cinco registros separados, cada uno con su propia numeración.

Lo que lo hace manejable es un identificador de correlación que viaja con el evento y que cada servicio incluye en todo lo que registra:

{
  "id": "evt_01J9X2K",
  "tipo": "pedido.confirmado",
  "correlacion": "req_7f3a91",
  "version": 2,
  "ocurrido_en": "2026-09-14T10:04:01Z",
  "datos": { "pedido_id": 8841 }
}

Con ese campo, buscar req_7f3a91 devuelve la historia completa en los cinco servicios. Sin él, hay que cruzar marcas de tiempo a mano y confiar en que los relojes están sincronizados.

Conviene añadirlo desde el primer evento. Incorporarlo después obliga a tocar todos los consumidores a la vez, que es justamente lo que la arquitectura de eventos intentaba evitar.

Trampa 5: guardar y publicar no son una sola operación

Esta es la más silenciosa de todas, y la que rompe la confianza en los datos.

El código habitual hace dos cosas seguidas: escribe en la base y publica el evento. Parecen un solo paso, pero son dos sistemas distintos y nada garantiza que ocurran los dos.

$db->beginTransaction();
$pedidos->confirmar($id);
$db->commit();                    // ✓ el pedido quedó confirmado

$bus->publicar('pedido.confirmado', ['pedido_id' => $id]);
// ✗ si esto falla, el pedido está confirmado y nadie se entera nunca

Si el bus está caído o la red falla en ese instante, el pedido queda confirmado en la base y el correo no sale, la factura no se genera y el inventario no se descuenta. Nada registra un error: la operación principal terminó bien.

Invertir el orden no ayuda, solo cambia el síntoma: publicas primero y, si la escritura falla, hay tres servicios reaccionando a un pedido que no existe.

La solución conocida

Se llama bandeja de salida, y consiste en escribir el evento en una tabla de la misma base, dentro de la misma transacción que el cambio de negocio.

Si la transacción se confirma, el cambio y el evento quedan juntos. Si falla, no queda ninguno de los dos. Después, un proceso aparte lee esa tabla y publica al bus, reintentando hasta conseguirlo.

-- Una sola transacción: o quedan los dos, o no queda ninguno
BEGIN;
  UPDATE pedidos SET estado = 'confirmado' WHERE id = 8841;
  INSERT INTO eventos_salientes (tipo, datos, publicado)
    VALUES ('pedido.confirmado', '{"pedido_id":8841}', false);
COMMIT;

El precio es un pequeño retraso, el que tarde el proceso publicador en pasar. A cambio, deja de existir la posibilidad de que el sistema cambie de estado sin que nadie se entere, que es el fallo caro.

Y encaja con lo dicho antes: como el publicador reintenta hasta confirmar, puede publicar dos veces el mismo evento. Por eso el identificador propio y el consumidor idempotente no son opcionales, son la otra mitad de esta solución.

Qué pasa cuando un consumidor no puede procesar algo

Un evento con un dato corrupto, un servicio externo caído o un fallo en el código del consumidor producen la misma situación: el mensaje no se puede procesar. Y aquí es donde muchos sistemas se atascan de una forma peculiar.

Si el consumidor falla y el bus reintenta, vuelve a fallar. Y otra vez. El mismo mensaje se procesa en bucle, consumiendo recursos y, lo que es peor, bloqueando a los que vienen detrás si hay garantía de orden.

Las dos piezas que lo resuelven suelen quedar sin montar hasta el día del incidente:

  • Espera creciente entre reintentos. Reintentar de inmediato ante un servicio caído solo añade carga a algo que ya está en problemas. Cada intento espera más que el anterior, con un pequeño margen aleatorio para que todos los consumidores no reintenten a la vez.
  • Una cola para lo que no se pudo procesar. Tras un número acotado de intentos, el mensaje se aparta a una cola separada en lugar de reintentarse para siempre. El flujo sigue y el mensaje queda guardado para revisarlo.

Esa segunda cola solo sirve si alguien la mira. Sin una alerta cuando deja de estar vacía, es un cajón donde los errores se acumulan en silencio, que es exactamente la situación que se intentaba evitar.

Conviene además decidir qué hacer con lo que cae ahí. Un evento apartado no es un error que se descarta: representa algo que ocurrió de verdad en el negocio y que quedó a medias. Reprocesarlo tras arreglar la causa es parte del diseño, no una tarea de emergencia.

Cuándo no usar eventos

La regla práctica es corta: si necesitas la respuesta para continuar, no es un evento.

Validar un pago, comprobar si queda inventario antes de confirmar o autenticar a alguien son operaciones donde el resultado condiciona lo siguiente. Convertirlas en eventos añade complejidad para simular una espera que con una llamada directa era gratis.

Tampoco compensan cuando hay un solo consumidor y no se prevén más: un bus entre dos servicios es infraestructura que mantener, y la llamada directa dice exactamente quién habla con quién.

Y hay un caso intermedio que se pasa por alto: se puede publicar eventos sin bus. Una tabla en la base de datos que registre lo ocurrido, y un proceso que la lea cada pocos segundos, da desacoplamiento, reintentos e historial, con la infraestructura que ya tienes. Es suficiente durante mucho más tiempo del que suele suponerse, y el salto a un bus real es directo cuando llegue.


Guía de referencia intermedio