This post is also available in english.

Posts en esta serie:

Los Bounded Contexts son autónomos

A la hora de integrar Bounded Contexts es importante recordar que los mismos deben ser autónomos. Cada uno es desarrollado de forma independiente y aislada de los demás. Su codebase puede ser evolucionado sin miedo a romper funcionalidades de otro Bounded Context. No hay dependencias de código fuente entre Bounded Contexts. Creamos estos contextos para reducir la complejidad de nuestro sistema y poder entregar valor de negocio rápidamente, por ende es muy importante intentar respetar su autonomía.

El equipo de desarrollo de un Bounded Context no debe tener dependencias de otros equipos, debe poder entregar valor (materializado en funcionalidades o cambios) sin necesitar que otros equipos realicen o aprueben algún cambio.

Si hay bajo acoplamiento entonces vamos a tener menos cuellos de botella y se va a entregar valor de negocio más rápido.

A continuación vamos a ver distintas estrategias de integración técnica entre Bounded Contexts y analizar los problemas y beneficios que cada una trae.

Integración a nivel código

Integración a nivel código

Tener todos los Bounded Contexts en el mismo repositorio de código o en una misma solución dentro de un IDE (por ejemplo en un mismo proyecto o solución de un IDE como IntelliJ o Visual Studio) puede ayudar a los developers a que puedan ver el big picture.

A veces esto no es posible porque los Bounded Contexts están escritos en distintos lenguajes o para distintos sistemas operativos.

Los Bounded Contexts, en este esquema, pueden simplemente importar código de otro contexto para integrarse y, en general, en estos casos los Bounded Contexts se deployan como módulos de una misma aplicación que integra todo, un monolito.

Este enfoque, que parece ser muy sencillo, trae el gran riesgo de generar grandes dependencias (y acoplamiento) entre los Bounded Contexts. Si un Bounded Context usa código de otro proyecto en la misma solución hay muchas probabilidades de que se pueda romper funcionalidad de otro o contaminar el modelo que queremos proteger.

Teniendo muchos Bounded Context juntos en la misma solución se empiezan a ver oportunidades de reutilización de código, de configuración y de tooling (herramientas de desarrollo). Si bien al principio parece una buena idea, con el paso del tiempo esto genera dependencias entre equipos y cuellos de botella, tenemos un gran riesgo de generar un Big Ball of Mud.

A medida que el codebase crezca, muchos developers van a estar trabajando en distintos features en paralelo. Intentar hacer merge del código y lograr hacer un release va ser cada vez mas difícil y doloroso. Los deploys van a ser más riesgosos y se va a necesitar mas cantidad de QA. Esto es un desperdicio enorme para la compañía.

Como ya dijimos anteriormente, en nuestra profesión nunca hay reglas sino heurísticas, cada enfoque tiene sus pros y sus contras y está en nosotros elegir los mejores trade-offs para cada contexto. Ya hablaremos en otra serie de posts sobre los Monolitos Modulares donde se hace integración por código pero procurando un bajo acoplamiento que luego permita pasar a otros esquemas de integración cuando el crecimiento de negocio lo amerite.

Integración a nivel base de datos

Integración a nivel base de datos

En los casos donde los Bounded Context están integrados por código en una misma aplicación, un enfoque muy popular es que compartan la misma base de datos.

En este esquema los Bounded Context tienen sus tablas en la misma DB y las comparten, leyendo y escribiendo lo que cada uno necesita para poder interactuar.

Este tipo de integración es muy problemático. La DB es una dependencia entre equipos y contextos que suele enlentecer enormemente los procesos de desarrollo y limitar la autonomía.

Por ejemplo, el equipo que desarrolla el Bounded Context de ventas quiere cambiar la tabla de usuarios pero nadie sabe si eso va a afectar al código de los contextos de delivery o de facturación. Hacer este tipo de cambios va a requerir coordinar con varios equipos y distraerlos de sus prioridades. Esto va a afectar la frecuencia de entrega de valor de los equipos.

A veces cuando dos modelos se integran compartiendo la misma base de datos hay conceptos que son similares pero distintos. Las tablas comienzan simples pero con el tiempo aparecen nuevas columnas que son útiles solo para determinado Bounded Context y no para otros. En algunos casos hasta una misma columna puede ser interpretada de forma distinta o significar algo distinto según el contexto.

Como la naturaleza de cada Bounded Context es distinta, aquello que empezó simple va a ir complejizándose con el tiempo y los problemas van a ir creciendo exponencialmente porque cada contexto empuja en una dirección contraria.

Es necesario establecer barreras físicas para proteger los modelos y la autonomía de los equipos.

Usar barreras físicas para integrar Bounded Contexts

