One self-hosted console to run your entire business — commerce, ERP, HRM, CRM & manufacturing

Arquitectura orientada a eventos: por qué tu pedido debería avisarle solo al inventario y a la contabilidad

Las sincronizaciones nocturnas y las llamadas enredadas desfasan inventario y cuentas. Mira cómo los eventos, el transactional outbox y los listeners idempotentes hacen que un pedido actualice inventario, libro contable y lealtad, con un ejemplo.

Author

Anichur Rahaman

hace 2 meses11 min read3 views
Arquitectura orientada a eventos: por qué tu pedido debería avisarle solo al inventario y a la contabilidad

En el fin de semana de rebajas, una cadena de seis tiendas recibió 1,900 pedidos. El lunes por la mañana, su responsable de finanzas abre los reportes: la hoja de inventario dice que quedan 14 chamarras, pero la sucursal de la ciudad vecina vendió la última el sábado. Los libros están peor: los ingresos se contabilizan con un proceso nocturno, ese proceso agotó su tiempo en el pedido 1,412 y 488 pedidos no aparecen en la contabilidad. Nadie se enterará hasta la conciliación del martes.

En esta historia nadie se equivocó. Es el sistema el que obliga a cada área a enterarse del pedido por su cuenta: consultando cada tanto, exportando o recibiendo una llamada en el momento equivocado. Este artículo defiende lo contrario: cuando algo ocurre, regístralo una sola vez como un hecho y deja que cada parte del negocio reaccione a ese hecho por su cuenta. Eso es la arquitectura orientada a eventos.

Es también la parte del diseño de sistemas a la que he dedicado la mayor parte de mi carrera, así que espera opiniones firmes. La meta es que al terminar puedas distinguir un sistema realmente orientado a eventos de una promesa de marketing, y sepas qué pedir.

Por qué fallan las sincronizaciones nocturnas y las llamadas enredadas

La mayoría del software de negocio empieza con una llamada directa. Termina el pago, así que el código del pago llama al código de inventario, luego al de contabilidad, luego al de correo. Funciona en una demostración y se rompe de tres maneras previsibles.

  • El paso más lento retiene al cliente. Si el servicio de correo tarda ocho segundos, el comprador espera ocho segundos, o el pedido falla porque el correo estaba caído.
  • Cada necesidad nueva modifica código viejo. Agregar puntos de lealtad obliga a abrir el archivo más peligroso de la empresa, el del pago, y sumar una llamada más.
  • El trabajo a medias no tiene dueño. El inventario bajó y luego falló contabilidad. Nada registra que el segundo paso sigue pendiente.

El parche habitual es la sincronización nocturna: exportar pedidos a las 2 a. m. e importarlos en otro lado. Eso traslada la falla a las horas en que nadie vigila y deja cada cifra con hasta 24 horas de retraso. Entonces los equipos arman hojas de conciliación para perseguir las diferencias, y la hoja termina siendo el sistema real.

Comandos y eventos: el vocabulario que importa

Dos palabras hacen casi todo el trabajo, y confundirlas es el primer error de diseño más común.

Un comando es una petición: «registra este pedido», «reembolsa este pago». Va dirigido a un solo responsable, está en presente y se puede rechazar. Un evento es un hecho: OrderPlaced, PaymentReceived, ParcelDelivered. Se nombra en pasado porque ya ocurrió, y nadie puede rechazarlo. La parte del sistema dueña del hecho es el productor (producer). Todo lo que le interese es un listener (también llamado consumidor o suscriptor).

El productor no sabe quién escucha. Esa propiedad es todo el beneficio. Agregar puntos de lealtad significa escribir un listener nuevo para ParcelDelivered. El pago no se toca, así que no puede romperse.

Un pedido, tres eventos, cinco listeners

Un ejemplo ilustrativo lo vuelve concreto. Un cliente compra dos chamarras, SKU JKT-M, a 40.00 cada una (costo unitario 22.00), más 5.00 de envío y 8.00 de impuestos. El total es 93.00, pagado con tarjeta. En los tres días siguientes el pedido genera tres eventos.

