Cuando iniciamos nuestras andaduras por la orientación a objetos, tenemos claro que las clases deben contener métodos con las funciones clave del comportamiento de la entidad. Pero con el tiempo y la adopción de nuevos patrones de capas, tendemos a volcar toda la lógica de negocio a clases servicio y dejar entidades solo con métodos “get” y “set”.

Cuando aprendemos el paradigma de programación orientada a objetos, empezamos con ejemplos de los que es fácil extraer métodos, herencias e interfaces. Me viene a la mente el caso de la “clase vehículo”, de la que extienden “bicicleta, camión y coche”, en las que cada una de ellas tiene un número particular de ruedas y una implementación concreta de los métodos de desplazamiento. De esta forma, se consigue ilustrar de forma sencilla lo que representa este paradigma de la orientación a objetos, consiguiendo que tengamos siempre presente una clase con métodos que proveen el comportamiento inherente de la clase.

Me gustaría formular la siguiente pregunta: ¿a quién no le ha pasado llegar a un proyecto y
encontrarse con que las clases de dominio son “meros contenedores de getters y setters”?
Como resalta Martin Fowler en su artículo “Anemic Domain Model”, utilizamos modelos que nos indican que la lógica de negocio tiene que estar en servicios en vez de en clases de dominio, resultando contrario a la idea básica del diseño orientado a objetos en lo que define como un antipatrón. Y es que, como indica, acarreamos todos los costes, como los de mapear a fuentes externas de datos y perdemos los beneficios al enviar la lógica de negocio a servicios, fuera del dominio.

Según Evans y Fowler, la capa de servicio debe ser fina y la lógica de negocio residir en el dominio para no privarnos de todo el beneficio que aporta.

Leer todo esto me hizo reflexionar y darme cuenta de que, con el tiempo, nos vemos arrastrados con nuevas formas de hacer el trabajo y, cuando intentamos adoptarlas, olvidamos las ventajas de patrones que aplicábamos con éxito hasta ahora.

Por ello, me gustaría compartir la experiencia de un caso real en un proyecto donde pasar de un modelo anémico, enriqueciendo el dominio con lógica, hizo más comprensible el servicio reduciendo, además, duplicidad de código.

Invito con ello a dar importancia a tener en cuenta otros puntos de vista, a dar énfasis al diseño de aplicaciones antes de iniciar un desarrollo y a recordar que el código es siempre una herencia que dejamos al que llega detrás nuestro y que, en gran número de ocasiones, serán “yos del futuro” que no recuerdan haber tocado nada de esa aplicación.

Caso de uso

Una importante empresa del sector del comercio de alimentación necesita desarrollar una aplicación cuyo propósito será monitorizar los beneficios obtenidos en tiempo real, conocer el origen de esos beneficios, potenciar los puntos fuertes y mejorar los débiles.

El cliente nos provee un servicio API que nos ofrece el coste del producto, el precio neto de venta y nos pide que contabilicemos el beneficio de cada uno de sus helados y su rentabilidad, agrupado por sabor, en una primera aproximación del MVP.

Dado este escenario, entendemos oportuno definir un dominio con una clase para contener los datos de venta, que denominamos SquisheeSale, y otra que contendrá el beneficio y coste para cada sabor.

public record SquisheeProfitability(Float profitability, Float profitAmount) {

}

public record SquisheeSale(FlavourEnum flavour, String shopId, float price, float cost) {

}

Definimos un servicio con toda la lógica de negocio para el caso de uso, denominándolo SquisheeByFlavourService.

@Service
@RequiredArgsConstructor
public class SquisheeByFlavourService {

  private final SquiseePort squiseePort;

  public Map<FlavourEnum, SquisheeProfitability> getSquisheeProfitabilityByFlavour() {
    return squiseePort.getSquiseeByFlavour().stream()
        .collect(Collectors.groupingBy(SquisheeSale::flavour, collectingAndThen(reducing(
                    (s1, s2) -> new SquisheeSale(s1.flavour(), null, s1.price() + s2.price(),
                        s1.cost() + s2.cost())),
                this::getSquiseeProfitability
            ))
        );
  }

  private SquisheeProfitability getSquiseeProfitability(Optional<SquisheeSale> sale) {
    return sale.map(s ->
            new SquisheeProfitability((s.price() - s.cost()) / s.cost(), s.price() - s.cost()))
        .orElse(new SquisheeProfitability(null, null));
  }

}

