Guía técnica

Arquitectura Event-Driven en la Práctica: Qué Sale Realmente Mal

Patrones reales de arquitectura event-driven en producción. Event storms, loops de sincronización bidireccional, dead letters, idempotencia y elección entre Kafka, RabbitMQ, BullMQ y Symfony Messenger.

23 de febrero de 202618 min de lecturaEquipo de Ingeniería Oronts

El Event Storm que Nadie Espera

La arquitectura event-driven se ve limpia en una pizarra. El servicio A emite un evento. El servicio B lo consume. Acoplamiento mínimo. Escalado independiente. Diagramas bonitos.

Entonces lo desplegás a producción. Un guardado de producto dispara 8 event subscribers. Cada subscriber despacha 3 mensajes asíncronos. Cada message handler carga el producto, modifica un campo y guarda. Cada guardado dispara los 8 subscribers de nuevo. En segundos, tu cola de mensajes tiene miles de mensajes para una sola actualización de producto, tus workers están saturados y tu base de datos sufre contención de locks.

Hemos operado cuatro sistemas event-driven distintos en producción. Cada uno usaba un message broker diferente. Cada uno tenía patrones de fallo distintos. Este artículo cubre lo que realmente sale mal y cómo solucionarlo. Para más contexto sobre cómo abordamos la arquitectura de sistemas, esa guía cubre nuestra metodología.

Los Cuatro Patrones de Fallo

Todo sistema event-driven eventualmente se encuentra con estos:

PatrónQué PasaSeveridad
Event stormUna acción genera cascada de miles de mensajesCrítica (puede saturar la infraestructura)
Loop de sincronización bidireccionalSistema A sincroniza con B, B sincroniza de vuelta con A, loop infinitoCrítica (crecimiento infinito de mensajes)
Procesamiento duplicadoMismo mensaje procesado dos veces, crea datos duplicadosAlta (integridad de datos)
Acumulación de dead lettersMensajes fallidos se acumulan sin estrategia de resoluciónMedia (deuda operativa)

1. Event Storms

El patrón más común y más peligroso. Ocurre cuando los event handlers disparan nuevos eventos, y esos eventos disparan más handlers.

Product save
  -> EventSubscriber A dispatches MessageA
  -> EventSubscriber B dispatches MessageB
  -> EventSubscriber C dispatches MessageC
  -> ...8 subscribers en total

WorkerA processes MessageA
  -> Modifica product, llama save()
  -> Product save dispara 8 subscribers de nuevo
  -> Cada uno despacha más mensajes

WorkerB processes MessageB (mismo patrón)
  -> 8 mensajes más

Resultado: 1 save -> 8 mensajes -> 8 saves -> 64 mensajes -> ...

La solución: EventSubscriberSupervisor. Un control a nivel de proceso que deshabilita los event subscribers durante los guardados del worker.

// Handler del worker: deshabilitar subscribers antes de guardar
async function handleGenerateThumbnail(message: ThumbnailMessage) {
    const product = await productService.findById(message.productId);

    subscriberSupervisor.disableAll();
    try {
        product.thumbnail = await generateThumbnail(product);
        await product.save(); // No se disparan subscribers, no se despachan mensajes nuevos
    } finally {
        subscriberSupervisor.enableAll();
    }
}

El orden importa. El guardado debe ocurrir dentro del scope deshabilitado. Si rehabilitás los subscribers antes de guardar, el guardado dispara la cascada.

Para una mirada más profunda sobre cómo aplicamos este patrón específicamente en Pimcore, consulta nuestra guía de diseño de workflows en Pimcore.

2. Loops de Sincronización Bidireccional

Cuando dos sistemas sincronizan datos bidireccionalmente, cada actualización de A dispara una sincronización hacia B, que dispara una sincronización de vuelta a A. Sin prevención de loops, esto corre para siempre.

System A (Commerce)         System B (Operations)
     │                           │
     │  Customer updated         │
     │ ────────────────────────▶ │
     │                           │  Sync recibido, guardar customer
     │                           │  Customer updated event se dispara
     │  Sync recibido            │
     │ ◀──────────────────────── │
     │  Guardar customer         │
     │  Customer updated event   │
     │ ────────────────────────▶ │
     │           ...para siempre...   │

La solución: source tracking. Cada mensaje lleva un header x-source que identifica el sistema de origen. Cuando un sistema recibe un mensaje de sí mismo (a través del otro sistema), lo ignora.

// Al publicar un mensaje de sincronización
async function publishCustomerSync(customer: Customer, source: string) {
    await messageQueue.publish('customer.updated', {
        customerId: customer.id,
        data: customer,
        source: source,  // "commerce" o "operations"
    });
}