Diagrama de un evento OrderPlaced que se reparte entre los listeners de inventario, libro contable, lealtad, notificaciones y entrega
Un hecho, cinco reacciones independientes. El pago no llama a ninguna de ellas.

Cada evento lleva un payload pequeño, suficiente para que el listener actúe sin volver a preguntarle nada al productor.

EventoCampos del payload
OrderPlacedevent_id evt_5001, order_id 1042, customer_id 77, lines [JKT-M, qty 2, price 40.00, cost 22.00], shipping 5.00, tax 8.00, total 93.00, USD, location WH-1, occurred_at
PaymentReceivedevent_id evt_5002, order_id 1042, payment_id 9001, amount 93.00, method card, occurred_at
ParcelDeliveredevent_id evt_5003, order_id 1042, delivery_id 311, delivered_at, lines [JKT-M, qty 2]

Ahora lo que le importa al dueño: qué filas existen al final y quién las escribió.

EventoListenerFilas escritas
OrderPlacedInventariostock_movements: JKT-M, WH-1, type reserve, qty 2, ref order 1042. Las existencias siguen en 48, reservado +2, disponible 46
OrderPlacedNotificacionesnotifications: confirmación del pedido 1042 al cliente 77, status queued
PaymentReceivedLibro contableDébito Card clearing 93.00; crédito Customer deposits 93.00
ParcelDeliveredInventariostock_movements: type sale, qty -2, reserva liberada. Existencias 46
ParcelDeliveredLibro contableDébito Customer deposits 93.00; crédito Sales 80.00, Shipping income 5.00, Tax payable 8.00. Débito Cost of goods sold 44.00; crédito Inventory 44.00
ParcelDeliveredLealtadloyalty_entries: cliente 77, +80 puntos (1 por unidad monetaria de mercancía), ref order 1042
ParcelDeliveredNotificacionesnotifications: aviso de entrega e invitación a opinar, status queued

Revisa la aritmética: el asiento de la entrega tiene débitos de 93.00 + 44.00 = 137.00 y créditos de 80.00 + 5.00 + 8.00 + 44.00 = 137.00. Cuadra porque el listener se escribió para registrar asientos balanceados, no porque alguien lo revisó al cierre del mes. Aquí el ingreso se reconoce al entregar; tu política contable puede ser distinta, y esa decisión le corresponde solo al listener del libro contable.

El outbox: cómo un evento nunca se pierde

Hay una trampa en el productor. Registrar un pedido implica dos escrituras: guardar el pedido en la base de datos y publicar OrderPlaced en la cola. Son dos sistemas distintos, así que ninguna transacción cubre ambos. Si la base confirma y el proceso muere antes de publicar, el pedido existe y el evento no; inventario nunca se entera. Si publicas primero y la base revierte, el resto reacciona a un pedido que jamás se guardó. Es el problema de la doble escritura (dual-write).

La solución es el transactional outbox, descrito por Chris Richardson en su catálogo de patrones de microservicios. El pedido y su evento se escriben en la misma base de datos y en la misma transacción, el evento en una tabla outbox. O existen las dos filas o no existe ninguna. Un proceso aparte, el relay, lee las filas del outbox sin enviar, las publica en la cola y las marca como enviadas.

Diagrama del patrón outbox: una transacción escribe el pedido y una fila de outbox, el relay publica en la cola y los listeners idempotentes consumen
Una transacción, dos filas. El relay lleva la segunda a la cola, las veces que haga falta.

El relay también puede caerse entre publicar y marcar la fila como enviada. Al reiniciar, publica el evento otra vez. Así que el outbox te da algo preciso: el evento nunca se pierde, y puede llegar más de una vez. De ahí sale el siguiente punto.

«Exactamente una vez» es, en realidad, al menos una vez más listeners idempotentes

A los proveedores les encanta prometer entrega exactamente una vez. Entre máquinas separadas por una red, en general no se puede: un emisor que no recibe confirmación no sabe si el mensaje llegó, así que debe elegir entre reenviar o arriesgar una pérdida. Los sistemas confiables eligen reenviar. Los proveedores de pagos lo dicen abiertamente; la documentación de Stripe, por ejemplo, te pide esperar el mismo evento de webhook más de una vez y deduplicar por el ID del evento.