Para asegurar la integridad de los modelos y que los equipos puedan ser autónomos el enfoque más utilizado es una arquitectura donde no se comparte nada. Cada Bounded Context tiene su propio codebase, su propia base de datos y su propio equipo de desarrollo.

Cuando esto ocurre estamos ante un sistema distribuido.

Sistema distribuido

En este esquema cada Bounded Context está aislado físicamente, no se pueden llamar métodos directamente de uno a otro o acceder directamente a los datos. En esta arquitectura hay una fricción para generar acoplamiento, no se puede caer en él accidentalmente sino que requiere un esfuerzo especial.

La comunicación entre Bounded Contexts sucede mediante un contrato explícito y bien definido.

A continuación vamos a ver distintos esquemas de integración de Bounded Contexts en sistemas distribuidos.

Integración distribuida mediante base de datos

Una variante distribuida de la integración mediante base de datos que vimos anteriormente consiste en utilizar una DB como medio de intercambio de información (cada Bounded Context tiene su propia DB).

En este esquema generalmente un Bounded Context permite a otro el acceso a alguna tabla de intercambio. Por ejemplo un Bounded Context de ventas genera una nueva fila en una tabla de ordenes cada vez que se realiza una nueva venta. El Bounded Context de facturación revisa periódicamente esta tabla para verificar los nuevos registros que requieran una nueva factura. En este caso podría guardarse en su propia DB el id de la última orden facturada o, en algunos casos, se habilita una columna en esta tabla para que el Bounded Context marque como procesado el elemento. Este acceso suele estar restringido a las tablas compartidas para evitar el acoplamiento.

Integración distribuida con Base de datos

En este esquema los contextos no están tan acoplados. Por supuesto que en el caso de la tabla de ordenes esa tabla funciona como contrato, por ende al equipo de ventas le va a resultar difícil modificarla y va a tener que coordinar con los otros equipos.

Un problema que hay con este esquema es que se pueden generar problemas de locks en la DB (ambos sistemas compiten por los mismos recursos, si se generan muchas ventas afecta a la capacidad de facturar). Además esta DB se convierte en un único punto de falla (si la DB de ventas se cae no se puede facturar).

Integración distribuida con archivos

En este tipo de integración (que es muy común en los sistemas bancarios tradicionales) un contexto escribe archivos en un servidor y otro contexto los lee.

Integración distribuida con archivos

Este esquema es más flexible que la integración mediante DB y no tiene problemas de locks. Sin embargo hay otros problemas como la dependencia en el formato de los archivos. Se debe mantener un standard respecto al formato y estructura de los archivos y ese standard opera como contrato, cambiarlo requiere coordinación entre ambos equipos y desarrollo en ambos sistemas.

Los motores de base de datos nos resuelven muchos problemas que en este esquema debemos atender de forma manual, lo cual genera desarrollos adicionales. Hay que ocuparse de la gestión de los errores, de la escalabilidad, la concurrencia, la robustez, etc.

Integración distribuida con RPC

La idea de RPC (Remote Procedure Call) es mantener el diseño monolítico pero con los beneficios de un sistema distribuido.

Un contexto llama a un método de una clase para invocar alguna funcionalidad de otro contexto. Internamente ese método hace la llamada a través de la red y no invocando directamente el código fuente (por ejemplo mediante un request HTTP).

Esta integración es mas simple que la integración con archivos porque hay muchas librerías y frameworks que resuelven problemas de RPC.

Se puede comenzar con un sistema monolítico y cuando llega el momento de escalar se reemplazan las llamadas directas de código por llamadas RPC a través de la red. La lógica que se quiere distribuir pasa del monolito a otro subsistema y de esa forma se distribuye la carga entre 2 servidores en vez de utilizar solo uno.

El código con RPC es muy parecido al que se utiliza en un sistema monolítico:

val order = salesBoundedContext.createOrder(orderRequest)
val paymentStatus = billingBoundedContext.processPaymentFor(order)
if (paymentStatus.isSuccessful) {
	shippingBoundedContext.arrangeShippingFor(order)
}

Cada método puede ser procesado en memoria o hacer una llamada HTTP a otro servicio. El objetivo de RPC es que la comunicación por red sea transparente.

Integración distribuida con RPC

Como podemos ver en el ejemplo todo el procesamiento del request http de place order (comprar un producto por ejemplo) se hace de forma sincrónica con llamadas remotas RPC a distintos Bounded Contexts (que a su vez utilizan servicios de terceros). El thread que atiende el request HTTP queda bloqueado hasta que se procese el flujo completo y se devuelve la respuesta.

