Arquitectura/ServerStack Journal

Microservicios con DDD + Outbox: cómo coordinar servicios sin acoplarlos

El problema número uno al pasar a microservicios no es partir la app — es coordinarlos sin perder mensajes. DDD define los límites; Outbox resuelve la mensajería transaccional.

Cuando una app crece, pasar a microservicios es tentador. Pero casi todos tropiezan en lo mismo: cómo coordinar los servicios sin volverlos dependientes unos de otros. La combinación DDD + Outbox Pattern es la que usamos porque resuelve los dos lados: dónde poner cada cosa (DDD) y cómo comunicarlos sin perder datos (Outbox).

El problema real con microservicios

Alguien divide una app monolítica en 5 servicios. Cada uno tiene su base de datos. Hasta aquí va bien. El problema aparece cuando:

  • El servicio de Pedidos necesita saber cuándo se registró un Usuario.
  • El servicio de Billing necesita saber cuándo se completó un Pedido.
  • El servicio de Notificaciones necesita saber cuándo pasó casi cualquier cosa.

Los intentos ingenuos:

❌ Llamadas HTTP directas entre servicios Pedidos llama a Usuarios, Billing llama a Pedidos, etc. Resultado: si Usuarios está caído, Pedidos tampoco funciona. Acabas con un "monolito distribuido" — la peor versión de ambos mundos.

❌ Compartir la misma base de datos Todos los servicios leen la tabla orders. Suena simple. Cuando alguien cambia el esquema, rompe a 3 servicios y nadie se da cuenta hasta que explota en producción.

La solución es: servicios que no se conocen directamente, comunicándose por eventos.

DDD: dónde poner cada cosa

Domain-Driven Design (DDD) es una forma de modelar software que pone el dominio del negocio en el centro. Para microservicios, su aporte clave es el concepto de bounded context:

Un bounded context es una zona del sistema donde un término del negocio significa una cosa específica.

Ejemplo: la palabra "usuario" significa cosas distintas según dónde estés:

  • En Auth: un usuario es {id, email, password, roles}.
  • En Pedidos: un usuario es {id, nombre, dirección de envío, teléfono}.
  • En Billing: un usuario es {id, nombre fiscal, RFC, método de pago}.

Son el mismo "usuario" conceptual, pero cada contexto necesita distinta información. La regla DDD: un microservicio = un bounded context. Cada servicio guarda solo los datos que necesita para su dominio.

Cómo se decide el corte

Preguntas para identificar bounded contexts:

  • ¿Qué equipo del negocio se encarga de esto? (Ej: "ventas", "facturación", "logística".)
  • ¿Qué vocabulario tiene sentido aquí y pierde sentido fuera?
  • ¿Qué reglas de negocio cambian en esta zona sin afectar las demás?

Regla práctica: si dos partes de la app evolucionan por razones distintas y a velocidades distintas, probablemente son bounded contexts separados.

El bug clásico: "cambié la BD pero el evento no salió"

Ya decidimos que los servicios se comunican por eventos. El servicio de Pedidos hace:

await db.orders.create({ id: 123, total: 500 });
await eventBus.publish('order.created', { id: 123 });

Problema: ¿qué pasa si el primer paso funciona pero el segundo falla (caída de red, proceso muerto)?

Resultado: la orden existe en la BD pero nadie se enteró. Billing nunca cobra, Notificaciones nunca avisa, el cliente cree que pidió algo y tú crees que no. Inconsistencia eterna.

Los intentos de arreglarlo:

❌ Invertir el orden Publicar primero, luego guardar. Ahora puedes mandar evento sin que la orden exista. Peor.

❌ Try/catch con rollback manual Pareces cubrirlo, pero si el proceso muere entre el commit y el rollback, la inconsistencia queda.

❌ Transacciones distribuidas (2PC) Técnicamente funciona. En la práctica son lentas, frágiles y la mayoría de bases de datos modernas no las soportan bien.

Outbox Pattern: el truco que sí funciona

La idea es sencilla y elegante:

En lugar de publicar el evento directamente, guárdalo en una tabla de tu propia BD dentro de la misma transacción. Un proceso aparte lee esa tabla y publica los eventos al bus.

Paso a paso:

  1. El servicio de Pedidos tiene dos tablas: orders y outbox_events.
  2. Cuando crea una orden, usa una sola transacción para insertar en las dos tablas:
