Desacoplar modelos con Eventos de Dominio

Llevo un par de posts dedicados a construir modelos de dominio más ricos, poniendo como ejemplo el uso de clases en lugar de tipos enumerados y explicando cómo pasar lógica de los servicios a las entidades para obtener un dominio más compacto.

En el último post, al explicar las ventajas e inconvenientes de encapsular la lógica en entidades, indicaba que un problema potencial era que aumentábamos el acoplamiento entre diferentes componentes del sistema.

Al pasar lógica a las entidades, había ocasiones en que teníamos que pasarle a las entidades servicios como argumentos de métodos para que pudiesen utilizarlos, quedando acoplados entre sí la entidad y el servicio recibido como argumento. Concretamente, en el ejemplo que veíamos para la confirmación de un pedido, acabábamos con un método Confirm con este aspecto:

public class Order
{
  // Otros métodos, propiedades, etc.
 
  public void Confirm(Carrier carrier, IShippingCostCalculator calculator) 
  {
    // ...
  }
}

Supongamos que ahora el proceso de confirmación de pedido implica más pasos, por ejemplo enviar un email de confirmación al cliente y reservar el stock de las unidades pedidas. Esa lógica de coordinación podríamos dejarla en un servicio externo o, como vimos en el post anterior, incluirla en el método Confirm añadiendo parámetros para representar un IEmailSender y un IWarehouseService. Lo malo es que nuestro método Confirm empezaría a mezclar muchas responsabilidades y a tener una lista de parámetros un tanto desagradable.

OJO: No perdáis de vista que esto es sólo un ejemplo, en la vida real sería discutible que esos procesos quisiéramos hacerlos en el mismo momento de confirmar el pedido y que quisiéramos hacerlos dentro del mismo bounded context que la confirmación, pero para el ejemplo vamos a asumir que es así.

Eventos de dominio

Los eventos de dominio no son algo precisamente novedoso. La primera vez que leí sobre ellos fue en este post de Udi Dahan del 2009 que os recomiendo que leáis detenidamente. La idea es representar cosas que han sucedido en nuestro dominio y poder detectar cuándo se producen los eventos para actuar en consecuencia.

Existen varias formas de implementarlos, pero lo más normal es tener clases que representen cada evento de dominio y una fachada estática que nos permita «disparar» los eventos.

En una versión muy minimalista podríamos tener algo así:

// Interface marcador para eventos de dominio
public interface IDomainEvent {}

// Interface marcador para manejadores de eventos
public interface IDomainEventListener {}

// Interface concreto para manejar un evento
public interface IDomainEventListener<T> : IDomainEventListener where T : IDomainEvent
{
  void Handle(T @event);
}

// Fachada estática para disparar los eventos
public static class DomainEvents
{
  private static readonly List<object> listeners = new List<object>();
 
  public static void Register(IDomainEventListener handler)
  {
     listeners.Add(handler);
  }
 
  public static void Raise<T>(T @event) where T : IDomainEvent
  {
    var thisEventListeners = listeners.Where(x => x is IDomainEventListener<T>).ToArray();
   
    foreach (var handler in thisEventListeners)
      handler.Handle(@event);
  }
}

La implementación de la clase DomainEvents es muy simple y está pensada para entender el ejemplo, pero no es una implementación apta para producción. Tiene varias limitaciones, la más importante de ellas es que tiene problemas en un entorno multihebra. Si quieres ver una implementación más completa, échale un vistazo al post de Udi Dahan.

Una vez que tenemos implementada esta pequeña infraestructura, podríamos reconvertir nuestro método Confirm de la clase Order:

public class Order
{
  public void Confirm(Carrier carrier, IShippingCostCalculator shippingCostCalculator)
  {
    this.carrier = carrier;
    this.shippingCost = calculator.CalculateShippingCost(carrier, deliveryAddress.ZipCode, TotalAmount);
    this.status = OrderStatus.Confirmed;  

    DomainEvents.Raise(new OrderConfirmed(this));
  }
}

Ahora, además del proceso que hacíamos anteriormente, cuando confirmamos el pedido lanzamos un evento para indicar que el pedido ha sido confirmado. Este evento tiene el siguiente aspecto:

public class OrderConfirmed : IDomainEvent
{
  public readonly Order Order;

  public OrderConfirmed(Order order)
  {
    Order = order;
  }
}

Los eventos siempre tendrán nombres en pasado puesto que representan cosas que ya han sucedido. Ahora, podríamos definir manejadores de eventos para gestionarlos:

public class EmailSender : 
  IDomainEventListener<OrderConfirmed>,
  IDomainEventListener<OrderCancelled>
{
  public void Handle(OrderConfirmed @event)
  {
    // Enviar email de confirmación
  }
  
  public void Handle(OrderCancelled @event)
  {
    // Enviar email de cancelación
  }
}