Mucha gente inexperimentada elige utilizar RPC por lo simple que parece implementarlo y por las posibilidades de reutilización de código, sin embargo RPC tiene varios problemas:

  • Es muy difícil hacer que el sistema sea resiliente a fallos: Al ser transparente la comunicación por red uno se olvida de la misma. Los errores de red suceden más frecuente de lo que uno se imagina, lo cual hace que los sistemas que utilizan RPC sean menos confiables. En el ejemplo anterior si cualquiera de todas las llamadas remotas falla por un problema de red, el flujo completo falla.
  • Es difícil y costoso escalar el sistema: Si por ejemplo tenemos una aplicación web que anda lenta por la gran cantidad de usuarios que la utilizan, podríamos pensar en mejorar el servidor web agregándole mejor hardware (más CPU y memoria), sin embargo muchos requests de usuarios van a utilizar los contextos de ventas y facturación que están en otros servidores. Como cada llamada es sincrónica, el tiempo que tarda el request del usuario es el tiempo que tarda en procesarse en el servidor web más el tiempo de procesamiento en los otros servidores y la latencia de la red. Esto nos lleva a que para que realmente haya un impacto real en el tiempo de respuesta de cara al usuario tengamos que mejorar todos los servidores y no uno solo.
  • Si algún servicio esta caído (por ejemplo el de facturación o de delivery) no se puede procesar ninguna venta, lo cual no es una buena estrategia de negocio. Los servicios deberían ser independientes.

Para abordar nuestra próxima estrategia de integración primero vamos a tener que repasar algunos conceptos.

Transacciones distribuidas

Las transacciones se utilizan para mantener la consistencia de los datos. Por lo general se utilizan a nivel base de datos.

Cuando estamos ante un sistema distribuido las transacciones de base de datos no nos sirven para garantizar la atomicidad y consistencia de las operaciones que abarcan a más de un servicio. En estos casos surge la necesidad de utilizar transacciones distribuidas.

Por ejemplo, si compramos un paquete turístico debemos garantizar que se pueda reservar un hotel en el sistema de reservas de hoteles, un vuelo en el sistema de pasajes aéreos y un auto en el sistema de reserva de vehículos. Se deben hacer las 3 reservas o sino el paquete debe cancelarse.

Las transacciones distribuidas, al igual que RPC, están desaconsejadas por los problemas que conllevan.

Por ejemplo, en el caso de la reserva de paquetes turísticos que vimos anteriormente, cuando hay una venta de un paquete se debería hacer un lock de la habitación del hotel en la base de datos. Ese lock se mantiene mientras se reserva el avión. Mantener el lock por tanto tiempo (varios segundos o minutos) hace que también deban mantenerse abiertas conexiones a la base de datos por mucho tiempo. Estas conexiones empiezan a acumularse a medida que los usuarios compran paquetes.

Llegado a un punto las bases de datos empiezan a rechazar nuevas conexiones y los locks empiezan a fallar. Esto trae serios problemas de escalabilidad porque pone un limite sobre la cantidad de usuarios concurrentes que soporta nuestro sistema.

Además de los problemas técnicos, esto trae un serio problema a nivel negocio: no permitimos vender paquetes y tener ingresos por detalles técnicos, se pierden ganancias.

Otro problema es la disponibilidad parcial. ¿Qué pasa si el sistema de hoteles funciona pero el sistema de pasajes aéreos esta caído? Con la transacción distribuida se debería abortar toda la operación y devolver la habitación al hotel. A nivel negocio no es necesario que ambas reservas se hagan en el mismo momento, se podría reintentar sacar el pasaje más adelante.

Consistencia Eventual

Los Bounded Contexts no tienen porque ser consistentes inmediatamente entre sí. Un usuario puede haber actualizado su dirección en un contexto pero otros contextos aún podrían tener su dirección antigua.

El contexto de ventas puede tener una compra pero en otro contexto aún la compra no se terminó de realizar porque no se reservó el aéreo.

Las transacciones distribuidas se pueden evitar introduciendo consistencia eventual. Permitimos reservar un hotel y el aéreo se reserva más tarde cuando el sistema este online otra vez.

El sistema en su totalidad está en un estado inconsistente, pero en algún momento esta consistencia llega (de aquí el nombre de eventual). En general los sistemas distribuidos modernos jamás están en un estado consistente, siempre hay alguna parte que aún tiene información vieja.

En la vida real la consistencia es eventual. Hacemos el tramite para cambiar nuestro nombre en el DNI pero el pasaporte y otros sistemas aún tienen nuestro nombre viejo. Nos mudamos y el proveedor de internet o de la tarjeta de crédito aún tienen la dirección anterior.

Repasando estos conceptos ahora estamos preparados para introducir la última forma de integración.

Integración distribuida con mensajes (messaging)

