Aplica la Ley de Demeter

En el que seguimos hablando acerca de la redistribución de responsabilidades. Un sistema orientado a objetos se basa en los mensajes que se envían los objetos entre sí, por lo que resulta importante aprender qué objetos pueden hablar entre sí y cuáles no.

Ley de Demeter

La Ley de Demeter1, que también se conoce como Principio de mínimo conocimiento, dice que un objeto no debería conocer la organización interna de los otros objetos con los que colabora. Lo único que debería saber es cómo comunicarse con ellos. De este modo, al conocer lo mínimo posible, se acopla mínimamente a ellos.

Notas de la segunda edición

Como mencionamos en el anterior, hemos separado los capítulos de Tell, Don’t Ask y Ley de Demeter en dos capítulos distintos. Aunque ambos tratan sobre la redistribución de responsabilidades, el anterior se centra en la encapsulación de la lógica de negocio, mientras que este se centra en la comunicación entre objetos.

Cumpliendo la Ley

Siguiendo la Ley de Demeter, como veremos, un método de una clase solo puede hablar con los objetos a los que conoce. Estos son:

  • La propia clase, de la que puede usar todos sus métodos.
  • Objetos que son propiedades de esa clase.
  • Objetos creados en el mismo método que los usa.
  • Objetos pasados como parámetros a ese método.

La finalidad de la ley de Demeter es evitar el acoplamiento estrecho entre objetos. Si un método usa un objeto, contenido en otro objeto que ha recibido o creado, implica un conocimiento que va más allá de la interfaz pública del objeto intermedio.

Vamos a ver esto con un ejemplo un poco amañado, pero que nos permitirá ilustrar varios problemas, empezando por algunos más superficiales y llegando a otros relacionados con el diseño de la solución.

Aquí tenemos una clase Product que representa un producto en una tienda online. Cada producto tiene un precio unitario y una promoción que se aplica si se compran más unidades de una cantidad determinada.

 1 class Product
 2 {
 3     private float $unitPrice;
 4     private Promotion $promotion;
 5 
 6     public function __construct(float $unitPrice, Promotion $promotion)
 7     {
 8         $this->unitPrice = $unitPrice;
 9         $this->promotion = $promotion;
10     }
11 
12     public function unitPrice(): float
13     {
14         return $this->unitPrice;
15     }
16 
17     public function currentPromotion(): Promotion
18     {
19         return $this->promotion;
20     }
21 }

Aquí tenemos Promotion, que contiene la información sobre el descuento aplicable.

 1 class Promotion
 2 {
 3     private int $threshold;
 4     private float $discountRate;
 5 
 6     public function __construct(int $threshold, float $discountRate)
 7     {
 8         $this->threshold = $threshold;
 9         $this->discountRate = $discountRate;
10     }
11 
12     public function threshold(): int
13     {
14         return $this->threshold;
15     }
16 
17     public function discountRate(): float
18     {
19         return $this->discountRate;
20     }
21 }

Ambas clases ya nos muestran un problema de padecer el smell Data Class o, dicho en otras palabras, de ser clases anémicas y no tener comportamiento propio. No obstante, vamos a seguir adelante con el ejemplo a ver a dónde nos lleva.

Esta es la calculadora de precios. Aquí podemos ver que necesitamos obtener Promotion con el fin de calcular los descuentos aplicables y el límite mínimo de unidades.

 1 class PriceCalculator
 2 {
 3     public function calculatePrice(Product $product, int $units): float
 4     {
 5         $promotion = $product->currentPromotion();
 6         if ($units > $promotion->threshold()) {
 7             return $product->unitPrice() * $units * (1 - $promotion->di\
 8 scountPct());
 9         }
10         return $product->unitPrice() * $units;
11     }
12 }

En el ejemplo, el método calculatePrice obtiene el descuento aplicable llamando a un método de Product, que devuelve otro objeto al cual le preguntamos sobre los datos necesarios para realizar el descuento. Aquí tenemos un caso de Inappropriate Intimacy, porque PriceCalculator está hablando con un objeto que no debería conocer directamente, ya que está dentro de Product.

La violación de la Ley de Demeter se produce aquí porque el método calculatePrice está hablando con un objeto que no conoce directamente, sino a través de otro. ¿Qué objeto es este y cuál es su interfaz? Podemos suponer que se trata de un objeto Promotion, pero eso es algo que sabemos nosotros, no el código. Este es el exceso de conocimiento que la Ley de Demeter trata de evitar.

¿Cómo podemos refactorizar este tipo de problemas y cumplir con la Ley de Demeter? Antes de nada, hay que advertir que no existe una solución única. Esta dependerá del contexto y de la correcta atribución de responsabilidades a los distintos objetos. Así que vamos a analizar varias opciones:

Pasa el objeto intermedio a otro método del objeto consumidor

