Crear modelos más ricos quitando lógica de los servicios

En el último post explicaba cómo podemos construir modelos más ricos empleando clases en lugar de tipos enumerados. Siguiendo con el tema de añadir peso a nuestro modelo de dominio (por cierto, es importante recordar que podemos tener un modelo de dominio aunque no estemos aplicando estrictamente DDD), en este post vamos a ver cómo pasar lógica a las entidades para añadir riqueza a nuestro modelo de dominio.

Servicios y Entidades

Aunque en terminología DDD pura los servicios y entidades tienen una definición muy concreta, en este post vamos a referirnos a ellos de una forma más amplia, considerando servicios aquellas clases que no tienen estado y se limitan a coordinar operaciones, y entidades a aquellas clases que representan conceptos más «tangibles» en el dominio y que contienen estado y comportamiento. Para que nos entendamos, en una tienda online un servicio podría ser un IOrderProcessor encargado de procesar pedidos y una entidad un Order que representa un pedido.

Normalmente en la frontera de nuestro modelo encontraremos servicios que recibirán entradas externas a través de un controlador de una aplicación ASPNET MVC, WebAPI, un bus de mensajes o similar, y se encargarán de recuperar entidades de la base de datos y desencadenar acciones sobre ellas. El problema es que muchas veces lógica que es importante desde el punto de vista de nuestro dominio queda encapsulada en esos servicios en lugar de estar en nuestro dominio, que es donde pertenece.

Por ejemplo, podemos tener el siguiente diseño para confirmar un pedido. A través de un OrderProcessor se recupera el pedido (Order) de la base de datos y la agencia de transportes (Carrier) que se encargará de enviarlo, y se utiliza un IShippingCostCalculator para obtener el coste del envío en función del transportista, la dirección de envío y el valor del envío:

public interface IShippingCostCalculator
{
  decimal CalculateShippingCost(Carrier carrier, string zipCode, decimal shipmentValue);
}

public class OrderProcessor
{
  // Dependencias inyectadas por constructor... 
  
  public void ConfirmOrder(int orderId, int carrierId)
  {
    var order = orderRepository.FindById(orderId);
    var carrier = carrierRepository.FindById(carrierId);
    
    var zipCode = order.DeliveryAddress.ZipCode;
    var shipmentValue = order.TotalAmount;
    
    var shippingCost = shippingCostsCalculator.CalculateShippingCost(carrier, zipCode, shipmentValue);
    order.ShippingCost = shippingCost;
    order.Carrier = carrier;
    
    order.Status = OrderStatus.Confirmed;
  }
}

Este código se correspondería con un transaction script ejecutándose sobre un modelo anémico. No es que sea algo terrible, y de hecho hay muchas aplicaciones que funcionan así y lo hacen muy bien, pero desde el punto de vista de un modelo orientado a objetos, tiene ciertas carencias:

  • La clase Order no tiene forma de proteger su invariante. Si asumimos que un pedido no puede pasar a estado confirmado sin que se haya establecido su transportista ni sus costes de envío, con este diseño no podemos garantizarlo de ninguna forma.
  • Estamos exponiendo el estado interno de Order en lo que podríamos considerar una violación del principio tell, don’t ask.
  • Estamos acoplando la forma de obtener un pedido y un transportista (a través de repositorios en este caso) con la lógica de completar el pedido.
  • Tenemos un método incómodo de testear porque necesitamos utilizar un montón de stubs (o fakes, o lo que más te guste) para poder testear lo que realmente nos interesa (el estado final del objeto Order).

Dejando que la entidad controle su estado

Para mejorar esto (siempre desde el punto de vista de diseño orientado a objetos), podemos pasar la gestión del estado de Order a la propia clase Order, haciendo algo así:

public class OrderProcessor
{
  // Dependencias inyectadas por constructor... 

  public void ConfirmOrder(int orderId, int carrierId)
  {
    var order = orderRepository.FindById(orderId);
    var carrier = carrierRepository.FindById(carrierId);
    
    var zipCode = order.DeliveryAddress.ZipCode;
    var shipmentValue = order.TotalAmount;
    
    var shippingCost = shippingCostsCalculator.CalculateShippingCost(carrier, zipCode, shipmentValue);
    
    order.Confirm(carrier, shippingCost);
  }
}

public class Order
{
  // Resto de métodos, atributos y propiedades...

  public void Confirm(Carrier carrier, decimal shippingCost)
  {
    this.carrier = carrier;
    this.shippingCost = shippingCost;
    this.status = OrderStatus.Confirmed;
  }
}

Con este cambio conseguimos que sea el objeto Order el que controle su estado, con lo que podemos garantizar más fácilmente su invariante (que el pedido tiene que tener un transportista y un coste de envío asignado para poder ser confirmado).

Podríamos pararnos aquí, y seguramente en muchas situaciones éste sea el momento adecuado para detenerse, pero todavía hay parte de lógica que está en el servicio y que tal vez nos interese acercar al dominio.

El cálculo de los gastos de envío, aunque parece que está encapsulado en IShippingCostCalculator, en realidad está repartido entre IShippingCostCalculator y OrderProcessor, que es quien decide de dónde obtener la dirección de envío y de dónde obtener el valor del envío. ¿Qué pasa si a la hora de valorar los gastos de envío queremos tener en cuenta no el importe del pedido, sino el importe del pedido sin impuestos, o con descuentos incluidos? Esa lógica que debería estar en nuestro dominio realmente queda en manos de un servicio externo.