public class Warehouse : 
  IDomainEventListener<OrderConfirmed>,
  IDomainEventListener<OrderCancelled>
{
  public void Handle(OrderConfirmed @event)
  {
    // Reservar stock de los productos incluidos en el pedido
  }
  
  public void Handle(OrderCancelled @event)
  {
    // Liberar el stock reservado para el pedido
  }
}

He incluido dos manejadores de eventos, EmailSender y Warehouse, uno encargado del envío de emails y otro de la gestión del almacén. Cada uno de ellos responde a dos eventos el de OrderConfirmed lanzado cuando se confirma un pedido, y un hipotético OrderCancelled que se lanzaría en caso de que el cliente decidiera cancelar el pedido.

Lo he hecho así para remarcar algo que es muy importante: un manejador de eventos encapsula comportamiento relacionado con un concepto de nuestro dominio y para ello es habitual que tenga que escuchar varios eventos.

Si hubiésemos implementado esto con un servicio, tendríamos concentrada en un método la lógica relacionada con un caso de uso (confirmar un pedido). Al utilizar eventos de dominio, estamos encapsulando la lógica relativa a un concepto (el almacén o el envío de emailings).

La ventaja de este diseño es que nos permite añadir nuevo comportamiento en el dominio sin cambiar lo que ya existe. Sólo necesitamos registrar nuevos manejadores de eventos que escuchen los eventos necesarios para implementar la funcionalidad requerida.

Sobre acoplamiento y dependencias

En este ejemplo, el evento encapsula la clase Order completa, cosa que, sinceramente, no me acaba de gustar y habría que intentar evitar porque va a introducir una dependencia entre los manejadores del evento y la clase Order, pero a veces es lo más cómodo.

Pese a disparar el evento, la parte de cálculo de los gastos de envío sigue realizándose en la clase Order porque no queremos tener que exponer hacia el exterior la posibilidad de modificar el estado interno de la clase Order para que pueda mantener su invariante. Esto es importante: el uso de eventos de dominio no implica que todo haya que hacerlo mediante eventos de dominio.

Notas finales

Los eventos de dominio son una técnica que utilizo bastante para construir modelos más ricos. La facilidad con que se puede añadir nuevo comportamiento al dominio a base de enganchar nuevos manejadores es algo que se agradece mucho y que, sobre todo, permite no tocar el código que funciona cuando tenemos que hacer cosas nuevas (¿a alguien le suena el Open /Close Principle?).

Pese a todo, también tienen algunas pegas que debemos tener en cuenta:

Por una parte, estamos introduciendo una dependencia implícita entre nuestras entidades y la clase estática DomainEvents y, como toda dependencia implícita, es algo oscuro que puede dificultar la comprensión del código, ya que sólo viendo el API de la entidad no podemos saber si se lanzará o no un evento de dominio. Hay gente que además lo ve como un inconveniente a la hora de testear las entidades, pero la verdad es que existen técnicas sencillas para poder testearlas y no suele ser un problema.

Por otra parte, y como siempre que se introducen eventos, estamos ganando flexibilidad a costa de complicar el flujo de la aplicación. Lo que antes era una serie de sentencias que se ejecutaban linealmente, ahora es un evento que se dispara y que será gestionado por manejadores que pueden estar dispersos por la aplicación y de los que además (y esto es muy importante tenerlo en cuenta), no podemos garantizar el orden en que se ejecutan, por lo que deberán ser completamente independientes unos de otros.

En definitiva, los eventos de dominio pueden ayudarnos a encapsular lógica en nuestro modelo de dominio de una forma flexible y fácil de extender, pero como todo, es importante saber qué implicaciones tiene utilizarlos para poder tomar la decisión correcta sobre cuándo y cómo utilizarlos.