Aplicando la letra de la Ley de Demeter, un método que recibe el objeto intermedio como parámetro no viola la ley. Lo ideal sería que fuera el único parámetro del método, cosa que no siempre es fácil de conseguir. Por ejemplo, podríamos hacer algo así, devolviendo el porcentaje de descuento, o cero si no se puede aplicar:

 1 class PriceCalculator
 2 {
 3     public function calculatePrice(Product $product, int $units): float
 4     {
 5         $discountPct = $this->discountPct($product->currentPromotion(),\
 6  $units);
 7         
 8         return $product->unitPrice() * $units * (1 - $discountPct);
 9     }
10 
11     private function discountPct(Promotion $promotion): float
12     {
13         if ($units > $promotion->threshold()) {
14             return $promotion->discountRate();
15         }
16         return 0;
17     }
18 }

No se trata de la mejor solución posible, pero es un paso que nos puede ayudar a entender mejor lo que necesitamos del objeto extraño y así poder reorganizar las responsabilidades. Así, por ejemplo, nos ayuda a ver que una posible solución sería mover esa lógica a Product.

Encapsular el comportamiento en el objeto que conocemos (Tell, Don’t Ask)

En algunos casos, en lugar de pedirle a nuestro objeto conocido que nos dé el otro objeto, podemos pedirle que haga lo que sea necesario con él y nos devuelva el resultado. En este caso, podríamos pedirle a Product que nos devuelva el precio total para un número de unidades. De esta forma, ni siquiera necesitaríamos saber si hay una promoción o no.

 1 class Product
 2 {
 3     public function totalPrice(int $units): float
 4     {
 5         $promotion = $this->currentPromotion();
 6         if ($units > $promotion->threshold()) {
 7             return $this->unitPrice() * $units * (1 - $promotion->disco\
 8 untRate());
 9         }
10         return $this->unitPrice() * $units;
11     }
12 }
13 
14 class PriceCalculator
15 {
16     public function calculatePrice(Product $product, int $units): float
17     {
18         return $product->totalPrice($units);
19     }
20 }

Esta es una solución interesante para muchos casos. Si tenemos modelos anémicos, tendemos a usar sus propiedades de forma directa, o a través de getters, para hacer cálculos. En lugar de eso, podemos pedirle al objeto que haga el cálculo por nosotros, ya que contiene toda la información necesaria y, por tanto, debería ser su responsabilidad. Es, ni más ni menos, que el Information Expert.

Esto es una clara mejora con respecto a la situación inicial, pero seguramente podemos hacerlo mejor. Hay un par de cosas que nos chirrían:

  • ¿Por qué no aplicar el mismo principio Tell, Don’t Ask a Promotion? Estamos preguntando por el descuento y el umbral, cuando podríamos pedirle a Promotion que nos devuelva el descuento aplicable para el número de unidades.
  • ¿Tiene sentido que Product sepa cómo calcular el precio total? Es más, ¿tiene sentido que Product sepa si hay una promoción o no? ¿No sería mejor que otro objeto se encargara de eso?

Moviendo responsabilidades: otra vez Tell, Don’t Ask

Retomemos el primer intento de refactor. En ese caso, pasábamos el objeto intermedio como parámetro a otro método, siguiendo la letra de la ley de Demeter. Igualmente, podemos ver que estamos preguntando a Promotion por el descuento y el umbral, cuando podríamos pedirle que nos devuelva el descuento aplicable para un número de unidades.

 1 class PriceCalculator
 2 {
 3     public function calculatePrice(Product $product, int $units): float
 4     {
 5         $discountPct = $this->discountPct($product->currentPromotion(),\
 6  $units);
 7         
 8         return $product->unitPrice() * $units * (1 - $discountPct);
 9     }
10 
11     private function discountPct(Promotion $promotion, int $units): flo\
12 at
13     {
14         if ($units > $promotion->threshold()) {
15             return $promotion->discountRate();
16         }
17         return 0;
18     }
19 }

Es decir, dado que Promotion es el que sabe cómo calcular el descuento aplicable, es lógico que sea él quien lo haga. Y lo único que tenemos que hacer es encapsular esa lógica en un método de Promotion. De nuevo, un caso de Information Expert.

 1 class Promotion
 2 {
 3     public function discountPct(int $units): float
 4     {
 5         if ($units > $this->threshold()) {
 6             return $this->discountRate();
 7         }
 8         return 0;
 9     }
10 }
11 
12 class PriceCalculator
13 {
14     public function calculatePrice(Product $product, int $units): float
15     {
16         $discountPct = $this->discountPct($product->currentPromotion(),\
17  $units);
18         
19         return $product->unitPrice() * $units * (1 - $discountPct);
20     }
21 
22     private function discountPct(Promotion $promotion, int $units): flo\
23 at
24     {
25         return $promotion->discountPct($units);
26     }
27 }

Pero, como hemos dicho, ya que Promotion está contenido en Product, lo suyo sería que pedirle a Product que se encargue de gestionar esos cálculos. De esta forma, Product sería el que conoce la estructura de precios y cómo aplicar los descuentos y no tendríamos que saber nada de Promotion fuera de Product.

 1 class Promotion
 2 {
 3     public function discountPct(int $units): float
 4     {
 5         if ($units > $this->threshold()) {
 6             return $this->discountRate();
 7         }
 8         return 0;
 9     }
10 }
11 
12 class Product
13 {
14     public function totalPrice(int $units): float
15     {
16         $promotion = $this->currentPromotion();
17         return $this->unitPrice() * $units * (1 - $promotion->discountP\
18 ct());
19     }
20 }
21 
22 class PriceCalculator
23 {
24     public function calculatePrice(Product $product, int $units): float
25     {
26         return $product->totalPrice($units);
27     }
28 }

