¿PostgreSQL como cola de mensajería? Depende.
Reflexiones sobre cuándo usar PostgreSQL como event bus, cómo implementarlo con dos tablas y cómo se relaciona con las transacciones en sistemas con arquitectura orientada a eventos.
Este último fin de semana aproveché para hacer un par de cursos de Codely: "Event bus en base de datos [Diseño de Infraestructura]" y "Transacciones [Diseño de infraestructura]", con el fin de profundizar en la teoría de cómo implementar estas soluciones.
🐇 El contexto: RabbitMQ no siempre está en la mesa
En los últimos años he experimentado los beneficios de sistemas con event-driven architecture usando RabbitMQ principalmente. Aun así, hay proyectos en los que participo que o bien no tienen implementados los eventos, o todavía no requieren de esa potencia que nos proporcionan piezas de infraestructura como RabbitMQ o Kafka.
Sin embargo, he notado que no utilizar este tipo de sistemas reduce significativamente la capacidad de construir código sin romper principios como OCP o SRP, sobre todo en sistemas con varios casos de uso derivados. Además, la dificultad en cuanto a testeabilidad y mantenibilidad del código incrementa exponencialmente.
🤔 Entonces, ¿PostgreSQL como cola?
Volviendo a la pregunta del principio: una vez más, depende. Un bus en memoria podría resolver todos los problemas de mantenibilidad y testeabilidad y, en el caso de un sistema sencillo en el que podamos permitirnos sincronía, es una buena solución.
En el caso particular que estoy resolviendo, la respuesta definitiva es que sí: PostgreSQL es mi nueva cola de mensajería. Me garantiza:
- Asincronía
- Orden en los eventos
- Retry
- Dead letter
- Skip locked
- Capacidad de crecer y refactorizar en armonía
- Todos los beneficios SOLID respetando la clean architecture
🗃️ La implementación: dos tablas
En mi caso necesito dos tablas:
- Tabla de eventos: donde se insertan los eventos a consumir.
- Tabla de relaciones evento → subscribers: actualizada en cada despliegue, obteniendo todos los subscribers mediante el inyector de dependencias, ya que cada uno implementa una interfaz de dominio.
Cuando se levanta la app, se asigna un worker que va haciendo consultas a la tabla de eventos y consumiendo lo que se va creando. Desde ahí, es el funcionamiento típico de un bus: el publisher publica los eventos de dominio y los consumers los van ejecutando mediante los subscribers para los casos de uso derivados.
flowchart TB
UC[Use Case]
EB[Event Bus]
EV[(domain_events)]
SR[(event_subscribers)]
W[Worker]
S1[Subscriber A]
S2[Subscriber B]
DL[(dead_letter)]
UC -->|publica| EB
EB -->|inserta| EV
EV -.->|referencia| SR
EV -->|poll + SKIP LOCKED| W
W --> S1
W --> S2
S1 -->|marca procesado| EV
S2 -->|marca procesado| EV
EV -->|max retries| DL
style EV fill:#1e1b4b,stroke:#818cf8,color:#e0e7ff
style SR fill:#1e1b4b,stroke:#818cf8,color:#e0e7ff
style DL fill:#3b0764,stroke:#a855f7,color:#f3e8ff
style W fill:#0f172a,stroke:#818cf8,color:#e0e7ff
🔗 El rol de las transacciones
Enlazo esto con las transacciones, ya que me parece una posible fase intermedia previa a los eventos de dominio. Aunque abogaría por su uso en las implementaciones de los repositorios, en caso de tener que orquestar varios casos de uso las utilizaría como decorador en la capa de infraestructura.
Mi conclusión final es reducir su uso a situaciones estrictamente necesarias. Teniendo un sistema de eventos con retry, dead letter, etc., me inclino por tratar todos los casos de uso derivados sin bloquear la opción de continuar generando recursos mediante la funcionalidad principal.
Publicado originalmente en LinkedIn.