La estrategia que se suele utilizar mayormente en los sistemas modernos y que resuelve todos los problemas vistos anteriormente es la de integrar Bounded Contexts mediante soluciones de mensajería basadas en eventos.

Este enfoque esta basado en los principios de la programación reactiva que reemplaza RPC por mensajes asincrónicos.

Repasemos nuevamente como era el ejemplo de RPC y como se modificaría si utilizamos messaging.

Integración distribuida con RPC

La versión con messaging quedaría así:

Integración con messaging

Revisemos el flujo:

  • Primero llega el request HTTP de una nueva orden. Este request llama de forma sincrónica al Bounded Context de ventas. Este contexto almacena la nueva orden en su base de datos y luego se envía inmediatamente la respuesta HTTP informando al usuario que la orden fue recibida exitosamente (pero aún no fue procesada).
  • De forma asincrónica el contexto de ventas le envía un mensaje al contexto de facturación para que facture una nueva orden (puede ser un comando o un evento de que la orden se creó).
  • El contexto de facturación se comunica con el proveedor de pago de forma sincrónica (pero sin bloquear a ventas ni al request original del usuario) y realiza el pago.
  • Luego de realizado el pago, el contexto de facturación le avisa mediante un evento asincrónico al contexto de shipping del nuevo pago. Este utiliza el servicio de terceros para coordinar el delivery (esta última comunicación suele ser sincrónica) y luego publica un evento asincrónico que escucha el contexto de ventas.
  • Finalmente el contexto de ventas da por procesada la orden y le envía un email al usuario avisándole que su orden fue procesada correctamente.

Para resolver los problemas de resiliencia (por ejemplo que un servicio esté caído) se utilizan colas de mensajes. Los pedidos o eventos se encolan como mensajes en una cola, cada servicio va tomando los mensajes y atendiéndolos. Si un servicio está caído retoma los mensajes cuando vuelva a estar online. El sistema en su totalidad funciona aunque ciertos servicios no estén disponibles.

Los problemas de performance se solucionan introduciendo asincronismo. Los servicios no deben esperar que sus servicios dependientes terminen de procesar los pedidos.

A su vez se pueden escalar los servicios independientemente en base a las necesidades de uso de cada uno. Se puede mejorar el servidor web (o agregar más servidores web) sin necesidad de modificar el servidor de facturación. El servidor de facturación solo debe modificarse acorde a la frecuencia de mensajes que entran en su cola para procesar. Las necesidades de escalabilidad son distintas para cada contexto (el servidor web se escala en base a la cantidad de usuarios que están navegando el sitio, el servidor de facturación en base a la cantidad de facturas que se emiten, etc.). Esto permite una estrategia más efectiva a nivel costos.

Cuando aparecen cuellos de botella en el sistema tenemos más opciones para poder resolverlos. Podemos mejorar el hardware o agregar más instancias de cualquier servicio de forma independiente.

Hay 2 tipos de escalabilidad:

  • Vertical (o scaling up): Se logra mejorando el hardware (CPU, RAM, disco, etc) de un servidor.
  • Horizontal (o scaling out): Se logra distribuyendo la carga en más servidores, agregando más instancias.

El messaging viene con sus propios problemas:

  • Es más difícil debuggear y rastrear errores a través de distintos contextos y servidores.
  • Se agregan más indirecciones en el código, es difícil entender el flujo de cada proceso de negocio.
  • La consistencia eventual requiere esfuerzo adicional para lograrla, puede generar errores si es mal manejada y a veces los usuarios (y el negocio) esperan que la misma sea atómica y no eventual. Se suele necesitar modificar la UX acorde a esto.
  • Se incrementa la complejidad del sistema al necesitar más componentes de infraestructura que entreguen y reintenten los mensajes.

Conclusiones

Como pudimos ver hay distintas formas de integrar Bounded Contexts, cada una con sus beneficios y contras. Hay que tener en cuenta el contexto de cada sistema y tipo de negocio para elegir los mejores trade-offs. Aplicar un sistema distribuido preparado para una gran escalabilidad y resiliencia en un startup que esta empezando y validando su negocio genera una gran complejidad, lentitud en el desarrollo y limita la flexibilidad al momento de tener que pivotear (algo muy común). Es mejor en estos casos empezar con un monolito bien modularizado, aplicando buenas técnicas y principios de programación que permitan evolucionar la solución hacia un sistema distribuido cuando el crecimiento del negocio lo amerite.

Próximo post

En el próximo post discutiremos sobre la arquitectura interna de los Bounded Contexts introduciendo algunos conceptos que nos permitirán controlar el acoplamiento y la modularización. Esto nos permitirá tener flexibilidad a la hora de ir evolucionando nuestros diseños y tener mayores opciones abiertas a la hora de ir tomando decisiones tecnológicas o de integración.