Como nos preguntábamos antes, nos molesta un poco que Product sepa tanto sobre precios y promociones. Promotion podría ser mejor lugar para tener la lógica de descuentos. ¿Y si Promotion fuese la encargada de calcular el precio total?

 1 class Promotion
 2 {
 3     public function totalPrice(float $unitPrice, int $units): float
 4     {
 5         if ($units > $this->threshold()) {
 6             return $unitPrice * $units * (1 - $this->discountRate());
 7         }
 8         
 9         return $unitPrice * $units;
10     }
11 }
12 
13 class Product
14 {
15     public function totalPrice(int $units): float
16     {
17         $promotion = $this->currentPromotion();
18         
19         return $promotion->totalPrice($this->unitPrice(), $units);
20     }
21 }
22 
23 class PriceCalculator
24 {
25     public function calculatePrice(Product $product, int $units): float
26     {
27         return $product->totalPrice($units);
28     }
29 }

Esta solución es mucho mejor porque las responsabilidades están mejor repartidas:

  • PriceCalculator solo coordina la llamada a Product con el número de unidades de las que vamos a calcular el importe total.
  • Product solo tiene que preocuparse de su precio unitario y de aplicar la promoción.
  • Promotion encapsula la estrategia de descuentos y hace los cálculos a partir de la información bruta de Product y el número de unidades.

Reasignación de responsabilidades

Si lo piensas cuidadosamente, esta nueva distribución de responsabilidades tiene mucho sentido. Promotion está encapsulando una estrategia de descuentos, mientras que Product solo tiene que preocuparse de su precio unitario y de aplicar la promoción. Esto parece vacíar de contenido a PriceCalculator, por lo que podríamos eliminarlo. Sin embargo, lo habitual sería que una tienda on-line pueda tener varios tipos de promociones y estrategias de precios. Sigue teniendo sentido que haya un objeto que se encargue de calcular el precio total de un producto, pero lo que no está bien es que una promoción está tan estrechamente ligada a un producto. Querremos poder tener la flexibilidad de gestionar los productos, por un lado, y las promociones y estrategias de precios, por otro.

Esto nos debería sonar a un patrón Strategy para decirle a PriceCalculator qué estrategia de precios aplicar al producto que le pasamos. Por tanto, en lugar de que Price contenga una referencia a Promotion, lo que hacemos es separarlos e inyectar Promotion cuando solo cuando se necesita:

 1 class Promotion
 2 {
 3     private float $discountRate;
 4     private int $threshold;
 5     
 6     public function totalPrice(float $unitPrice, int $units): float
 7     {
 8         if ($units > $this->threshold) {
 9             return $unitPrice * $units * (1 - $this->discountRate);
10         }
11         return $unitPrice * $units;
12     }
13 }
14 
15 class Product
16 {
17     private float $unitPrice;
18     
19     public function totalPrice(int $units, Promotion $promotion): float
20     {
21         return $promotion->totalPrice($this->unitPrice, $units);
22     }
23 }
24 
25 class PriceCalculator
26 {
27     public function calculatePrice(Product $product, int $units, Promot\
28 ion $promotion): float
29     {
30         return $product->totalPrice($units, $promotion);
31     }
32 }

Aprovecho para llamar tu atención sobre la clase Product. A fin de no exponer sus propiedades, sino de encapsularlas, lo que hacemos es pasarle el objeto Promotion y que sea él quien se encargue de calcular el precio total, para lo cual Product le pasa la información de su precio unitario. Esto es un ejemplo de un patrón Double Dispatch, en el que un objeto recibe otro para poder pasarle información. De este modo, no tenemos que exponer las propiedades de Product a Promotion, y tampoco al revés.

1 class Product
2 {
3     private float $unitPrice;
4     
5     public function totalPrice(int $units, Promotion $promotion): float
6     {
7         return $promotion->totalPrice($this->unitPrice, $units);
8     }
9 }

Ahora Product tiene el acoplamiento mínimo con Promotion y esta no se acopla a nada, mientras que PriceCalculator solo se encarga de coordinarlos.

Resumen del capítulo

La Ley de Demeter o Principio de Mínimo Conocimiento nos dice con qué otros objetos podemos hablar, que serán aquellos que conozcamos directamente:

  • El mismo objeto, del cual puede usar todos sus métodos.
  • Objetos que sean propiedades de ese objeto.
  • Objetos creados en el mismo método que los usa.
  • Objetos pasados como parámetros a ese método.

Aplicando este principio, podemos mejorar la organización de nuestro código distribuyendo las responsabilidades entre los objetos participantes y manteniendo el acoplamiento controlado.