This post is also available in english.
Los eventos de dominio (Domain Events) son uno de los patrones de diseño tácticos de Domain Driven Design (DDD).
Aparecieron posteriormente al libro azul donde Eric Evans introduce los conceptos fundamentales y tuvieron un gran impacto en la comunidad por las posibilidades que otorgan.
Desde la parte estratégica de DDD, se descubrió que para entender el problema de dominio era muy útil focalizarse en los principales eventos que ocurren en el negocio (como por ejemplo que un cliente hizo una compra o que un producto está listo para despachar).
Los eventos son grandes ordenadores de los procesos de negocio y permiten descubrir y entender flujos complejos que involucran a distintos actores. Surgieron prácticas muy exitosas de exploración de negocios complejos como Event Storming que se basan en focalizarse en los principales eventos que ocurren en una empresa.
La idea del patrón es que los eventos de negocio sean parte explícita de nuestro modelo. Un evento de dominio es un objeto simple que contiene la información de lo que ocurrió, por ejemplo:
class ProductBought(product: Product, buyer: Buyer, date: LocalDateTime): DomainEvent() {
}
Es importante notar que los eventos de dominio no son cuestiones técnicas (por ejemplo que se persistió una entidad o se clickeo un botón) sino que son conceptos del negocio. Nos permiten enriquecer nuestro modelo y el lenguaje ubicuo incorporando hechos.
Se suelen generar como consecuencia de ejecutar un comando en nuestro modelo. Son los aggregates los que producen eventos de dominio. Por ejemplo, si se ejecuta el comando PublishCampaign nuestro aggregate Campaign se encargará de generar el evento CampaignPublished.
Los eventos de dominio se publican y ante ellos reaccionan distintos handlers. Los handlers procesan los eventos y actúan acorde a los mismos (por ejemplo enviando un email de que la campaña fue publicada, actuando sobre otro aggregate para poder mantener la consistencia eventual o comunicando a un sistema externo para que pueda comenzar algún proceso relacionado).
Un handler puede realizar una acción de negocio dentro del dominio o una acción de aplicación (enviar un SMS).
La contra de toda arquitectura basada en eventos es que el desacoplamiento genera indirección. Es más difícil encontrar todos los efectos de cada acción y seguir el orden de ejecución.
Eventos internos vs externos
Los eventos de dominio siempre son internos a un Bounded Context. Son parte de un modelo y de su lenguaje ubicuo. Cada Bounded Context implementa su propio modelo y lenguaje por ende tendrá sus propios eventos.
Hay muchas formas de comunicación entre Bounded Contexts, pero la más efectiva suele ser utilizando eventos. Estos eventos se conocen como eventos externos o de integración y no deben confundirse con eventos de dominio, que son internos. Por ejemplo, un Bounded Context de ventas notifica mediante el evento de integración ProductPurchased al Bounded Context de delivery para que pueda hacer el envío del producto al comprador.
Estos eventos también utilizan el lenguaje de dominio, pero es lo que se conoce como un Published Language puesto que es un contrato entre varias áreas de la empresa y no contextual a un subdominio particular. Cambiar aspectos (atributos, nombre, etc.) de estos eventos externos requiere mucho análisis y acuerdos puesto que un cambio no consensuado podría provocar que sistemas existentes dejen de funcionar.
Al ocurrir dentro de un mismo Bounded Context, los eventos de dominio pueden cambiar más libremente, puesto que si algo deja de funcionar el código no compila y los tests no pasan. Tambien pueden contener referencias directas a objetos del modelo (entidades, aggregates, value objects).
Por el contrario, los eventos de integración suelen referir a los aggregates solamente por id y usar tipos de datos simples así se evita acoplar el modelo a otros sistemas.
Casos de uso
Los eventos de dominio se suelen utilizar comúnmente para los siguientes casos:
- Asegurar consistencia eventual entre distintos aggregates del mismo Bounded Context. Cuando un comando relacionado a un aggregate se ejecuta, si hay reglas de negocio adicionales relacionadas con otros aggregates, las mismas se suelen procesar mediante eventos de dominio. Por ejemplo, si un cliente hace más de 3 compras costosas en un pequeño período de tiempo se lo marca como sospechoso de fraude. Una de las reglas de los aggregates indica que solo se debe modificar una única instancia en la misma transacción, todos los cambios dependientes deben ocurrir en transacciones separadas. Para garantizar la consistencia eventual entre lo mismos se pueden sincronizar los cambios mediante eventos de dominio.
- Coordinar un aggregate root con sus hijos. Un aggregate root puede escuchar eventos en sus entidades hijas y realizar cálculos o procesos de agregación ante los mismos. Por ejemplo, recalcular un total de una orden si cambia el importe de alguno de los items de la misma.
- Permiten integrar Bounded Context traduciéndose a eventos de integración en el lenguaje público, sincronizando así el estado de los mismos y garantizando la consistencia eventual. Esto permite que los sistemas sean altamente escalables (ya que no hay que utilizar transacciones globales como two-phase commit), resilientes y que haya un bajo nivel de acoplamiento entre los mismos.
- Reemplazan los procesos por lotes (batch) que suelen ejecutarse en horarios de menor uso del sistema procesando grandes cantidades de datos. En vez de ejecutar consultas complejas para detectar los cambios que ocurrieron en el día y realizar tareas de mantenimiento sobre los mismos, con eventos es posible ir haciendo las tareas de mantenimiento sobre cada cambio a medida que ocurre durante el día.
- Permiten tener un log de auditoría para inspeccionar la actividad de las principales entidades del sistema al persistir los eventos.
- Permiten implementar el patrón Event Sourcing y CQRS.
Algunos ejemplos de uso podrían ser:
- Verificar un nuevo método de pago de una tarjeta VISA (haciendo una compra por $1 y luego generando una devolución) luego de agregarlo a los métodos de pago de un cliente.
- Enviar un email de bienvenida luego de que un usuario se registró.
- Agregar un badge de “Gran comprador” a un usuario luego de realizar 10 compras en el mismo mes.
- Cuando un usuario se registra por recomendación de otro se le genera una gift card al segundo.
- Luego de crear una cuenta se obtiene un descuento del 10% en el primer mes.
- Luego de la compra de un producto:
- Enviar un email de confirmación al usuario.
- Enviar una notificación al slack de la empresa.
- Notificar al departamento de shipping.
Estructura de un evento de dominio
Los eventos de dominio son clases simples (POJOs) e inmutables.
Como refieren a algo que ya ocurrió su nombre debe estar en tiempo pasado (por ejemplo ProductShipped). En general los eventos se crean como reacción a un comando por ende el nombre suele referir al mismo (por ejemplo ShipProduct).
Suelen contener un timestamp del tiempo de ocurrencia junto con la información de las entidades involucradas.
Cuando se traducen como eventos de integración, suelen incorporar un ID único que sirve para que otros sistemas puedan detectar si ya fue procesado o no ignorando duplicados. Dependiendo el mecanismo de publicación de eventos hacia otros sistemas es posible que un mismo evento se envíe más de una vez.
Suelen ser serializables para poder trasmitirse entre procesos y ser persistidos fácilmente.
abstract class DomainEvent {
val id = UUID.randomUUID().toString()
val ocurredOn: LocalDateTime = SystemClock().now()
override fun equals(other: Any?) = other is DomainEvent && other.id == id
override fun hashCode() = id.hashCode()
}
class ProductShipped(
val productId: ProductId,
val address: Address,
val customerId: CustomerId
): DomainEvent() {
}
Desacoplamiento
Cuando ante un comando hay que realizar distintas acciones o reglas de negocio, podemos estar tentados a resolver todo dentro del mismo servicio de aplicación (o caso de uso si utilizamos Clean Architecture):
class PurchaseProductHandler(
private val orders: OrderRepository,
private val emailNotifications: EmailNotifications,
private val slackNotifications: SlackNotifications,
private val bestBuyerBadgeChecker: BestBuyerBadgeChecker,
) {
fun handle(command: PurchaseProductCommand) {
val order = Order.create(orders.nextId(), command.productId, command.customerId)
orders.add(order)
emailNotifications.notifyNewPurchase(order)
slackNotifications.notifyNewPurchase(order)
bestBuyerBadgeChecker.productPurchased(command.customerId)
}
}
Este handler del comando PurchaseProduct (un application service o use case) tiene acoplados todos los efectos secundarios de comprar un producto. Esto viola los principios de Single-Responsibility y Open-Closed haciendo que nuestro código sea más difícil de mantener e incrementando los riesgos de introducir bugs ante cambios en distintas funcionalidades.
Con los eventos de dominio podemos hacer que nuestro application service se encargue simplemente de ejecutar el comando en un aggregate y que todos los efectos secundarios se manejen de forma granular, cada uno en su propio handler del evento:
class PurchaseProductHandler(
private val orders: OrderRepository,
private val eventPublisher: DomainEventPublisher,
) {
fun handle(command: PurchaseProductCommand) {
val order = Order.create(orders.nextId(), command.productId, command.customerId)
orders.add(order)
eventPublisher.publish(order.recordedEvents)
}
}
class SendEmailOnProductPurchased(
private val emailNotifications: EmailNotifications,
): DomainEventHandler<ProductPurchased> {
fun on(event: ProductPurchased) {
emailNotifications.notifyNewPurchase(event.order)
}
}
class NotifySlackOnProductPurchased(
private val slackNotifications: SlackNotifications,
): DomainEventHandler<ProductPurchased> {
fun on(event: ProductPurchased) {
slackNotifications.notifyNewPurchase(event.order)
}
}
class BestBuyerBadgeOnProductPurchased(
private val bestBuyerBadgeChecker: BestBuyerBadgeChecker,
): DomainEventHandler<ProductPurchased> {
fun on(event: ProductPurchased) {
bestBuyerBadgeChecker.productPurchased(event.order.customerId)
}
}
Al estar modelados de forma explicita es sencillo encontrar si un efecto secundario está implementado o no y dónde. De la otra forma tendríamos que estar buscando en el código para encontrar si la regla fue implementada.
Sincrónicos o asincrónicos
Los eventos de dominio pueden implementarse de forma sincrónica (se ejecutan inmediatamente y en el mismo proceso) o de forma asincrónica. Los eventos de integración siempre se ejecutan de forma asincrónica.
Capas
La publicación y el handling de los eventos es un asunto de la capa de aplicación y no de la capa de dominio. La capa de dominio solo se encarga de cuestiones de negocio (lo que un experto de negocio podría entender) y no de cuestiones de infraestructura como publicar y despachar eventos.
Los handlers de los eventos suelen estar también en la capa de aplicación puesto que suelen utilizar servicios de infraestructura (aunque algunos autores consideran también handlers dentro del dominio). Un handler de evento es muy similar a un servicio de aplicación o use case, se encarga de orquestar objetos de dominio como aggregates o servicios de dominio. Tambien puede invocar a servicios externos (enviar emails, publicar eventos de integración en otros Bounded Contexts, etc).
Transacciones
Los autores clásicos (Eric Evans, Vaughn Vernon) indican que cada aggregate debe ejecutarse en una transacción atómica y si hay efectos secundarios que impliquen modificaciones a otros aggregates los mismos se deben implementar utilizando consistencia eventual.
Esto se debe a que en aplicaciones de gran escala las transacciones grandes generan muchos locks en la base de datos y esto afecta a la escalabilidad al introducir problemas de concurrencia. Se basan además en que los cambios atómicos no suelen ser necesarios por el negocio.
Otros autores como Jimmy Bogard indican que no hay problema con utilizar una misma transacción que afecte a varios aggregates siempre y cuando los efectos secundarios estén directamente relacionados con el comando que se ejecutó. Si se produce una cascada de efectos (un comando genera un evento que a su vez genera un comando y este otro evento..) ya no es recomendable. Tampoco si las operaciones son lentas y por ende quien ejecuta el comando debe quedarse esperando.
Mi recomendación es focalizarse en entender bien los requerimientos del negocio y poder hacer un trade-off correcto balanceando todos los pros y contras. Se puede también utilizar un esquema mixto donde algunos handlers se procesen de manera sincrónica en la misma transacción y otros de forma asincrónica en otras transacciones.
Estrategias de publicación
1) Patrón Observer
La forma más sencilla de implementar eventos de dominio es mediante el patrón Observer.
Con esta implementación cada aggregate gestiona una colección de observers a quienes notifica directamente cuando un evento ocurre.
Los observers implementan una interfaz común, lo que hace que el aggregate no conozca sus implementaciones.
interface Observer {
fun on(event: DomainEvent)
}
class Customer {
private val observers = mutableListOf<Observer>()
// ...
fun subscribe(observer: Observer) {
observers.add(observer)
}
private fun notify(event: DomainEvent) {
observers.forEach { it.on(event) }
}
fun move(newAddress: Address) {
// ...
notify(CustomerMoved(id, newAddress))
}
}
El problema de este enfoque es la subscripción y desuscripción del aggregate. Un servicio de aplicación debería suscribir a los observers (o a si mismo) ante el aggregate y luego recordar desuscribirlos. Los publishers y los observers están muy acoplados con este enfoque.
Otro problema es que el evento se publica antes de que el aggregate se persista lo cual puede generar que ante una falla se haya publicado un evento de algo que no sucedió.
En esta implementación no es posible que la ejecución de los handlers sea asincrónica.
2) In-Memory Bus
Se puede utilizar un message bus in-memory para desacoplar los publishers de los observers. Esto permite que la suscripción y publicación sea ante un intermediario, el bus.
Esta implementación también permite que la ejecución de los handlers sea asincrónica, el bus puede determinar cuando notificar a los handlers.
Uno de los problemas que tiene este enfoque es que es necesario estar pasando la referencia al bus por todo el dominio. Además de ser engorroso acopla el dominio a asuntos técnicos.
class Customer {
// ...
fun move(newAddress: Address, bus: MessageBus) {
// ...
bus.publish(CustomerMoved(id, newAddress))
}
}
3) Clase estática DomainEvents
Esta implementación popularizada por Udi Dahan implica hacer cosas que, por lo general, no nos gusta hacer: usar clases estáticas/singletons y acoplar el dominio a aspectos tecnológicos.
interface EventHandler {
fun on(event: DomainEvent)
}
object DomainEvents {
private val handlers = mutableListOf<EventHandler>()
fun register(handler: EventHandler) {
handlers.add(handler)
}
fun raise(event: DomainEvent) {
handlers.forEach { it.on(event) }
}
}
class Customer {
// ...
fun move(newAddress: Address) {
// ...
DomainEvents.raise(CustomerMoved(id, newAddress))
}
}
Uno de los problemas que tiene esta implementación es que, al ser una clase estática o singleton, es compartida por multiples threads, esto puede traer problemas de concurrencia y la necesidad de sincronización. Además de los problemas asociados al uso de un estado global.
4) Retornar eventos
Todos los métodos anteriores publicaban los eventos desde adentro del aggregate. Esto tiene los siguientes problemas:
- El handler se ejecuta antes de que el nuevo estado del aggregate sea persistido. Este handler podría notificar que algo se modificó y sin embargo aún no sucedió. También podría estar consultando información desactualizada.
- Si la transacción de la DB falla al momento de guardar el aggregate (por ejemplo por un problema de concurrencia) el evento ya se proceso. Se podría por ejemplo estar enviando un email a un usuario de un evento que finalmente no sucedió.
En esta implementación detallada por Jan Kronquist cada método del aggregate que genera eventos los devuelve directamente al application service / use case, este último se encarga de publicarlos (quizás utilizando un bus o domain event publisher) y puede coordinar con un repositorio para que esto suceda luego de persistir el aggregate.
class Customer {
// ...
fun move(newAddress: Address): List<DomainEvent> {
// ...
return listOf(CustomerMoved(id, newAddress))
}
}
Con este enfoque se logra desacoplar al dominio de la publicación y por ende de cuestiones de infraestructura. Tiene la contra de complejizar la signatura de los métodos.
5) RecordedEvents
Este enfoque descripto por Jimmy Bogard es una variante del anterior. La diferencia es que en vez de devolver los eventos en cada método el aggregate, los registra en una colección de eventos generados (recorded events). Luego de la ejecución del aggregate el application service / use case puede leer todos los eventos generados de esta propiedad y publicarlos directamente o indirectamente a través de un bus o publisher.
class Customer {
private val recordedEvents = mutableListOf<DomainEvent>()
// ...
fun move(newAddress: Address) {
// ...
recordedEvents.add(CustomerMoved(id, newAddress))
}
fun getRecordedEvents() = recordedEvents.toList()
}
Los publishers luego de publicar los eventos suelen limpiar la colección de eventos del aggregate por si el mismo se vuelve a utilizar.
Si queremos que los handlers se ejecuten en la misma transacción podemos hacer la publicación de los eventos dentro del repositorio del aggregate:
class PostgreSQLCustomerRepository(
private val db: DB,
private val eventPublisher: DomainEventPublisher
): CustomerRepository {
fun update(customer: Customer) {
db.transactional {
db.execute("UPDATE customers SET ...")
eventPublisher.publish(customer.recordedEvents)
}
}
}
De esta forma centralizamos la publicación de los eventos de cada aggregate y nos aseguramos que se ejecuten dentro de la misma transacción de forma atómica.
Asincronismo
En el caso de que querramos que la publicación de los eventos se haga de forma asincrónica seguramente vamos a utilizar algún message broker como RabbitMQ o Kafka para publicar los eventos. Luego tendremos workers que reciban los eventos y los ejecuten en otro proceso.
Para hacer esto deberíamos publicar los eventos con un DomainEventPublisher asincrónico luego de commitear la transacción que realiza la actualización del aggregate:
class PostgreSQLCustomerRepository(
private val db: DB,
private val eventPublisher: AsyncDomainEventPublisher
): CustomerRepository {
fun update(customer: Customer) {
db.transactional {
db.execute("UPDATE customers SET ...")
}
eventPublisher.publish(customer.recordedEvents)
}
}
Un problema con este enfoque es que si la publicación falla los eventos no se disparan, pero sin embargo el aggregate ya se modificó.
Si por el contrario decidimos publicar los eventos antes de guardar el aggregate tenemos el problema inverso: podría fallar la modificación del aggregate y sin embargo los eventos ya se dispararon.
Otro problema es que los handlers podrían intentar consultar información del aggregate pero se encontrarían con una versión anterior donde los cambios aún no están impactados.
Una solución a este problema es utilizar el patrón Outbox.
Patrón Outbox
El patrón Outbox asegura que los eventos se publiquen de forma confiable.
Para asegurar esto se utiliza una tabla de la DB como “bandeja de salida” de eventos. Se guardan los eventos registrados por el aggregate en esta tabla en la misma transacción que utiliza el aggregate. De esta forma se garantiza que el evento y el aggregate se guarden de forma atómica.
Luego un proceso worker lee esta tabla y va publicando los eventos y marcándolos como procesados (o borrándolos de la tabla).
Un aspecto a considerar es que cuando el worker esta invocando a los handlers puede ser que falle (habiendo ya enviado el evento a algunos handlers y a otros no). Si esto ocurre el evento no se marca como procesado. Esto haría que el worker en una próxima ejecución vuelva a enviar el mismo evento a algunos handlers. Esta estrategia de delivery se llama At-Least-Once por ende los handlers deben ser diseñados para lidiar con eventos duplicados (ser idempotentes).
Event Sourcing
Event sourcing es un patrón de persistencia. En vez de guardar el último estado de un aggregate se guardan todos los eventos de dominio que modificaron su estado. Cada vez que se quiere traer el aggregate del motor de persistencia se vuelven a procesar todos los eventos y se reconstituye su estado.
Muchas veces queremos saber el estado actual de nuestro mundo, pero otras veces queremos saber como llegamos ahí.
A los eventos cronológicos que le ocurrieron a un aggregate se los denomina Event Stream.
Esta forma de persistencia tiene algunas ventajas:
- Como no hay modificaciones sino solamente INSERTs es extremadamente performante, no se generan locks. Una aplicación que utiliza Event Sourcing tiene muy baja latencia y una alta escalabilidad.
- Al contar con el historial completo de eventos de dominio se pueden extraer mucha información útil y analytics. Se pueden ver tendencias de uso, debuggear.
- Si se requiere desarrollar un nuevo feature con, por ejemplo, un nuevo reporte, el mismo va a contar con toda la información desde el inicio de la aplicación. Si solo se guarda el último estado puede ser que no tengamos una parte de la información (porque no la registrábamos) y por ende ese reporte solo pueda mostrar la información a partir de la creación del nuevo feature.
Tambien cuenta con ciertas desventajas:
- Dependiendo la cantidad de eventos que tenga un aggregate la reconstitución del mismo puede ser poco performante. En la práctica esto no ocurre tan seguido (un aggregate no tiene mas de 15 o 20 eventos) pero de ser así se pueden guardar además de los eventos snapshots incrementales con el estado del aggregate hasta cierto punto en el tiempo y recrearlo desde el último snapshot. Al tener todos los eventos siempre podemos regenerar los snapshots.
- Agrega complejidad al software, suele requerir utilizar CQRS para poder tener las consultas separadas de los comandos puesto que la tabla de eventos no es performante para hacer las queries que requiere la UI. En este caso se suelen crear tablas desnormalizadas por cada consulta y actualizarlas a medida que se van generando nuevos eventos. A los componentes encargados de realizar estas actualizaciones se los llama proyecciones.
- Agregar propiedades o renombrar un evento suele ser problemático porque la historia no se puede cambiar. Es necesario manejar distintas versiones de los eventos lo cual agrega más complejidad.
Sagas y Process Managers
Si utilizamos consistencia eventual vamos a tener que coordinar los procesos de negocio que involucren a muchos aggregates para que eventualmente los mismos sean consistentes entre sí.
Supongamos que un cliente compra un producto. Luego de comprar el producto se debe hacer una reserva de stock y finalmente generar una orden de shipping. Todo este proceso debe ser consistente (no podemos comprar un producto si no hay stock ni generar una factura y que no se haga el envío).
La saga se encarga de coordinar este proceso y en el caso de que algún paso no se pueda concretar, genera acciones compensativas para deshacer lo ya realizado.
La saga escucha los eventos emitidos por los componentes relevantes y genera comandos a otros componentes. Si un paso falla, se encarga de generar un comando compensatorio para que el estado del sistema sea consistente.
Una saga puede necesitar guardar estado para saber las operaciones ya ejecutadas y poder calcular las acciones compensatorias.
Hay dos formas de implementar el patrón saga:
Coreografía. En la coreografía no es un componente central organizando todo el proceso sino que cada componente que participa intercambia eventos y actúa acorde a los mismos. La coreografía no tiene un punto central de fallo porque las responsabilidades están distribuidas.
Los flujos pueden ser difíciles de seguir y hay riesgo de introducir dependencias cíclicas.
Orquestación. En la orquestación hay un componente central que escucha los eventos, dispara los comandos y organiza todo el proceso. Para workflows complejos es más fácil de ver el proceso entero. Introduce un único punto de falla.
Se suele confundir saga con process manager. Una saga es un proceso (centralizado o no) que suele encargarse de compensar acciones en un flujo multi-transaccional. Un process manager se encarga de orquestar todo un proceso de forma centralizada. Cuando implementamos sagas con orquestación solemos utilizar process managers.
El clásico ejemplo de una saga es cuando se quiere comprar un paquete turístico:
Políticas
La práctica de event storming utiliza post-its de colores para representar los distintos elementos de un dominio y las relaciones entre los mismos.
El flujo es el siguiente:
- Los usuarios utilizan un Read Model (el resultado de una query de CQRS) en la UI para poder determinar una acción que quieren realizar. Esa acción genera un comando.
- Un comando se ejecuta sobre un aggregate.
- Un aggregate genera un evento de dominio.
- Un evento de dominio actualiza un read model que consume un usuario.
- El ciclo vuelve a comenzar.
Algunos eventos son atrapados por otro tipo de objetos, una política (Policy). Una política es un handler que determina que ante cierto evento se debe ejecutar un comando.
Por ejemplo, un sistema puede tener un “WelcomePolicy” que cada vez que se registra un usuario decide enviarle un email de bienvenida. Podría haber un “GreatBuyerDiscountPolicy” donde se chequea que si un usuario realizo más de tantas compras en un mes se le aplica un 40% de descuento en las futuras compras del mismo mes.
Estas policies no son más que event handlers pero son conceptos que suelen estar presentes en el lenguaje ubicuo cuando uno habla con expertos de negocio.
Eventos de paso del tiempo
Los eventos se suelen generar como consecuencia de la ejecución de un comando. Pero esto no siempre es así.
Hay otro caso muy común que son los eventos temporales. Un evento temporal es un evento de dominio que sucede cuando ocurre alguna fecha u hora especial. Por ejemplo: es viernes, son las 12, es la última semana del mes, es Navidad, es el día de liquidar los sueldos, etc.
Estos procesos temporales se suelen ejecutar con cron jobs o scheduled commands. Estas implementaciones hacen que cierta parte de la lógica del dominio no este encapsulada en el modelo sino por fuera (la información de cuando ocurre algún evento temporal relevante por ejemplo).
Una solución a este problema es modelar el paso del tiempo utilizando eventos de dominio. Con este enfoque utilizamos un cron o un scheduler para emitir eventos de dominio de paso del tiempo genéricos (como que paso una hora, un día, etc.) y luego podemos tener handlers que los escuchen y disparen eventos de dominio más relevantes (es Black Friday, día de pago de aguinaldo, etc.).
Un handler puede escuchar los eventos de que paso un día e ir revisando cuando expira el trial de una cuenta por ejemplo.
Otro handler podria determinar que una factura esta vencida y que hay que enviar una notificación al cliente.
Conclusión
Los eventos de dominio son una gran herramienta para considerar al modelar dominios complejos. Suelen ayudar a crear sistemas desacoplados, dominios encapsulados y modelar de forma más explicita los conceptos de negocio.
Como toda herramienta tiene trade-offs y multiples formas de implementarla.
Somos nosotros los que debemos analizar nuestro contexto particular y decidir la mejor forma de utilizarlos.
Referencias
- Patterns, Principles and Practices of DDD (Scott Millett, Nick Tune, Wrox, 2015)
- Domain-Driven Design Distilled (Vaughn Vernon, Addison Wesley, 2016)
- Implementing DDD (Vaughn Vernon, Addison Wesley, 2013)
- DDD Reference (Eric Evans, 2015)
- Learning Domain-Driven Design (Vlad Khononov, O’Reilly Media, 2021)
- Hands-On Domain-Driven Design with .NET Core (Alexey Zimarev, Packt Publishing Ltd, 2019)
- Implementing DDD, CQRS and Event Sourcing (Alex Lawrence, Leanpub, 2021)
- http://www.kamilgrzybek.com/design/how-to-publish-and-handle-domain-events/
- https://www.kamilgrzybek.com/design/handling-domain-events-missing-part/
- http://www.kamilgrzybek.com/design/the-outbox-pattern/
- Saga distributed transactions pattern