Por otra parte, si pensamos en la forma de utilizar nuestro modelo de dominio, cuando vamos a confirmar un pedido sabemos que hay que usar el método Order.Confirm(Carrier carrier, decimal shippingCost) pero ¿cómo obtenemos el valor de shippingCost? Nos arriesgamos a que un futuro usuario de nuestro dominio (o sea, nosotros dentro de 4 meses) calculemos mal el valor de shippingCost antes de pasárselo a Order.

Pasando toda la lógica a la entidad

Para evitar esto, podemos darle una vuelta de tuerca más y llegar a esto:

public class OrderProcessor
{
  public void ConfirmOrder(int orderId, int carrierId)
  {
    var order = orderRepository.FindById(orderId);
    var carrier = carrierRepository.FindById(carrierId);
    
    order.Confirm(carrier, shippingCostCalculator);
  }
}

public class Order
{
  // Resto de métodos, atributos y propiedades...
  
  public void Confirm(Carrier carrier, IShippingCostCalculator calculator)
  {
    this.carrier = carrier;
    this.shippingCost = calculator.CalculateShippingCost(carrier, deliveryAddress.ZipCode, TotalAmount);
    this.status = OrderStatus.Confirmed;
  }
}

¿Qué hemos conseguido con esto? Lo primero es que nuestro servicio OrderProcessor ahora no tiene nada de lógica. Lo único que hace es obtener las entidades de nuestro modelo de dominio y ponerlas a trabajar. Además, toda la lógica queda encapsulada en el método Order.Confirm() que, además, es bastante cómodo de testear (hay que usar un stub para IShippingCostCalculator, pero al menos sólo es uno y sólo para una llamada).

Ahora el sistema de tipos juega a nuestro favor y cuando queremos confirmar un pedido no habrá dudas sobre de dónde vienen los gastos de envío: vienen de un IShippingCostCalculator.

No es oro todo lo que reluce

Independientemente del ejemplo, del que podríamos discutir si la asignación de responsabilidad en este caso concreto es la correcta, lo interesante de esta técnica es que podemos conseguir un dominio muy encapsulado, con un control muy férreo de las operaciones que se pueden realizar para garantizar la corrección y que expone un API compacta hacia el exterior, lo que nos ayuda a evitar problemas en la forma de usarlo.

Para los que están empezando ahora a pensar «orientado a objetos» y a plantearse cambiar de un enfoque data centric a un enfoque más model centric, cosas como ésta pueden parecer una maravilla, y lo cierto es que son muy útiles, pero también tienen sus problemas.

El mayor problema de usar un diseño de este estilo es que estamos introduciendo un gran acoplamiento entre los distintos componentes del sistema. En el escenario intermedio, al confirmar el pedido sólo necesitamos un parámetro decimal, aunque no podíamos garantizar que se calculase correctamente. En el último escenario aseguramos que el cálculo se realiza correctamente a costa de introducir una dependencia entre Order e IShippingCostCalculator, lo que podría no ser deseable.

Por otra parte, estamos sacrificando versatilidad en aras de conseguir una mayor corrección. Cuanta más lógica encapsulemos en el dominio, más resistente será (en cuanto a que será más difícil utilizarlo de forma errónea), pero también será más complicado añadir funcionalidad sin modificar el dominio.

Como siempre, se trata de encontrar un equilibrio entre garantizar la corrección y la facilidad de uso, y conseguir un sistema que no sea demasiado monolítico ni rígido. Como ya discutimos por aquí al hablar de diseño de modelos, no hay una respuesta válida para todos los problemas y al final todo depende del contexto.

En general, es útil pensar en términos de bounded contexts para tener en cuenta dónde podemos permitirnos el incremento de acoplamiento, y utilizar esta técnica sólo dentro de cada bounded context y, especialmente, en aquellos que realmente sean críticos para nuestro sistema y que contengan una cantidad de lógica que justifique el uso de un modelo complejo.

5 comentarios en “Crear modelos más ricos quitando lógica de los servicios

  1. Muy bien, y sobre todo me ha gustado la puntualización final <> porque es dónde más se suele pecar, en tratar todo con un mismo martillo..

    a ver si nos volvemos a ver pronto!

  2. vaya, curioso como limpia los tags, con respecto a la puntualización me refería a :
    «es útil pensar en términos de bounded contexts para tener en cuenta dónde podemos permitirnos el incremento de acoplamiento»

    Unai

  3. Muy bueno! Al fin y al cabo aplicar el principio básico de que una clase encapsula datos y comportamiento.
    ¿Hasta que punto? Pues como bien dices cada uno debe considerarlo en su contexto concreto, pero cuanto más posibilidades conozcas mejor puedes decidir.
    Lo dicho muy buen artículo.

  4. Para ir directo al grano en los modelos de negocio especificamente en los diagramas de clases donde se definen el dominio de entidades es ahí que cada entidad tiene sus propias reglas de negocio y así como esta el diseño es como debe ir en la estructura.

Comentarios cerrados.