// Al consumir un mensaje de sincronización
async function handleCustomerSync(message: CustomerSyncMessage) {
    // Ignorar mensajes que se originaron en este sistema
    if (message.source === THIS_SYSTEM_ID) {
        logger.debug('Ignorando sync auto-originado', { customerId: message.customerId });
        return; // ACK el mensaje, no procesar
    }

    const customer = await customerService.update(message.customerId, message.data);

    // Al guardar, marcar el source para que los eventos downstream lo lleven
    await publishCustomerSync(customer, THIS_SYSTEM_ID);
}

Un enfoque alternativo: deduplicación de mensajes por hash de contenido. Calculás el hash del payload del mensaje y verificás si el mismo hash fue procesado recientemente. Si los datos no cambiaron, la sincronización es un no-op.

3. Procesamiento Duplicado

Fallos de red, reinicios de workers y las garantías de entrega at-least-once significan que el mismo mensaje puede ser entregado más de una vez. Sin idempotencia, obtenés pedidos duplicados, emails duplicados, registros de base de datos duplicados.

La solución: idempotency stores con deduplicación por clave de negocio.

// Idempotency store con clave de negocio
class IdempotencyStore {
    async checkAndAcquire(key: string, scope: string): Promise<boolean> {
        try {
            await this.db.insert('idempotency_keys', {
                key,
                scope,
                status: 'PROCESSING',
                created_at: new Date(),
                expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000), // TTL de 24h
            });
            return true; // Adquirido, proceder con el procesamiento
        } catch (error) {
            if (isDuplicateKeyError(error)) {
                return false; // Ya procesado, omitir
            }
            throw error;
        }
    }

    async complete(key: string, scope: string): Promise<void> {
        await this.db.update('idempotency_keys',
            { status: 'COMPLETED', completed_at: new Date() },
            { key, scope }
        );
    }
}

// Uso en un worker
async function handleNotification(message: NotificationMessage) {
    const dedupeKey = `${message.recipientEmail}:${message.category}:${message.entityRef}:${todayBucket()}`;

    const acquired = await idempotencyStore.checkAndAcquire(dedupeKey, 'notification');
    if (!acquired) {
        logger.info('Notificación duplicada omitida', { dedupeKey });
        return; // ACK el mensaje
    }

    try {
        await emailService.send(message);
        await idempotencyStore.complete(dedupeKey, 'notification');
    } catch (error) {
        await idempotencyStore.fail(dedupeKey, 'notification');
        throw error; // NACK, dejar que la cola reintente
    }
}

La clave de deduplicación debe tener significado de negocio. Para notificaciones: destinatario + categoría + entidad + bucket del día (evita enviar la misma notificación dos veces en un día pero lo permite al día siguiente). Para pedidos: tenant + producto + fecha. Para importaciones: ID del registro fuente + ID del batch de importación.

4. Manejo de Dead Letters

Los mensajes que fallan repetidamente terminan en una dead letter queue. La mayoría de los equipos configuran la DLQ y después la ignoran. Las dead letters se acumulan. Meses después, alguien descubre miles de mensajes sin procesar.

// Handler de dead letters con clasificación
async function processDeadLetter(message: DeadLetterMessage) {
    const failureType = classifyFailure(message.error);

    switch (failureType) {
        case 'TRANSIENT':
            // Timeout de red, servicio temporalmente caído
            // Re-encolar con backoff exponencial
            await requeue(message, { delay: calculateBackoff(message.retryCount) });
            break;

        case 'PERMANENT':
            // Datos inválidos, mismatch de esquema, violación de regla de negocio
            // Loguear, alertar, archivar. No reintentar.
            await archiveDeadLetter(message);
            await alertOps('Fallo permanente en dead letter', message);
            break;

        case 'POISON':
            // El mensaje mismo causa crashes (payload malformado)
            // Archivar inmediatamente, nunca reintentar
            await archiveDeadLetter(message);
            await alertOps('Mensaje envenenado detectado', message);
            break;
    }
}

function classifyFailure(error: string): 'TRANSIENT' | 'PERMANENT' | 'POISON' {
    if (error.includes('ECONNREFUSED') || error.includes('timeout')) return 'TRANSIENT';
    if (error.includes('SyntaxError') || error.includes('Cannot parse')) return 'POISON';
    return 'PERMANENT';
}

El insight clave: no todas las dead letters son iguales. Los fallos transitorios deben reintentarse. Los fallos permanentes deben archivarse e investigarse. Los mensajes envenenados deben ponerse en cuarentena inmediatamente.

Para cómo manejamos patrones de fallo en sistemas de IA específicamente, consulta nuestra guía de modos de fallo en IA.

Eligiendo un Message Broker

Hemos usado cuatro brokers distintos en producción. Cada uno tiene un punto fuerte específico.