En una segunda iteración, el cliente solicita que quiere también ver el beneficio en cada una de sus tiendas. Por ello, definimos un nuevo servicio para este nuevo caso de uso, que denominamos SquiseeByShopService:

@Service
@RequiredArgsConstructor
public class SquiseeByShopService {

  private final SquiseePort squiseePort;

  public Map<String, SquisheeProfitability> getSquisheeProfitabilityByShop() {
    return squiseePort.getSquiseeByShop().stream()
        .collect(Collectors.groupingBy(SquisheeSale::shopId, collectingAndThen(reducing(
                    (s1, s2) -> new SquisheeSale(s1.flavour(), null, s1.price() + s2.price(),
                        s1.cost() + s2.cost())),
                this::getSquiseeProfitability
            ))
        );
  }

  private SquisheeProfitability getSquiseeProfitability(Optional<SquisheeSale> sale) {
    return sale.map(s ->
            new SquisheeProfitability((s.price() - s.cost()) / s.cost(), s.price() - s.cost()))
        .orElse(new SquisheeProfitability(null, null));
  }

}

Podríamos seguir definiendo más casos de uso, pero entendemos que es suficiente con estos dos para detectar varias problemáticas del planteamiento inicial. La primera es clara, hemos duplicado la lógica del cálculo de forma idéntica en ambos casos de uso. Entendemos que habría soluciones para unificar esta lógica, susceptible de crecer en otras clases comunes, haciendo uso de un abanico de patrones de diseño, pero ninguno de ellos son el objetivo que aquí nos concierne.

Me gustaría añadir que, con distintas perspectivas y consideraciones en el diseño, tendríamos diferentes escenarios con el mismo problema como, por ejemplo, haber entendido que nuestro dominio solo es la clase SquisheeProfitability. En ese caso, el puerto debería haberse enriquecido con todo el cálculo de la acumulación por clave (tienda o sabor) en, por ejemplo, un “mapper” que transformara la entidad del servicio API a dominio. Seguiríamos manteniendo la duplicidad alejando hacia capas externas nuestra lógica de negocio, entendiendo que se trata de lógica de transformación. Si cambiamos la perspectiva y enriquecemos el dominio con nuevos métodos, nuestro dominio es una clase capaz de calcular beneficio y rentabilidad por sí misma.

public record SquisheeSale(FlavourEnum flavour, String shopId, float price, float cost) {

  public SquisheeSale() {
    this(null, null, 0F, 0F);
  }

  public SquisheeSale accumulate(SquisheeSale other) {
    return new SquisheeSale(
        firstNonNull(this.flavour, other.flavour()),
        firstNonNull(this.shopId, other.shopId()),
        this.price + other.price,
        this.cost + other.cost);
  }

  public Float getProfitability() {
    return (this.price - this.cost) / this.cost;
  }

  public Float getProfit() {
    return this.price - this.cost;
  }

}

Así, las lógicas de negocio se vuelven mucho más finas, permitiendo enviar el objeto de dominio hacia puertos de salida y otorgando su ganada responsabilidad de decidir qué dato necesitará el consumidor que ahí se conecte.

@Service
@RequiredArgsConstructor
public class SquiseeByShopService {

  private final SquiseePort squiseePort;

  public Map<String, SquisheeSale> getSquisheeProfitabilityByShop() {
    return squiseePort.getSquiseeByShop().stream()
        .collect(Collectors.groupingBy(
            SquisheeSale::shopId, Collectors.reducing(new SquisheeSale(), SquisheeSale::accumulate)));
  }

}

@Service
@RequiredArgsConstructor
public class SquisheeByFlavourService {

  private final SquiseePort squiseePort;

  public Map<FlavourEnum, SquisheeSale> getSquisheeProfitabilityByFlavour() {
    return squiseePort.getSquiseeByFlavour().stream()
        .collect(Collectors.groupingBy(SquisheeSale::flavour,
            Collectors.reducing(new SquisheeSale(), SquisheeSale::accumulate)));
  }

}

Conclusión

Hemos analizado cómo el uso de clases vacías de comportamiento, denominadas anémicas, se pueden considerar como un antipatrón al privarnos del beneficio que aporta tener la lógica localizada dentro de nuestro dominio.

Mediante un ejemplo se hace mucho más visible y, en lo personal, espero esta experiencia te haya servido para seguir creciendo en el desarrollo de un código más limpio.

Referencias

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.

Suscríbete