await db.transaction(async (tx) => {
  await tx.orders.create({ id: 123, total: 500 });
  await tx.outboxEvents.create({
    type: 'order.created',
    payload: { id: 123 },
    published: false,
  });
});
  1. Un worker aparte (proceso de background) lee outbox_events donde published = false, publica al bus, y marca como publicado.

Por qué funciona

  • La transacción es atómica: o se insertan las dos cosas o ninguna.
  • Si el proceso muere en medio, al reiniciar encuentra el evento sin publicar y lo envía.
  • Si el bus está caído, el evento se queda en outbox_events hasta que el bus vuelva.
  • Nunca hay inconsistencia: BD y eventos siempre están sincronizados.

Único cuidado: el consumidor del evento debe ser idempotente (recibir el mismo evento dos veces no debe romper nada), porque el worker puede reintentar.

Ejemplo completo del flujo

Un usuario crea un pedido. Así fluye en un sistema con DDD + Outbox:

1. POST /api/orders  →  servicio "Pedidos"
2. Pedidos guarda orden + evento en outbox (1 transacción)
3. Worker de Pedidos lee outbox → publica "order.created" al bus
4. Servicio "Billing" escucha "order.created"
   → crea factura → guarda + evento "invoice.created" en su outbox
5. Worker de Billing publica "invoice.created"
6. Servicio "Notificaciones" escucha ambos → manda email al cliente

Cada servicio:

  • Tiene su BD independiente.
  • Usa su propio outbox.
  • No llama HTTP a otros servicios.
  • Puede caerse sin tumbar a los demás.

¿Qué bus de eventos usar?

Las opciones comunes, de menos a más complejas:

  • Redis Streams / BullMQ — si ya usas Redis. Ideal para MVP y tráfico moderado.
  • RabbitMQ — balance entre features y complejidad operacional.
  • Apache Kafka — si necesitas alta throughput y reproducción de eventos históricos.

Para la mayoría de proyectos, Redis Streams o RabbitMQ son suficientes durante años. Kafka tiene costo operacional que no justifica salvo casos muy grandes.

Microservicios vs Monorepo vs Legacy: cuál te toca

Antes de saltar a microservicios, vale la pena entender qué estás dejando y qué ganas. Estos son los tres escenarios más comunes:

1. Monolito legacy (el punto de partida de casi todos)

Una sola app, una sola BD, un solo deploy. Todo el código comparte memoria y transacciones locales.

Lo bueno:

  • Transacciones ACID "gratis": cambias 3 tablas en un solo BEGIN/COMMIT.
  • Desplegar es un solo comando.
  • Debugging simple: un stack trace te lleva de la petición al bug.
  • Costo de infra bajo: 1 servidor, 1 BD, 1 pipeline.

Lo malo:

  • Escalado "todo o nada": si el módulo de facturación necesita más CPU, tienes que escalar toda la app.
  • Un bug en un módulo puede tumbar la app completa.
  • Equipos se pisan entre sí: dos PRs tocando el mismo archivo es fricción diaria.
  • Cambiar stack (ej. pasar de Express a Next.js) obliga a migrar todo de una vez.

Para qué sirve: MVPs, startups con menos de 5 devs, productos donde la complejidad de negocio es baja. El 80% de las apps deberían quedarse aquí.

2. Monorepo con módulos bien delimitados (el punto medio)

Un solo repo con varios paquetes/apps adentro. Pueden compartir tipos, librerías, configs — pero cada uno puede desplegarse por separado. Herramientas típicas: Turborepo, Nx, pnpm workspaces.

Lo bueno:

  • Código compartido sin publicar paquetes a npm: importas del workspace directo.
  • Refactors atómicos: renombras un tipo y actualizas todos los usos en un solo PR.
  • Cada app puede ser su propio deploy: backend, frontend, widget, CLI, cada uno en su pipeline.
  • Onboarding más rápido: un solo git clone y tienes todo el ecosistema.

Lo malo:

  • El repo crece y el CI se vuelve lento si no configuras cache bien.
  • La disciplina de no acoplar módulos es responsabilidad del equipo — nada físico lo impide.
  • Si quieres un servicio en otro lenguaje (Go, Rust, Python), empiezas a chocar con las convenciones.

Para qué sirve: productos con 2-5 aplicaciones relacionadas (web + mobile + admin + widget), equipos de 3-15 devs. Es el sweet spot de la mayoría de SaaS modernos.

3. Microservicios con DDD + Outbox (el escenario de este post)

Múltiples servicios, cada uno con su repo o su sub-repo, su BD propia, su pipeline, su deploy, su bus de eventos.