BrokerIdeal ParaNo Tan Bueno ParaComplejidad de Despliegue
KafkaStreaming de eventos de alto throughput, event sourcing, replayColas de trabajos simples, sistemas de bajo volumenAlta (ZooKeeper/KRaft, particiones, consumer groups)
RabbitMQRouting, dead letters, colas con prioridad, topologías complejasThroughput muy alto (millones/seg), replay de eventosMedia (clustering, políticas HA)
BullMQ (Redis)Colas de trabajos, trabajos con delay, rate limiting, setups simplesRouting complejo, replay de mensajes, patrones multi-consumerBaja (solo Redis)
Symfony MessengerApps PHP/Symfony, Pimcore, dispatch asíncrono simpleEcosistemas no-PHP, features complejas de brokerBaja (usa Doctrine, AMQP o Redis como transporte)

Cuándo Usar Kafka

  • Necesitás replay de eventos (los consumers pueden releer desde cualquier offset)
  • Tenés múltiples consumer groups procesando los mismos eventos de forma diferente
  • El throughput supera los 100K mensajes por segundo
  • Estás haciendo event sourcing o construyendo un pipeline de change data capture
  • Necesitás orden garantizado dentro de una partición

Cuándo Usar RabbitMQ

  • Necesitás routing complejo (topic exchanges, routing basado en headers)
  • Dead letter queues con políticas de reintento automático son importantes
  • La prioridad de mensajes importa (mensajes urgentes procesados primero)
  • Necesitás patrones request-reply (RPC sobre mensajes)
  • Tu sistema tiene múltiples servicios en distintos lenguajes

Cuándo Usar BullMQ

  • Estás en un ecosistema Node.js/TypeScript (Vendure, NestJS)
  • Scheduling de trabajos con delays y patrones cron
  • Rate limiting por cola o por tipo de trabajo
  • Ya tenés Redis para caching y sesiones
  • Tu sistema es de escala pequeña a media (menos de 10K mensajes/seg)

Cuándo Usar Symfony Messenger

  • Estás en un ecosistema PHP/Symfony/Pimcore
  • Necesitás dispatch asíncrono simple para tareas en background
  • La infraestructura de workers sigue convenciones de Symfony (supervisord)
  • Flexibilidad de transporte (cambiar entre Doctrine, AMQP, Redis sin cambios de código)

Para nuestros proyectos en Pimcore, usamos Symfony Messenger con transporte RabbitMQ. Para proyectos Vendure, usamos BullMQ. Para plataformas de ingesta de datos de alto throughput, usamos Kafka. La elección del broker sigue al ecosistema, no al revés.

Orden de Mensajes

La mayoría de los equipos asumen que el orden de los mensajes importa. Generalmente no es así.

Escenario¿Se Necesita Orden?Por Qué
Enviar email de notificaciónNoLos emails son inherentemente desordenados
Generar thumbnailNoLa última versión gana
Actualizar índice de búsquedaNoEl último estado gana (consistencia eventual)
Procesar pasos de pagoEl cargo debe ocurrir antes de la captura
Transiciones de estado de pedidoNo se puede enviar antes de confirmar el pago
Replay de event sourcingLos eventos deben reproducirse en orden causal

Cuando el orden importa, usá colas FIFO (SQS FIFO, particiones de Kafka con clave por entity ID, RabbitMQ con un solo consumer). Cuando no importa, el procesamiento paralelo es más rápido y simple.

// Kafka: orden dentro de una partición (keyed por product ID)
await producer.send({
    topic: 'product-updates',
    messages: [{
        key: productId,  // Todas las actualizaciones del mismo producto van a la misma partición
        value: JSON.stringify(event),
    }],
});

// BullMQ: no se necesita orden para thumbnails
await thumbnailQueue.add('generate', { productId }, {
    removeOnComplete: true,
    attempts: 3,
    backoff: { type: 'exponential', delay: 5000 },
});

Estrategias de Reintento

No todos los reintentos son iguales. La estrategia depende del tipo de fallo.

EstrategiaCuándo UsarEjemplo
Reintento inmediatoGlitch transitorio (race condition)Fallo de optimistic lock
Backoff exponencialServicio temporalmente caídoTimeout de API externa
Delay fijoRate limitingLa API devuelve 429
Sin reintentoFallo permanentePayload inválido, violación de regla de negocio
// Configuración de reintentos en BullMQ
const queue = new Queue('notifications', {
    defaultJobOptions: {
        attempts: 5,
        backoff: {
            type: 'exponential',
            delay: 60000, // 1min, 2min, 4min, 8min, 16min
        },
        removeOnComplete: { count: 1000 },
        removeOnFail: false, // Mantener para análisis de dead letters
    },
});

Después de agotar todos los reintentos, el mensaje pasa a una dead letter queue. Nunca reintentes infinitamente. Establecé un conteo máximo de intentos y manejá el fallo explícitamente.

Monitoreo de Sistemas Event-Driven