13 comentarios en “Desacoplar modelos con Eventos de Dominio

  1. Juanma, grandísimo artículo! Estos últimos posts sobre modelos de dominio y sus intríngulis me está encantando.
    Enhorabuena!!

  2. De que me suena???

    Juanma, un par de notas :

    * Creo que lo másinteresante es dar semántica a estos eventos y no caer en la tentación de hacernos un «crud» para todos los procesos..

    * Hay cosas con las que se debe de lidiar, como que los eventos son sucesos en pasado y por lo tanto puede ser necesario asegurarnos que se lanzan cuando las cosas han ocurrido, de aquí que muchas veces no siempre las podamos ver tal cual lo muestras en OrderConfirmed… ¿Qué pasa si por lo que sea esa persistencia falla?

    * Hay otras interesantes implementaciones basadas en mensajería para dar soporte a integraciones de diferentes BC en diferentes soluciones, creo que Luis comentó que el lo hacía con service bus y el uso de topics… los cuales ya te aportan muchas otras cosas relacionadas como la detección de duplicados, sesiones etc..

    saludos
    Unai

  3. Unai,

    Sobre lo de dar valor semántico a los eventos, estoy completamente de acuerdo. De hecho para un CRUD no sólo sobran los eventos, sino posiblemente todo el modelo de dominio.

    En cuanto a la temporalidad (o más bien transaccionalidad) de la operación, es un tema interesante y es de lo que tratará el post de la semana que viene. Te me has adelantado, pero si quieres en unos días lo discutimos ;)

    Por último, para mi el uso de eventos para comunicar BCs, ya sea a través de un bus, un API, etc., es un caso particular del caso general de usarlos para integrar el modelo con sistemas externos (p.ej. un sistema de medios de pago, de impresión o de envío de emails). Aún así, es cierto que me parece una característica interesante y que en el post puede pasarse por alto.

    Incluso podrías llevar el concepto de los eventos de dominio al extremo y acabar basando todo tu diseño y tu persistencia en ellos al estilo CQRS con event sourcing.

    Saludos.

  4. Vaya, no quería «adelantarme» al siguiente post… ok esperaré a ver que comentas de la transaccionabilidad y la «ubicación» de los eventos de dominio..

    Unai

  5. Hola Juanma

    Me ha gustado bastante el post como los últimos que has escrito sobre el modelo de dominio.

    Solo un apunte, creo que hay una pequeña errata en el código.
    Por un lado defines un interface IDomainEventListener así como sus implimentaciones para EmailSender y Warehouse y sin embargo lo que recibe la fachada DomainEvents es IDomainEventHandler handler

    Saludos

  6. Hola Juanma!

    Esta serie que tienes sobre eventos de dominio me encanta. Que bien encaja todo!! Estoy haciendo alguna prueba tomando como base tus artículos y con este me he encontrado un tema:

    Entiendo que para el registro de los listeners, realizarás algo así en alguna parte del código (por ejemplo para registrar el Handler MailSender):

    var mailSender = new MailSender();
    DomainEvents.Register(mailSender);
    DomainEvents.Register(mailSender);

    Si no he metido el cuezo, creo que si llamas al método Raise tal y como está montado, por ejemplo así:

    DomainEvents.Raise(new OrderConfirmed(this));

    El método Handle de MailSender va a saltar dos veces, porque el handler ha sido registrado dos veces para dar cobertura a dos eventos distintos, pero como se cumple la condición del método Raise…

    Entonces ¿Es correcto realizar el registro de los Listeners así? ¿Me he columpiado? ¿?Es viernes y estoy pensando más en una buena siesta reparadora?

    Un saludo!!

  7. Hola Antonio,

    No sé si lo he entendido bien, pero aun así intento contestarte.

    El registro de handlers lo puede hacerlo como dices, instanciándolos manualmente. También puedes resolverlos de un contenedor IoC para registrarlos (por si tienen dependencias), o puedes hacer que los resuelva «en tiempo real» el dispatcher de eventos, al estilo de lo que pone en este post de Udi Dahan (http://www.udidahan.com/2009/06/14/domain-events-salvation/). Yo personalmente suelo usar la última opción.

    En cuanto a que se ejecute dos veces el manejador, no entiendo el problema. ¿Por qué lo registras dos veces? Si lo registras dos veces, es normal que se ejecute dos veces.

    Creo que me estoy perdiendo alguna parte…

    Un saludo,

    Juanma.

  8. Hola de nuevo Juanma,

    Tienes toda la razón, he puesto mal las líneas que registran el listener. El caso es que cuando haces que la clase MailSender implemente las interfaz genérica con dos tipos distintos, tengo que hacer esto:

    var mailSender = new MailSender();
    DomainEvents.Register<OrderConfirmed>(mailSender);
    DomainEvents.Register<OrderCancelled>(mailSender);

    Y ahí está el verdadero motivo del registro «doble».

    Me estoy perdiendo algo seguro!! He leído también la serie de post de Udi Dahan, que son interesantísimos, y quiero profundizar con calma en el tema. Me parece un enfoque muy bonito, la verdad.

    Mil gracias!

  9. Ahora lo entiendo :-)

    Tienes razón, con la implementación «de juguete» que estaba puesta en el post no podías cubrir el escenario descrito (y bastante habitual) de un sólo servicio que escucha varios eventos.

    La he actualizado para cubrir ese caso. Al registrar un listener, escuchará automáticamente todos los eventos cuyos interfaces implemente.

    ¡Gracias por darte cuenta!

  10. Hola Juanma,

    De nada! Mil gracias a ti por tu dedicación y por compartir todas estas cosas. Son una serie de post excelentes!

    Un saludo!!

Comentarios cerrados.