Lo bueno:

  • Un servicio puede caerse sin tumbar al resto.
  • Escalado selectivo: el servicio de checkout escala solo en Black Friday, el resto no.
  • Equipos independientes pueden usar stacks distintos (Node para API, Python para ML, Go para ingesta).
  • Integraciones con terceros no contaminan al core: aíslas el caos del tercero en un servicio dedicado.

Lo malo:

  • Operaciones 5–10x más complejas: orquestación, service discovery, observabilidad distribuida.
  • Debugging difícil: un bug puede cruzar 3 servicios antes de manifestarse.
  • Eventualmente consistente: olvídate de "leo lo que acabo de escribir" en otro servicio.
  • Costos de infra más altos: bases de datos por servicio, brokers de eventos, tracing.

Para qué sirve: productos con tráfico alto, áreas de negocio bien diferenciadas, equipos grandes (20+ devs), o integraciones externas complejas.

Tabla de decisión rápida

SituaciónQuédate en...
MVP con <5 devsMonolito legacy
SaaS con 2-3 apps (web, admin, widget)Monorepo
Tráfico masivo + equipos por verticalMicroservicios + DDD
"Todos los grandes lo hacen"No es razón. Sigue en monorepo.

El error que vemos seguido

Equipos que saltan de monolito a microservicios sin pasar por monorepo. Resultado: descubren que su "API Gateway" + "Auth Service" + "User Service" son en realidad el mismo bounded context partido en 3 por moda. Y ahora tienen 3 pipelines, 3 BDs, 3 logs — para algo que cabía en una tabla.

Si dudas si ya es hora de microservicios, probablemente no lo es. El monorepo te lleva muy lejos.

¿Cuándo usar DDD + Outbox específicamente?

Asumiendo que ya decidiste microservicios:

  • Tu app tiene más de 3 "áreas" de negocio con equipos o flujos distintos.
  • Ya tuviste inconsistencias entre sistemas por mensajes perdidos.
  • Quieres que un servicio pueda escalar o caerse sin afectar a los demás.
  • Vas a agregar integraciones con terceros (pasarelas de pago, ERP, CRM).

Cuándo NO usarlo: si tu app cabe en un monolito bien hecho o en un monorepo con módulos. Microservicios sin necesidad = más operaciones, más costos, más bugs.

En ServerStack Solutions

Para productos SaaS multi-tenant usamos este patrón desde el diseño: cada servicio es un bounded context con su base de datos, outbox propio y comunicación asíncrona vía bus. Resultado: servicios que se despliegan, escalan y fallan de forma independiente.

Contáctanos si estás diseñando una arquitectura de microservicios y quieres una segunda opinión antes de empezar.


Relacionado: IA cambia el desarrollo: necesitamos arquitectos y Pipelines multi-stage automatizados.

Contando…

Preguntas frecuentes

¿Qué es el Outbox Pattern en palabras simples?

Es una forma de asegurar que cuando guardas algo en la base de datos Y debes avisar a otro servicio, ambas cosas pasan o ninguna. En la misma transacción que guardas el dato, también guardas un "evento pendiente" en una tabla local (outbox). Un proceso separado toma los eventos pendientes y los publica al bus. Si algo falla, los eventos siguen en la tabla y se reintentan. Evita el clásico bug de "guardé en la BD pero el mensaje se perdió".

¿Necesito Kubernetes para usar microservicios con DDD?

No. Kubernetes es una herramienta de orquestación, no un requisito de arquitectura. Puedes correr microservicios con Docker Compose, con un PaaS (Railway, Render), con Lightsail + scripts, o con AWS ECS. Kubernetes solo justifica su complejidad cuando tienes muchos servicios y operaciones de escala.

¿Cuándo un "modulito" dentro del monolito se convierte en microservicio?

Cuando cumple al menos dos de: (1) equipo distinto lo mantiene y se pisan con el resto; (2) tiene necesidades de escalado muy distintas (el módulo de reportes necesita 10x más CPU que el resto); (3) cambia mucho más rápido que el resto y quieres deployarlo sin arriesgar el monolito; (4) el stack ideal es distinto (Python para ML, Go para ingesta). Cumplir uno solo casi nunca justifica el costo.

Escrito por

Equipo ServerStack Solutions

Fundador, ServerStack Solutions. Fundador de ServerStack Solutions. Diseño infraestructura y automatización para negocios que quieren dormir tranquilos. Escribo sobre CI/CD, DevOps y herramientas que hacen la diferencia.