La parte más difícil de los sistemas event-driven es el debugging. Cuando algo falla, el error puede estar a 5 servicios y 12 mensajes de distancia de la causa original.

Correlation IDs

Cada mensaje lleva un correlation ID que lo vincula a la acción original:

// La request API original genera el correlation ID
const correlationId = generateUUID();

// Cada mensaje en la cadena lo lleva
await queue.add('process-order', {
    orderId: order.id,
    correlationId,
});

// Los workers lo pasan a los mensajes downstream
async function handleProcessOrder(job) {
    const { orderId, correlationId } = job.data;

    // ... procesar pedido ...

    // Los mensajes downstream llevan el mismo correlation ID
    await notificationQueue.add('send-confirmation', {
        orderId,
        correlationId, // Mismo ID, trazable hasta la request original
    });
}

Al debuguear, consultá todos los logs y eventos por correlation ID para ver la cadena completa. Para más sobre patrones de trazabilidad distribuida, consulta nuestra guía de observabilidad de IA.

Métricas de Salud de Colas

MétricaSaludableAdvertenciaCrítico
Profundidad de cola< 100100-1000> 1000
Tiempo de procesamiento (p95)< 5s5-30s> 30s
Tasa de fallos< 1%1-5%> 5%
Conteo de dead letters01-10> 10
Consumer lag (Kafka)< 10001K-10K> 10K

Alertá sobre tendencias en la profundidad de cola, no sobre valores absolutos. Una profundidad de cola de 500 que lleva horas estable está bien. Una profundidad de cola de 50 que era 0 hace una hora y está creciendo es un problema.

Errores Comunes

  1. Sin control de subscribers durante guardados del worker. La fuente principal de event storms. Los guardados del worker no deben disparar los mismos event subscribers que despachan trabajo hacia los workers.

  2. Sin source tracking en sincronización bidireccional. Sin headers x-source, la sincronización bidireccional entre dos sistemas genera loops infinitos.

  3. Usar message ID para idempotencia. Los message IDs cambian con la re-entrega en algunos brokers. Usá claves de negocio (entity ID + acción + bucket de tiempo) en su lugar.

  4. Reintentos infinitos. Establecé un conteo máximo de reintentos. Después del agotamiento, mové a dead letter queue. Nunca reintentes para siempre.

  5. Ignorar dead letter queues. Las dead letters son deuda operativa. Clasificalas (transitoria, permanente, envenenada) y manejá cada tipo de forma diferente.

  6. Asumir orden de mensajes. La mayoría de las cargas de trabajo no necesitan orden. El procesamiento paralelo es más rápido. Solo usá FIFO cuando las transiciones de estado o el orden causal lo requieran.

  7. Misma estrategia de reintento para todos los fallos. Un timeout necesita backoff exponencial. Un payload inválido necesita cero reintentos. Un rate limit necesita delay fijo.

  8. Sin correlation IDs. Sin ellos, debuguear una cadena de fallos a través de 5 servicios es imposible.

Conclusiones Clave

  • Los event storms son el modo de fallo más peligroso. Una cascada de guardados descontrolada puede saturar toda tu infraestructura. El EventSubscriberSupervisor no es opcional.

  • La sincronización bidireccional necesita source tracking. Cada mensaje lleva el ID del sistema de origen. Cuando recibís un mensaje de vos mismo (a través del otro sistema), ignoralo.

  • La idempotencia usa claves de negocio, no message IDs. Destinatario + categoría + entidad + bucket del día para notificaciones. Tenant + producto + fecha para pedidos. La clave debe tener significado de negocio.

  • Las dead letters necesitan clasificación. Los fallos transitorios se reintentan. Los fallos permanentes se archivan. Los mensajes envenenados se ponen en cuarentena. Nunca ignores la dead letter queue.

  • El broker sigue al ecosistema. Kafka para streaming de alto throughput, RabbitMQ para routing complejo, BullMQ para colas de trabajo en Node.js, Symfony Messenger para PHP. No elijas un broker y después construyas alrededor de él.

  • La mayoría de las cargas de trabajo no necesitan orden. El procesamiento paralelo es más simple y rápido. Usá FIFO solo cuando las transiciones de estado lo demanden.

Aplicamos estos patrones en nuestros proyectos de software a medida, pipelines de ingeniería de datos y despliegues en la nube. Si estás construyendo un sistema event-driven o debugueando uno que ya está en problemas, hablá con nuestro equipo o solicitá un presupuesto.

Temas cubiertos

arquitectura event-drivencolas de mensajes producciónevent sourcingCQRS producciónRabbitMQ vs KafkaBullMQevent stormsidempotenciadead letter queuesincronización bidireccional

¿Listo para construir sistemas de IA listos para producción?

Nuestro equipo se especializa en sistemas de IA listos para producción. Hablemos de cómo podemos ayudar.

Iniciar una conversación