En mi post anterior expliqué los componentes básicos para implementar arquitectura basada en eventos (EDA) con servicios gestionados de AWS. Este post trata sobre lo básico avanzado. Es avanzado porque, según mi experiencia como consultor, muy pocas empresas aplican las prácticas de las que quiero hablar. Aun así, las sigo considerando parte de lo básico, porque no son opcionales: son esenciales para implementar sistemas confiables basados en mensajería.
Aunque los ejemplos de este post usan servicios de AWS, el contenido aplica a cualquier sistema, sin importar la infraestructura sobre la que corra. La implementación puede variar, pero los principios de fondo son los mismos.
Como este post trata sobre un patrón —y los patrones, por definición, son soluciones repetibles a problemas frecuentes—, quiero empezar por un problema que suele pasarse por alto en los sistemas basados en EDA.
El problema
En el post anterior sugerí usar AWS SNS para publicar mensajes en un sistema basado en eventos. Este es un ejemplo típico de cómo se hace esa publicación:
...
sns.publish(
TopicArn=users_topic_arn,
Message=json.dumps({
'event_type': 'user_registered',
'event_id': str(uuid.uuid4()),
'user_id': 'USER12345',
'name': 'John Doe',
'email': '[email protected]',
'source': 'mobile_app',
'registration_date': '2024-10-11T20:01:00Z'
})
)
...
En el ejemplo anterior se publica un evento de tipo user_registered en un topic de SNS. Pero ¿ese evento salió de la nada? ¿Eso es todo lo que hacen los servicios, simplemente publicar mensajes? Claro que no. El evento forma parte de un proceso de negocio más amplio que normalmente implica actualizar algún estado en una base de datos operacional antes de notificar a los componentes externos. Una representación más fiel del proceso se vería así:
...
# Persist state changes
users_table.put_item(
Item={
'user_id': user_id,
'name': name,
'password_hash': password_hash,
'email': email,
'source': source,
'registration_date': registration_date,
'created_at': datetime.utcnow().isoformat()
}
)
# Publish corresponding events
sns.publish(
TopicArn=users_topic_arn,
Message=json.dumps({
'event_type': 'user_registered',
'event_id': str(uuid.uuid4()),
'user_id': user_id,
'name': name,
'email': email,
'source': source,
'registration_date': registration_date
})
)
...
Primero se persiste el nuevo usuario y después se publica una notificación. El código parece sencillo, pero piensa en estas tres preguntas:
- ¿Qué puede salir mal aquí?
- ¿Qué implicaciones tiene?
- ¿Dirías que este código es confiable?
Tómate un momento para reflexionar antes de seguir leyendo.
Listo, comparemos respuestas.
Si algo falla entre la escritura en la base de datos y la publicación del mensaje, el sistema queda en un estado inconsistente. El usuario podría recibir un error y asumir que toda la operación falló, pero el registro ya se habría creado en la base de datos. Sin embargo, los suscriptores al evento user_registered no se enterarán, porque el mensaje nunca se publicó. ¿Por qué pasaría esto? El servidor podría reiniciarse, la Lambda podría agotar su tiempo, podría haber particiones de red o un sinfín de otras razones, sobre todo en la nube.
La consistencia de un sistema basado en eventos depende de su capacidad para entregar mensajes de manera confiable entre sus componentes. Aquí no se cumple. Pese a su aparente simplicidad, el código anterior no es confiable.
Entonces, ¿qué hacemos? ¿Podemos envolver la escritura en la base de datos y la publicación del evento dentro de una transacción atómica? No. En el pasado hubo intentos (por ejemplo, DTC), y no terminaron bien. ¿Two-phase commit? Tampoco sirve, porque sufre condiciones de falla parecidas.
La solución confiable es convertir dos transacciones en una sola. ¿Cómo? Hablemos del patrón outbox.
La solución: Outbox
La idea detrás del patrón outbox es bastante sencilla. Primero, persistes tanto los cambios de estado como los mensajes salientes en la base de datos operacional dentro de una sola transacción atómica. O ambos tienen éxito o ambos fallan, sin puntos intermedios. Después, un mecanismo externo —el relay de publicación— recoge los mensajes ya confirmados y los publica de manera asíncrona en un bus de mensajes.