Por eso, lo que de verdad se puede garantizar es entrega al menos una vez más procesamiento idempotente. Un listener es idempotente cuando procesar el mismo evento dos veces tiene el mismo efecto que procesarlo una. El mecanismo habitual es pequeño: una tabla processed_events con una llave única sobre (listener, event_id).

Diagrama de flujo de un listener idempotente: llega el evento, ¿ya se procesó?, omitir o aplicar el cambio, registrar el ID, reintento con espera creciente, cola de mensajes fallidos y alerta
Lo que hace un listener con cada evento, incluido el que ya había visto.

Dos detalles deciden si esto funciona. Primero, los pasos «aplicar el cambio» y «registrar el ID del evento» deben confirmarse en la misma transacción de base de datos. Si aplicas y luego registras en dos pasos, una caída entre ambos produce un doble asiento al reintentar. Segundo, la llave única debe volver atómica la verificación, para que dos copias del mismo evento que llegan al mismo tiempo no puedan pasar las dos.

Vuelve al ejemplo: si ParcelDelivered llega dos veces, la segunda ejecución del listener contable encuentra evt_5003 ya registrado y lo omite. El asiento no se registra dos veces, y los 80 puntos de lealtad no se vuelven 160.

Orden, reintentos y el historial que obtienes gratis

Orden

Los eventos de pedidos distintos pueden procesarse en cualquier orden. Los del mismo pedido, no: procesar RefundIssued antes que PaymentReceived no tiene sentido. Dos defensas trabajan juntas. Enruta los eventos por ID de pedido para que los de un pedido viajen en secuencia por un solo carril, y dale a cada evento un número de secuencia por pedido para que el listener reconozca uno demasiado viejo y lo aparte o lo ignore.

Reintentos y la cola de mensajes fallidos

Cuando un listener falla, porque la base estaba ocupada o la API de la paquetería estaba caída, el evento vuelve para otro intento con esperas crecientes, por ejemplo 10 segundos, 1 minuto, 5 minutos, 30 minutos, 2 horas. La espera creciente (backoff) importa: reintentar de inmediato convierte una caída corta en una sobrecarga provocada por uno mismo. Tras un número fijo de intentos, digamos cinco, el evento pasa a una cola de mensajes fallidos (dead-letter queue) y se avisa a alguien. Un mensaje fallido se ve y se puede recuperar. Compáralo con una exportación nocturna que falla, que simplemente no está.

El historial

Como cada cambio del negocio es un evento con hora, ID y payload, obtienes una pista de auditoría sin construirla. «¿Por qué este cliente tiene 80 puntos?» tiene respuesta: ParcelDelivered evt_5003, procesado por Lealtad a una hora concreta. Conserva los eventos durante un periodo definido y, cuando un listener tenga un error, corrígelo y reproduce (replay) los eventos afectados. La idempotencia hace segura la reproducción.

Cuándo no usarlo

El diseño orientado a eventos agrega piezas móviles: una cola, un relay, workers, monitoreo y el hábito de pensar en consistencia eventual. Para un sitio de presentación, una herramienta de un solo usuario o un panel CRUD simple donde se actualiza una tabla y una pantalla la lee, una llamada directa a una función es más simple y mejor. Lo mismo aplica a un paso que necesita la respuesta ahora, como «¿es válida esta tarjeta?». Eso es un comando con respuesta, no un evento.

El umbral que uso: cuando una acción tiene tres o más consecuencias independientes en manos de equipos o módulos distintos (inventario, dinero, mensajes, entrega), los eventos empiezan a pagarse solos.

También hay que ser honestos con el costo. Los listeners corren un instante después del pedido, así que una pantalla que lee el inventario justo después del pago puede mostrar por un momento la cifra anterior. Los buenos productos lo resuelven haciendo la reserva dentro de la transacción del pago y mostrando «procesando» en lugar de adivinar.

Cómo llegar ahí y cómo saber que funciona

Pasos

  1. Haz una lista de los hechos de los que tu negocio ya habla: pedido registrado, pago recibido, paquete entregado, devolución aprobada, inventario contado. Nombra cada uno en pasado.
  2. Define el payload de cada evento con campos suficientes para que el listener nunca tenga que volver a preguntar, y dale a cada evento un ID único y una marca de tiempo.
  3. Escribe los eventos en una tabla outbox dentro de la misma transacción que el cambio que describen.
  4. Ejecuta un relay que publique las filas del outbox en una cola y las marque como enviadas.
  5. Construye cada listener como idempotente, con el registro de eventos procesados en la misma transacción que sus escrituras.
  6. Agrega reintentos con espera creciente, una cola de mensajes fallidos y una alerta sobre ella antes del primer pedido real.
  7. Migra los consumidores de uno en uno: primero inventario, luego el libro contable, luego mensajes y lealtad. Apaga el proceso nocturno al final, después de una semana de cifras que coinciden.

Preguntas para un proveedor

  • ¿El evento se escribe en la misma transacción que el cambio de negocio, o se publica después?
  • ¿Qué pasa si el mismo evento llega dos veces? Pide que te muestren la llave de deduplicación.
  • ¿A dónde van los eventos fallidos, a quién se avisa y puedo reproducirlos?
  • ¿Puedo ver el historial de eventos de un pedido, con marcas de tiempo y el handler que procesó cada uno?

Una forma de comprobar estas afirmaciones en un sistema real: una plataforma autoalojada como StoreConsole maneja sus módulos de inventario, contabilidad y lealtad como listeners separados sobre los mismos eventos del pedido.

Qué medir

  • Retraso del outbox: antigüedad de la fila sin enviar más vieja. Unos segundos es saludable.
  • Profundidad de la cola y tiempo de procesamiento por listener.
  • Cantidad de mensajes fallidos: debe estar cerca de cero y nunca crecer en silencio.
  • Tasa de omisión por duplicado: una cifra mayor que cero demuestra que la deduplicación funciona.
  • Revisión diaria de desvío: unidades del libro de inventario contra las unidades que implican los pedidos, e ingresos del libro contable contra ingresos de los pedidos. Ambas diferencias deben ser cero.

El mismo lunes, reconstruido

Volvamos a la responsable de finanzas con sus 1,900 pedidos. Cada pedido escribió su evento en la misma transacción que la venta, así que hay 1,900 filas en el outbox y ninguna se perdió. En el pedido 1,412, el listener contable agotó su tiempo. Los pedidos del 1,412 al 1,900 esperaron en la cola, reintentaron con espera creciente y quedaron registrados en minutos. Dos pedidos fallaron cinco veces por un código de impuesto erróneo, llegaron a la cola de mensajes fallidos y activaron una alerta el sábado por la tarde, no el martes.

La venta del sábado en la sucursal vecina reservó inventario en el instante en que ocurrió, así que las 14 chamarras eran de verdad 14. La revisión de desvío del lunes marca cero en las dos columnas. La hoja de conciliación ni se abre.

Ideas clave

  • Un comando pide y se puede rechazar; un evento declara un hecho que ya ocurrió. Los productores anuncian eventos y no saben quién escucha.
  • Escribe el evento en la misma transacción que el cambio de negocio (el outbox) para que nunca se pierda.
  • La entrega exactamente una vez no es una promesa realista. Construye entrega al menos una vez con listeners idempotentes.
  • Registra el ID del evento procesado en la misma transacción que las escrituras del listener.
  • Reintenta con espera creciente y después manda a la cola de mensajes fallidos y avisa. Una falla visible es mejor que un hueco silencioso.
  • Úsalo cuando una acción tenga tres o más consecuencias independientes. Déjalo fuera en un CRUD simple.

Anichur Rahaman es arquitecto de software y creador de StoreConsole. Diseña sistemas de comercio y ERP para negocios en crecimiento, con enfoque en arquitectura orientada a eventos, integridad de datos y operación en servidores propios.

About the Author

Anichur Rahaman

Continue Reading