Figura 1: El patrón outbox
Implementación: General
No hay una única forma de implementar el patrón outbox que sirva para todos los casos. Los detalles dependen del stack tecnológico que uses, sobre todo de la base de datos.
Primero, la base de datos define los recursos que tienes (o no tienes) para confirmar los datos actualizados y los mensajes salientes en una transacción atómica. Si admite transacciones multi-tabla (por ejemplo, bases de datos relacionales, DynamoDB, etc.), puedes persistir los mensajes en una tabla dedicada, normalmente llamada "outbox". Si no, tanto el estado actualizado como los mensajes deben persistirse en un mismo registro.
Segundo, necesitas una manera confiable de recuperar los mensajes ya persistidos. Algunas bases de datos habilitan el modelo push: la propia base de datos puede invocar al relay de publicación y entregarle los mensajes nuevos. Por ejemplo, los triggers de Lambda en DynamoDB o un mecanismo de change data capture (CDC) en bases de datos relacionales.
En última instancia, la forma en que persistes y recuperas los mensajes determina cómo vas a evitar que el mismo mensaje se tome y se vuelva a publicar sin necesidad.
Implementación: Ejemplo
Volvamos al ejemplo con el que arrancamos. Como el código usa DynamoDB, la forma más sencilla de implementar el patrón outbox en este caso sería aprovechar su capacidad para ejecutar transacciones multi-tabla y agregar los eventos salientes a una tabla dedicada:
...
with dynamodb.meta.client.transact_write_items(
TransactItems=[\
{\
'Put': {\
'TableName': users_table.name,\
'Item': {\
'user_id': user_id,\
'name': name,\
'password_hash': password_hash,\
'email': email,\
'source': source,\
'registration_date': registration_date,\
'created_at': datetime.utcnow().isoformat()\
}\
}\
},\
{\
'Put': {\
'TableName': outbox_table.name,\
'Item': {\
'event_id': event_id,\
'data': {\
'event_type': 'user_registered',\
'event_id': event_id,\
'user_id': user_id,\
'name': name,\
'email': email,\
'source': source,\
'registration_date': registration_date\
}\
}\
}\
}\
]
)
...
Ahora hay que decidir cómo publicar realmente los eventos en el topic de SNS. La solución más simple es usar DynamoDB Streams sobre la tabla outbox para disparar una función Lambda por cada registro nuevo y publicar sus eventos en el topic de SNS.
Por último, hay que decidir qué hacer con los mensajes ya publicados. La función de publicación podría borrar los registros de la tabla outbox una vez que reciba la confirmación de que la publicación en SNS se completó correctamente. O bien, podría conservar el registro y actualizarlo con la marca de tiempo de la publicación efectiva.
La Figura 2 resume la solución completa:

Figura 2: El patrón outbox implementado con AWS DynamoDB, Lambda y SNS
Comparada con la solución ingenua de publicar los eventos salientes directamente en un topic de SNS, la implementación del patrón outbox da como resultado un sistema más complejo, con más piezas en movimiento. Sin embargo, la solución resultante sí es confiable. Si la transacción original se confirmó, pase lo que pase en tiempo de ejecución, los eventos correspondientes se publicarán y llegarán a los suscriptores.
Posts de la serie
- Arquitectura basada en eventos en AWS, Parte I: lo básico
- Arquitectura basada en eventos en AWS, Parte II: lo básico avanzado (post actual)
- Arquitectura basada en eventos en AWS, Parte III: lo básico difícil
Publicado originalmente en https://vladikk.com .
La consistencia de un sistema basado en eventos depende de su capacidad para entregar mensajes de manera confiable entre sus componentes. El patrón outbox permite actualizar el estado del sistema y publicar los eventos resultantes como una transacción atómica, incluso cuando la infraestructura subyacente no admite ese tipo de transacciones entre servicios. La confiabilidad que el outbox aporta al sistema compensa con creces el esfuerzo de implementarlo.