Refactor y rediseño
En los siguientes capítulos hablaremos de acciones de refactor de mayor calado. Aquí ya no se trata de clarificar el código, sino de reorganizar el conocimiento que representa, aplicando mejores principios y patrones de diseño y arquitectura.
Dónde poner el conocimiento
En el que recurrimos a principios básicos de asignación de responsabilidades para averiguar qué objetos deberían saber qué cosas.
Notas de la segunda edición
En este capítulo introduciremos los principios GRASP, los cuales habíamos mencionado en la edición anterior, por lo que cambiamos casi por completo los ejemplos. Por otro lado, hemos eliminado la sección sobre el patrón Specification, que resulta demasiado sofisticado como para ser útil en el contexto de refactoring que estamos tratando.
Refactorizar para trasladar conocimiento
Solemos decir que refactorizar tiene que ver con el conocimiento y el significado. Fundamentalmente, porque lo que hacemos es aportar significado al código con el objetivo de que este represente de una manera fiel y dinámica el conocimiento cambiante que tenemos del negocio del que nos ocupamos. Y, por otro lado, porque lo que perseguimos con un refactoring continuado es representar nuestro entendimiento actual del negocio en el código con la mayor precisión posible.
En el código de una aplicación tenemos objetos que representan alguna de estas cosas:
- Conceptos, ya sea en forma de entidades o de value objects. Las entidades representan conceptos que nos interesan por su identidad y tienen un ciclo de vida. Los value objects representan conceptos que nos interesan por su valor.
- Relaciones entre esos conceptos, que suelen representarse en forma de agregados y que están definidas por las reglas de negocio.
- Procesos que hacen interactuar los conceptos conforme a reglas de negocio también.
Uno de los problemas que tenemos que resolver al escribir código y al refactorizarlo es dónde poner el conocimiento y, más exactamente, las reglas de negocio.
Si hay algo que caracteriza al legacy es que el conocimiento sobre las reglas de negocio suele estar disperso a lo largo y ancho del código, en los lugares más imprevisibles y representado de las formas más dispares. El efecto de refactorizar este código es, esperamos, llegar a trasladar ese conocimiento al lugar donde mejor nos puede servir.
Pero incluso en código nuevo, el conocimiento puede estar disperso. Puede ser debido a que no conocemos bien nuestro negocio todavía, o porque no somos capaces de expresarlo mejor en un momento dado. Además, el código siempre va a tener un cierto desfase con lo que sabemos del negocio, porque el negocio cambia y nuestro conocimiento de él también.
Para saber donde colocar el conocimiento en el código podemos recurrir a varios principio y patrones.
Principios básicos
Principio de abstracción. Benjamin Pierce formuló el principio de abstracción en su libro Types and programming languages:
Each significant piece of functionality in a program should be implemented in just one place in the source code. Where similar functions are carried out by distinct pieces of code, it is generally beneficial to combine them into one by abstracting out the varying parts.
DRY. Por su parte, Andy Hunt y David Thomas, en The Pragmatic Programmer, presentan una versión de este mismo principio que posiblemente te sonará más: Don’t Repeat Yourself:
Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.
En esencia, la idea que nos interesa recalcar es que cada regla de negocio estará representada en un único lugar y esa representación será la de referencia para todo el código.
Los principios que hemos enunciado se centran en el carácter único de la representación, pero no nos dicen dónde debe residir la misma. Lo cierto es que es un tema complejo, pues es algo que puede admitir varias interpretaciones y puede depender del estado de nuestro conocimiento actual del negocio.
Buscando dónde guardar el conocimiento: patrones GRASP
Los patrones GRASP son un conjunto de patrones de diseño que nos ayudan a asignar responsabilidades a los objetos de un sistema. Fueron introducidos por Craig Larman en Applying UML and Patterns, que identifica una serie de preguntas que nos podemos hacer para saber dónde colocar el conocimiento en un sistema orientado a objetos.
Regla general: en los objetos que tienen la información necesaria
Este patrón se llama Information Expert y es el más general de todos. Una responsabilidad se asignará al objeto que tenga la información necesaria para ejercerla.
En el contexto de refactoring, lo que nos dice este principio es que cuando estamos usando la información contenida en un objeto, ese uso o comportamiento tendría que estar en ese mismo objeto. Expresándolo en otras palabras, quiere decir que un objeto debe ser capaz de realizar todos los comportamientos que le sean propios, dentro del contexto de nuestra aplicación. Para ello no debería necesitar exponer sus propiedades internas o estado.
Por tanto, cuando preguntamos a un objeto sobre su estado y realizamos acciones basadas en la respuesta, lo suyo debería ser encapsular esas acciones en forma de comportamientos del objeto. Para ello, podemos seguir el principio Tell, don’t ask. Esto es, en lugar de obtener información de un objeto para operar con ella y tomar una decisión sobre ese objeto, le pedimos que lo haga él mismo y nos entregue un resultado si es adecuado para el contexto. Esto lo trataremos con más detalle en el siguiente capítulo.
Los value objects y entidades son lugares ideales para encapsular conocimiento de dominio.
Supongamos que en nuestro negocio estamos interesados en ofrecer productos o ventajas a usuarios cuya cuenta de correo pertenezca a ciertos dominios. Un ejemplo de esto son los programas de beneficios de algunas empresas. El correo electrónico es, pues, un concepto importante del negocio y lo representamos mediante un value object:
1 class Email
2 {
3 private string $email;
4
5 public function __construct(string $email)
6 {
7 $this->email = $email;
8 }
9
10 public static function valid(string $email)
11 {
12 if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
13 throw new InvalidArgumentException(sprintf('%s is not valid\
14 email.', $email));
15 }
16 return new self($email)
17 }
18
19 public function __toString(): string
20 {
21 return $this->$email;
22 }
23 }
En un momento dado nos puede interesar saber si un empleado tiene acceso a un beneficio concreto, cosa que vamos a controlar obteniendo la lista de dominios corporativos que lo ofrecen. Podríamos hacerlo de esta manera:
1 class CanSeeBenefit
2 {
3 public function byCorporateEmail(Email $email)
4 {
5 [, $domain] = explode('@', (string)$email);
6
7 if (!in_array($domain, $this->getBenefitDomains->execute(), tru\
8 e)) {
9 return false;
10 }
11
12 return true
13 }
14 }
Como se puede ver, estamos pidiendo su valor a $email para poder extraer el dominio y compararlo con la lista de dominios. Por definición, sabemos que un email se compone de un nombre de usuario y un dominio, así que lo lógico sería preguntarle a $email por su dominio y no calcularlo fuera de él.
1 class Email
2 {
3 private string $email;
4
5 public function __construct(string $email)
6 {
7 $this->email = $email;
8 }
9
10 public static function valid(string $email)
11 {
12 if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
13 throw new InvalidArgumentException(sprintf('%s is not valid\
14 email.', $email));
15 }
16 return new self($email)
17 }
18
19 public function domain(): string
20 {
21 [, $domain] = explode('@', $this->email);
22
23 return $domain;
24 }
25
26 public function __toString(): string
27 {
28 return $this->$email;
29 }
30 }
Este es un primer paso para trasladar el conocimiento al lugar donde mejor se puede usar.
1 class CanSeeBenefit
2 {
3 public function byCorporateEmail(Email $email)
4 {
5 if (!in_array($email->domain(), $this->getBenefitDomains->execu\
6 te(), true)) {
7 return false;
8 }
9
10 return true
11 }
12 }
Pero en el fondo esto no soluciona completamente el problema. Ahora podemos obtener el dominio de un email y, aunque se obtenga de un cálculo, no deja de ser el acceso a una propiedad. Cierto es que lo hemos implementado de tal forma que necesitamos calcular el dominio, pero podría no ser así. Mira, por ejemplo, esta versión:
1 class Email
2 {
3 private string $username;
4 private string $domain;
5
6 private function __construct(string $username, string $domain)
7 {
8 $this->username = $username;
9 $this->domain = $domain;
10 }
11
12 public static function valid(string $email): self
13 {
14 if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
15 throw new InvalidArgumentException(sprintf('%s is not a val\
16 id email.', $email));
17 }
18
19 [$username, $domain] = explode('@', $email);
20 return new self($username, $domain);
21 }
22
23 public function domain(): string
24 {
25 return $this->domain;
26 }
27
28 public function __toString(): string
29 {
30 return $this->username . '@' . $this->domain;
31 }
32 }
Entonces, ¿qué podríamos hacer? Pues la respuesta es invertir la cuestión, En lugar de extraer si el dominio de Email para mirar si está en la lista que tenemos, lo suyo es pasarle la lista para que nos diga si su dominio está en ella:
1 class Email
2 {
3 private string $username;
4 private string $domain;
5
6 private function __construct(string $username, string $domain)
7 {
8 $this->username = $username;
9 $this->domain = $domain;
10 }
11
12 public static function valid(string $email): self
13 {
14 if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
15 throw new InvalidArgumentException(sprintf('%s is not a val\
16 id email.', $email));
17 }
18
19 [$username, $domain] = explode('@', $email);
20 return new self($username, $domain);
21 }
22
23 public function belongsToOneOfThisDomains(array $domains): bool
24 {
25 return in_array($this->domain, $domains, true);
26 }
27
28 public function __toString(): string
29 {
30 return $this->username . '@' . $this->domain;
31 }
32 }
Ahora podemos usarlo así:
1 class CanSeeBenefit
2 {
3 public function byCorporateEmail(Email $email)
4 {
5 return $email->belongsToOneOfThisDomains($this->getBenefitDomai\
6 ns->execute());
7 }
8 }
Con este cambio resulta que Email ya no tiene que mostrar ninguna de sus propiedades. Esto nos da libertad para cambiar su implementación sin tener que cambiar el código que lo usa. Y, por otro lado, expone comportamiento que puede ser usado por otros objetos interesados.
Quien se ha de encargar de construir un objeto
Este patrón se llama Creator y nos dice que la responsabilidad de crear un objeto debe recaer en aquel que tenga la información necesaria para hacerlo, o bien que coleccione o agrupe los objetos que se van a crear.
El ejemplo paradigmático de este patrón es el de un objeto que representa una factura y sus líneas. La responsabilidad de crear una línea de factura debería recaer en la propia factura, ya que aunque no tenga la información necesaria para crearla, sí que agrupa las líneas de factura. De hecho, las líneas de factura no tienen sentido fuera de una factura.
1 class Invoice
2 {
3 private array $lines = [];
4
5 public function addLine(string $description, float $amount): void
6 {
7 $this->lines[] = new InvoiceLine($description, $amount);
8 }
9 }
1 class InvoiceLine
2 {
3 private string $description;
4 private float $amount;
5
6 public function __construct(string $description, float $amount)
7 {
8 $this->description = $description;
9 $this->amount = $amount;
10 }
11 }
Resumen del capítulo
En este capítulo introducimos algunos patrones útiles para decidir donde poner las responsabilidades en un sistema de software. Estos patrones son los GRASP, que nos ayudan a asignar responsabilidades a los objetos de un sistema. En concreto, hemos visto el patrón Information Expert y el patrón Creator.
Aplica Tell, Don’t Ask
En el que buscamos empujar el comportamiento dentro de nuestros objetos para que sepan hacer cosas por sí mismos, en lugar de preguntarles por lo que saben.
En la primera parte, hemos trabajado refactors muy orientados a mejorar la expresividad del código y a la organización de unidades de código. En esta segunda, estamos enfocándonos en la aplicación de varios principios de diseño orientado a objetos.
Los principios de diseño nos proporcionan criterios útiles tanto para guiarnos en el desarrollo como para evaluar código existente en el que tenemos que intervenir.
Notas de la segunda edición
En esta revisión, hemos dividido este capítulo en dos a fin de tratar de forma más detallada los principios presentados: Tell, Don’t Ask y la Ley de Demeter. Esto nos permitirá explorarlos con más detalle y mejores ejemplos.
Tell, don’t ask
La traducción de este enunciado a español sería algo así como “Pide, no preguntes”. La idea de fondo de este principio es que cuando queremos modificar un objeto basándose su propio estado, no es buena idea preguntarle por su estado (ask), hacer el cálculo y cambiar su estado si fuera preciso. En su lugar, lo propio sería encapsular ese proceso en un método del propio objeto y decirle (tell) que lo realice él mismo.
Dicho en otras palabras: cada objeto es responsable de su estado, representado por sus propiedades internas, y lo mantiene oculto a los demás objetos, que solo conocerán su interfaz pública. Este principio se conoce como Information hiding y es uno de los fundamentos de la orientación a objetos. Consecuentemente, las relaciones entre los objetos deben producirse siempre mediante llamadas a métodos, evitando acceder a las propiedades internas de otros objetos.
Como siempre, vamos a verlo con un ejemplo. Imagina una aplicación para calcular la pintura necesaria para dar color a diversas superficies. Un concepto importante es el área de las superficies a pintar, por lo que necesitamos poder representar esas superficies y calcular su área. Así que empezamos por una clase Square que representa un cuadrado:
1 class Square
2 {
3 private $side;
4
5 public function __construct($side)
6 {
7 $this->side = $side;
8 }
9
10 public function side()
11 {
12 return $this->side;
13 }
14 }
Aquí tenemos una posible implementación de un AreaCalculator que da soporte a cuadrados.
1 class AreaCalculator
2 {
3 public function calculate($square)
4 {
5 $side = $square->side();
6 return $side**2;
7 }
8 }
Por supuesto, necesitamos poder representar más formas para combinarlas. Así que querremos calcular el área de otras figuras geométricas, como el triángulo o el círculo:
1 class Triangle
2 {
3 private $base;
4 private $height;
5
6 public function __construct($base, $height)
7 {
8 $this->base = $base;
9 $this->height = $height;
10 }
11
12 public function base()
13 {
14 return $this->base;
15 }
16
17 public function height()
18 {
19 return $this->height;
20 }
21 }
¿Cómo le explicamos a AreaCalculator que ahora tiene que calcular el área de un triángulo? Podríamos fijarnos en su tipo, para decidir qué algoritmo se debe aplicar:
1 class AreaCalculator
2 {
3 public function calculate($shape)
4 {
5 if ($shape instanceof Square) {
6 $side = $shape->side();
7 return $side**2;
8 }
9
10 if ($shape instanceof Triangle) {
11 $base = $shape->base();
12 $height = $shape->height();
13 return ($base * $height) / 2;
14 }
15 }
16 }
Como se puede ver en el código, AreaCalculator tiene que saber un montón de cosas acerca de los objetos de los que tiene que calcular su área:
- Necesita saber qué tipo de objeto es (
SquareoTriangle). - Necesita saber qué propiedades tiene cada objeto, según el tipo de objeto (
sideobaseyheight). - Necesita saber cómo calcular el área de cada objeto.
Esto viola todos los principios relacionados con la encapsulación. AreaCalculator tiene que conocer demasiados detalles de los objetos que tiene que calcular. Además, si añadimos una nueva figura geométrica, tendremos que modificar AreaCalculator para añadir una nueva decisión:
1 class Circle
2 {
3 private $radius;
4
5 public function __construct($radius)
6 {
7 $this->radius = $radius;
8 }
9
10 public function radius()
11 {
12 return $this->radius;
13 }
14 }
Y una nueva modificación en AreaCalculator:
1 class AreaCalculator
2 {
3 public function calculate($shape)
4 {
5 if ($shape instanceof Square) {
6 $side = $shape->side();
7 return $side**2;
8 }
9
10 if ($shape instanceof Triangle) {
11 $base = $shape->base();
12 $height = $shape->height();
13 return ($base * $height) / 2;
14 }
15
16 if ($shape instanceof Circle) {
17 $radius = $shape->radius();
18 return pi() * $radius**2;
19 }
20 }
21 }
Este código muestra un smell conocido como Data Class: las clases que representan las distintas figuras geométricas solo guardan sus datos, pero no tienen comportamiento. Eso hace que AreaCalculator tenga que aportar ese comportamiento y conocer demasiados detalles de los objetos. Además, si añadimos una nueva figura geométrica, tendremos que modificar AreaCalculator para añadir una nueva decisión. La solución a este problema es aplicar refactorings que nos permitan aplicar el principio Tell, Don’t Ask. Por ejemplo, moviendo el cálculo del área a cada figura geométrica. A este refactoring se le llama Move Method.
Aquí tenemos el cuadrado:
1 class Square
2 {
3 private $side;
4
5 public function __construct($side)
6 {
7 $this->side = $side;
8 }
9
10 public function side()
11 {
12 return $this->side;
13 }
14
15 public function area()
16 {
17 return $this->side**2;
18 }
19 }
El triángulo:
1 class Triangle
2 {
3 private $base;
4 private $height;
5
6 public function __construct($base, $height)
7 {
8 $this->base = $base;
9 $this->height = $height;
10 }
11
12 public function base()
13 {
14 return $this->base;
15 }
16
17 public function height()
18 {
19 return $this->height;
20 }
21
22 public function area()
23 {
24 return ($this->base * $this->height) / 2;
25 }
26 }
Y el círculo:
1 class Circle
2 {
3 private $radius;
4
5 public function __construct($radius)
6 {
7 $this->radius = $radius;
8 }
9
10 public function radius()
11 {
12 return $this->radius;
13 }
14
15 public function area()
16 {
17 return pi() * $this->radius**2;
18 }
19 }
Puesto que ahora cada figura sabe cómo calcular su área, AreaCalculator se simplifica. Vamos a hacerlo por pasos y así entender mejor cómo cambian las cosas cuando dejamos de preocuparnos por las propiedades de los objetos y nos centramos en lo que les podemos pedir que hagan por nosotras. En el primer paso, nos limitamos a reemplazar el cálculo del área por el método area de cada figura. Así quedan tras la introducción del principio Tell, Don’t Ask a nuestro problema.
1 class AreaCalculator
2 {
3 public function calculate($shape)
4 {
5 if ($shape instanceof Square) {
6 return $shape->area();
7 }
8
9 if ($shape instanceof Triangle) {
10 return $shape->area();
11 }
12
13 if ($shape instanceof Circle) {
14 return $shape->area();
15 }
16 }
17 }
Esto nos llevará a descubrir otro patrón de diseño que nos ayudará a simplificar aún más nuestro código: el polimorfismo. Como podemos ver, tenemos varios objetos que responden a los mismos mensajes, aunque sean de tipos distintos. Usando Polimorfismo dejaremos de preocuparnos por el tipo de objeto, y nos centraremos en el mensaje que queremos enviar. Tanto es así, que AreaCalculator puede simplificarse hasta llegar a lo siguiente:
1 class AreaCalculator
2 {
3 public function calculate($shape)
4 {
5 return $shape->area();
6 }
7 }
Pero no nos adelantemos. Dentro de un par de capítulos hablaremos de como aplicar polimorfismo. Antes, nos ocuparemos de ciertas reglas de convivencia que los objetos deben seguir para que todo funcione de la mejor forma posible.
Resumen del capítulo
El principio Tell, Don’t Ask nos ayuda a mejorar la encapsulación de nuestros objetos. En lugar de preguntarles por su estado y calcular el resultado en otro lugar, les pedimos que realicen la operación ellos mismos. De esta forma, cada objeto es responsable de su estado y de las operaciones que se pueden realizar con él.
Por otro lado, la aplicación del principio, puede llevarnos a describir oportunidades para aplicar otros patrones beneficios. Uno de ellos, es el polimorfismo, que nos permite enviar el mismo mensaje a diferentes objetos, para que cada uno de ellos lo interprete de la forma que le corresponda. Gracias al polimorfismo el código se simplifica al organizarlo en objetos pequeños y muy especializados, que son fáciles de entender y de testear. Por otro lado, eso nos permite llevar las decisiones a las factorías, que se encargan de seleccionar el objeto adecuado para cada situación.
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 aPromotionque nos devuelva el descuento aplicable para el número de unidades. - ¿Tiene sentido que
Productsepa cómo calcular el precio total? Es más, ¿tiene sentido queProductsepa 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:
-
PriceCalculatorsolo coordina la llamada aProductcon el número de unidades de las que vamos a calcular el importe total. -
Productsolo tiene que preocuparse de su precio unitario y de aplicar la promoción. -
Promotionencapsula la estrategia de descuentos y hace los cálculos a partir de la información bruta deProducty 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.
Polimorfismo
Donde abordamos el problema de lidiar con variaciones de comportamiento de un objeto basadas en su tipo o, en general, de un aspecto de su estado.
Notas de la segunda edición
Este capítulo no estaba en la edición original, pero hemos reunido aquí elementos procedentes de otros capítulos.
Gestionar variantes de comportamiento basadas en tipo es fácil si sabes cómo
Recordemos el problema del nombre de una persona que usamos para trabajar la introducción de conceptos formados por varios componentes. En este caso, el nombre de una persona puede estar formado por un nombre y un apellido.
1 class SimpleName
2 {
3 private string $name;
4 private string $lastName;
5
6 public function __construct(string $name, string $lastName)
7 {
8 $this->name = $name;
9 $this->lastName = $lastName;
10 }
11
12 public function __toString(): string
13 {
14 return $this->name . ' ' . $this->lastName;
15 }
16 }
Un problema que te podrías encontrar es la diversidad de formatos de nombre que puedes encontrar en el mundo. Los nombres coreanos comienzan por el apellido. En USA es frecuente la inicial entre el nombre y el apellido. En España lo común es tener dos apellidos, mientras que en otros países solo se tiene uno. Una aplicación que pueda dar soporte a toda esta casuística debería tener un diseño que permita adaptarse a estos cambios.
¿Y qué ocurre si necesitamos representar el nombre coreano? Efectivamente, podemos crear un objeto KoreanName que se adapte a las necesidades de este caso:
1 class KoreanName
2 {
3 private string $lastName;
4 private string $name;
5
6 public function __construct(string $lastName, string $name)
7 {
8 $this->lastName = $lastName;
9 $this->name = $name;
10 }
11
12 public function __toString(): string
13 {
14 return $this->lastName . ' ' . $this->name;
15 }
16 }
Para que los objetos sean intercambiables entre sí, deberían declarar una interfaz común. Por ejemplo, PersonName e implementar los métodos necesarios para que puedan ser usados de forma intercambiable. En este caso, es __toString():
1 interface PersonName
2 {
3 public function __toString(): string;
4 }
Modificamos SimpleName y KoreanName para que implementen la interfaz:
1 class SimpleName implements PersonName
2 {
3 private string $name;
4 private string $lastName;
5
6 public function __construct(string $name, string $lastName)
7 {
8 $this->name = $name;
9 $this->lastName = $lastName;
10 }
11
12 public function __toString(): string
13 {
14 return $this->name . ' ' . $this->lastName;
15 }
16 }
1 class KoreanName implements PersonName
2 {
3 private string $lastName;
4 private string $name;
5
6 public function __construct(string $lastName, string $name)
7 {
8 $this->lastName = $lastName;
9 $this->name = $name;
10 }
11
12 public function __toString(): string
13 {
14 return $this->lastName . ' ' . $this->name;
15 }
16 }
Y de este modo, podemos introducir cualquier variante de nombre que necesitemos en nuestra aplicación.
Polimorfismo
Recordemos el calculador de superficies que imaginamos en un artículo anterior, que dejamos así después de aplicar el principio Tell, Don’t Ask:
1 class AreaCalculator
2 {
3 public function calculate($shape)
4 {
5 if ($shape instanceof Square) {
6 return $shape->area();
7 }
8
9 if ($shape instanceof Triangle) {
10 return $shape->area();
11 }
12
13 if ($shape instanceof Circle) {
14 return $shape->area();
15 }
16 }
17 }
Sin embargo, seguimos preguntándole cosas a las figuras geométricas. En este caso, preguntamos si son instancias de Square, Triangle o Circle, que es básicamente preguntarles por una propiedad, y es obvio que la sucesión de if es redundante porque en último les estamos pidiendo que hagan lo mismo, aunque cada una lo resuelva de forma diferente. Dado que cada figura expone un método area, podemos asumir que todas ellas son capaces de responder al mismo mensaje. Por tanto, simplifiquemos AreaCalculator:
1 class AreaCalculator
2 {
3 public function calculate($shape)
4 {
5 return $shape->area();
6 }
7 }
Y aquí podemos ver el principio Tell, Don’t Ask en acción. En lugar de preguntar a las figuras geométricas por su estado y calcular el área en otro lugar, les pedimos que lo hagan ellas mismas independientemente de su tipo. Y lo que antes era un código confuso con montones de líneas, ahora se queda en una sola.
Esta solución es posible gracias al polimorfismo. El polimorfismo es una característica de los lenguajes orientados a objetos gracias a la cual podemos enviar el mismo mensaje a diferentes objetos, para que cada uno de ellos haga lo que le pedimos a su manera particular, que desconocemos.
El problema, en nuestro caso, es que al principio teníamos objetos anémicos a los que no podíamos enviar ningún mensaje. Una vez que hemos aplicado Tell, don’t ask y hemos movido el comportamiento a los objetos, nos hemos dado cuenta de que podríamos beneficiarnos del polimorfismo.
Por otro lado, nos hemos aprovechado de la posibilidad de PHP, y de otros lenguajes, de hacer “duck typing”, gracias a lo cual el método calculate no requiere tipado. En lugar de preguntar por el tipo de objeto, simplemente le pedimos que haga algo. Si el objeto sabe hacerlo, lo hará. Si no, lanzará una excepción. En algunos lenguajes de programación tendríamos que haber declarado el tipo de $shape en el método calculate para saber que se puede llamar a area.
Examinando las regularidades de las clases Square, Triangle y Circle, podemos ver que todas ellas tienen un método area que devuelve un valor numérico. Por tanto, podemos definir una interfaz común para todas ellas:
1 interface Shape
2 {
3 public function area(): float;
4 }
Gracias a esto, AreaCalculator puede confiar en que cualquier objeto que implemente la interfaz Shape tendrá un método area que devolverá un valor numérico. En caso de que el objeto recibido no implemente la interfaz Shape, PHP lanzará una excepción antes incluso de intentar ejecutar el método calculate.
1 class AreaCalculator
2 {
3 public function calculate(Shape $shape): float
4 {
5 return $shape->area();
6 }
7 }
Una ventaja extra, como se puede ver, es que ahora AreaCalculator no tiene que preocuparse por los detalles de cada figura geométrica. Solo necesita saber que el objeto que recibe implementa la interfaz Shape y que, por tanto, tiene un método area que puede llamar. Eso hace innecesario acceder directamente a las propiedades internas de cada figura.
1 class Square implements Shape
2 {
3 private $side;
4
5 public function __construct($side)
6 {
7 $this->side = $side;
8 }
9
10 public function area(): float
11 {
12 return $this->side**2;
13 }
14 }
1 class Triangle implements Shape
2 {
3 private $base;
4 private $height;
5
6 public function __construct($base, $height)
7 {
8 $this->base = $base;
9 $this->height = $height;
10 }
11
12 public function area(): float
13 {
14 return ($this->base * $this->height) / 2;
15 }
16 }
1 class Circle implements Shape
2 {
3 private $radius;
4
5 public function __construct($radius)
6 {
7 $this->radius = $radius;
8 }
9
10 public function area(): float
11 {
12 return pi() * $this->radius**2;
13 }
14 }
Otros escondites del polimorfismo
El tipo muchas veces viene dado por la clase de los objetos, que es lo que ocurre en nuestro ejemplo, pero en otros casos es una propiedad de una única clase.
Imagina que, en lugar del AreaCalculator del ejemplo anterior, tuviésemos algo así. Expresamos el concepto de forma geométrica mediante una clase Shape que puede decirnos su superficie. Sin embargo, cada vez que se lo pedimos tiene que preguntarse “¿de qué tipo soy?”, para saber cómo calcularla. No solo, sino que además tiene que mantener datos internos que no le corresponden, como el lado de un cuadrado, la base y la altura de un triángulo o el radio de un círculo.
1 class Shape
2 {
3 private $type;
4 private $data;
5
6 public function __construct($type, $data)
7 {
8 $this->type = $type;
9 $this->data = $data;
10 }
11
12 public function area(): float
13 {
14 switch ($this->type) {
15 case 'square':
16 return $this->data['side'] ** 2;
17 case 'triangle':
18 return ($this->data['base'] * $this->data['height']) / \
19 2;
20 case 'circle':
21 return pi() * $this->data['radius'] ** 2;
22 default:
23 throw new Exception("Invalid shape type");
24 }
25 }
26 }
Se trata de un caso similar al anterior, pero en lugar de preguntar por el tipo de objeto, que es siempre el mismo, preguntamos por una propiedad interna del objeto. En este caso, el polimorfismo se esconde en la propiedad type de la clase Shape. La solución es la misma: mover el comportamiento a objetos especializados y dejar que ellos se encarguen de calcular su área.
Para hacer este cambio de manera progresiva podríamos empezar por introducir un método factoría. A continuación podríamos reemplazar los usos de new Shape() con Shape::create().
1 class Shape
2 {
3 private $type;
4 private $data;
5
6 private function __construct($type, $data)
7 {
8 $this->type = $type;
9 $this->data = $data;
10 }
11
12 public static function create($type, $data): Shape
13 {
14 return new self($type, $data);
15 }
16
17 public function area(): float
18 {
19 switch ($this->type) {
20 case 'square':
21 return $this->data['side'] ** 2;
22 case 'triangle':
23 return ($this->data['base'] * $this->data['height']) / \
24 2;
25 case 'circle':
26 return pi() * $this->data['radius'] ** 2;
27 default:
28 throw new Exception("Invalid shape type");
29 }
30 }
31 }
A continuación, podríamos crear una clase para cada tipo de forma geométrica y mover el cálculo del área a cada una de ellas. Estas clases heredan extienden Shape y sobrescriben el método area. En este caso, podemos considerar que cada una de ellas es una especialización, pero podríamos hacerlo también con una interfaz común.
1 class Square extends Shape
2 {
3 private $side;
4
5 public function __construct($side)
6 {
7 $this->side = $side;
8 }
9
10 public function area(): float
11 {
12 return $this->side**2;
13 }
14 }
1 class Triangle extends Shape
2 {
3 private $base;
4 private $height;
5
6 public function __construct($base, $height)
7 {
8 $this->base = $base;
9 $this->height = $height;
10 }
11
12 public function area(): float
13 {
14 return ($this->base * $this->height) / 2;
15 }
16 }
1 class Circle extends Shape
2 {
3 private $radius;
4
5 public function __construct($radius)
6 {
7 $this->radius = $radius;
8 }
9
10 public function area(): float
11 {
12 return pi() * $this->radius**2;
13 }
14 }
Ahora, en el constructor estático create de Shape, podemos decidir qué tipo de objeto devolver en función del tipo de forma geométrica que queremos crear.
1 class Shape
2 {
3 public static function create($type, $data): Shape
4 {
5 switch ($type) {
6 case 'square':
7 return new Square($data['side']);
8 case 'triangle':
9 return new Triangle($data['base'], $data['height']);
10 case 'circle':
11 return new Circle($data['radius']);
12 default:
13 throw new Exception("Invalid shape type");
14 }
15 }
16
17 public function area(): float
18 {
19 throw new Exception("Not implemented");
20 }
21 }
Con este cambio, el código que consume Shape, recibirá un objeto del tipo correcto, y no tendrá que preocuparse por la forma geométrica que está manejando. Solo tendrá que llamar al método area y el objeto se encargará de calcularla.
Resumen del capítulo
En este capítulo hemos visto de qué manera el polimorfismo nos permite manejar de forma sencilla las variantes de comportamiento relacionadas con el tipo de los objetos, centrando nuestra atención en los Roles que desempeñan, más que en el tipo de objeto de que se trate.
Esto lleva a mover las decisiones de comportamiento de los objetos fuera de los objetos y hacia las factorías. La factoría nos proporciona el objeto con el que debemos hablar y nos garantiza que el objeto que recibimos sabe cómo responder a los mensajes que le enviamos.
Gestionar estados y transformaciones
Capítulo en el que se descubre como gestionar estados y transformaciones de un objeto, a través de un objeto capaz de decidir qué transformaciones entre estados son válidas y cuáles no.
Notas de la segunda edición
Hemos extraído parte del contenido del capítulo dedicado a enumerables para introducir un patrón que puede ser muy útil en aplicaciones complejas. Se trata de la máquina de estados o State Pattern.
Representando estados
Vamos a empezar por un ejemplo muy sencillo. Es bastante típico tener que modelar que un cierto objeto puede estar en un estado Activo o Inactivo. Así, un Usuario puede enrolarse en nuestro sistema y estar inactivo hasta que verifica su email, lo que sirve como una forma de protección sencilla para evitar que se creen cuentas falsas. La siguiente es una aproximación a cómo podríamos modelar esto, bastante típica:
1 class ActivationStatus
2 {
3 public const ACTIVE = 'active';
4 public const INACTIVE = 'inactive';
5
6 private $status;
7
8 public function __construct(string $status)
9 {
10 if (!in_array($status, [self::ACTIVE, self::INACTIVE])) {
11 throw new \InvalidArgumentException('Invalid status');
12 }
13 $this->status = $status;
14 }
15 }
Hay un par de cosas que nos interesa señalar aquí. En primer lugar, hemos definido dos constantes para representar los dos estados posibles. Esto es una buena práctica, ya que nos permite evitar errores tipográficos y nos facilita la vida a la hora de trabajar con estos valores. En segundo lugar, hemos definido un constructor que recibe un string y comprueba que el valor sea uno de los dos estados posibles. Si no lo es, lanza una excepción. Esto es importante, porque nos asegura que el objeto siempre estará en un estado válido.
El siguiente paso en pensar en las transformaciones que puede sufrir un objeto. En este caso, un usuario puede pasar de inactivo a activo, pero no al revés. Obviamente, si intentamos activar un usuario que ya está activo, no debería suceder nada.
Las transformaciones posibles se representan mediante métodos, así que podríamos añadir activate y deactivate.
1 class ActivationStatus
2 {
3 public const ACTIVE = 'active';
4 public const INACTIVE = 'inactive';
5
6 private $status;
7
8 public function __construct(string $status)
9 {
10 if (!in_array($status, [self::ACTIVE, self::INACTIVE])) {
11 throw new \InvalidArgumentException('Invalid status');
12 }
13 $this->status = $status;
14 }
15
16 public function activate(): void
17 {
18 $this->status = self::ACTIVE;
19 }
20
21 public function deactivate(): void
22 {
23 if ($this->status === self::ACTIVE) {
24 throw new \DomainException('Can not deactivate an active us\
25 er');
26 }
27 $this->status = self::INACTIVE;
28 }
29 }
Un primer problema es que estamos mutando el objeto ActuationStatus. Esto no es necesariamente malo, pero puede llevar a errores si no tenemos cuidado. El objeto que muta, en nuestro ejemplo sería User, y ActivationStatus está mejor representado como un Value Object, ya que es un concepto que nos interesa por valor. Los Value Objects queremos que sean inmutables:
1 class ActivationStatus
2 {
3 public const ACTIVE = 'active';
4 public const INACTIVE = 'inactive';
5
6 private $status;
7
8 public function __construct(string $status)
9 {
10 if (!in_array($status, [self::ACTIVE, self::INACTIVE])) {
11 throw new \InvalidArgumentException('Invalid status');
12 }
13 $this->status = $status;
14 }
15
16 public function activate(): self
17 {
18 return new self(self::ACTIVE);
19 }
20
21 public function deactivate(): self
22 {
23 if ($this->status === self::ACTIVE) {
24 throw new \DomainException('Can not deactivate an active us\
25 er');
26 }
27 return new self(self::INACTIVE);
28 }
29 }
El objeto User, por otro lado, tendrá métodos para cambiar su estado:
1 class User
2 {
3 private $activationStatus;
4
5 private function __construct(ActivationStatus $activationStatus)
6 {
7 $this->activationStatus = $activationStatus;
8 }
9
10 public static function enroll(): self
11 {
12 return new self(new ActivationStatus(ActivationStatus::INACTIVE\
13 ));
14 }
15
16
17 public function activate(): void
18 {
19 $this->activationStatus = $this->activationStatus->activate();
20 }
21
22 public function deactivate(): void
23 {
24 $this->activationStatus = $this->activationStatus->deactivate();
25 }
26 }
Como puedes ver, el método enroll nos permite crear un usuario que comienza su andadura en el sistema estando inactivo. Los métodos activate y deactivate nos permiten cambiar el estado del usuario cuando sea necesario. La lógica de esos métodos está en el objeto ActivationStatus, que es el que realmente sabe cómo cambiar de estado.
Mejorando lo presente
Hay un par de cosas que resultan molestas en ActivationStatus. Un solo objeto debe conocer toda la lógica de activación y desactivación. Para dos estados no es mucho problema, pero seguramente nos encontraremos con casos con muchos más estados y con lógica más compleja. Así que vamos a ver cómo podemos mejorar esto.
En primer lugar, vamos a representar cada estado con una clase distinta, aunque ambas serán igualmente ActivationStatus. Este es un uso bastante justificable de la herencia, ya que ambas clases son del mismo tipo y comparte interfaz.
1 class ActiveActivationStatus extends ActivationStatus
2 {
3 public function activate(): self
4 {
5 return $this;
6 }
7
8 public function deactivate(): self
9 {
10 throw new \DomainException('Can not deactivate an active user');
11 }
12 }
13
14 class InactiveActivationStatus extends ActivationStatus
15 {
16 public function activate(): self
17 {
18 return new ActiveActivationStatus();
19 }
20
21 public function deactivate(): self
22 {
23 return $this;
24 }
25 }
La clase ActivationStatus ahora será abstracta y tendrá dos métodos abstractos, activate y deactivate. Las clases ActiveActivationStatus e InactiveActivationStatus implementarán estos métodos de forma distinta. La clase ActivationStatus será la que se encargue de crear las instancias de las clases concretas, por lo que se convierte en una factoría:
1 abstract class ActivationStatus
2 {
3 public static function inactive(): self
4 {
5 return new InactiveActivationStatus();
6 }
7
8 public static function active(): self
9 {
10 return new ActiveActivationStatus();
11 }
12 }
De esta forma, la clase User puede hacer uso de la misma:
1 class User
2 {
3 private $activationStatus;
4
5 private function __construct(ActivationStatus $activationStatus)
6 {
7 $this->activationStatus = $activationStatus;
8 }
9
10 public static function enroll(): self
11 {
12 return new self(ActivationStatus::inactive());
13 }
14
15 public function activate(): void
16 {
17 $this->activationStatus = $this->activationStatus->activate();
18 }
19
20 public function deactivate(): void
21 {
22 $this->activationStatus = $this->activationStatus->deactivate();
23 }
24 }
Ahora bien, puede que te estés haciendo algunas preguntas:
¿Cómo podemos obtener el valor del estado actual, por ejemplo, para la persistencia o la serialización? Pues es bastante sencillo, tan solo necesitamos un método en cada subtipo de ActivationStatus que nos devuelva un valor para representar el estado actual serializado. Podríamos simplemente implementar un __toString().
1 class ActiveActivationStatus extends ActivationStatus
2 {
3 public function activate(): self
4 {
5 return $this;
6 }
7
8 public function deactivate(): self
9 {
10 throw new \DomainException('Can not deactivate an active user');
11 }
12
13 public function __toString(): string
14 {
15 return ActivationStatus::ACTIVE;
16 }
17 }
¿Cómo podemos reconstruir el estado a partir de un valor serializado? Pues también es sencillo, podemos añadir un método estático a ActivationStatus que nos permita hacer esto:
1 abstract class ActivationStatus
2 {
3 public const ACTIVE = 'active';
4 public const INACTIVE = 'inactive';
5
6 public static function fromString(string $status): self
7 {
8 switch ($status) {
9 case self::ACTIVE:
10 return new ActiveActivationStatus();
11 case self::INACTIVE:
12 return new InactiveActivationStatus();
13 default:
14 throw new \InvalidArgumentException('Unsupported status\
15 ');
16 }
17 }
18 }
¿Qué comportamientos deberían implementar los métodos activate y deactivate en la clase abstracta? Esto va a depender de tus preferencias o de tus necesidades. En nuestro caso, hemos decidido que activate y deactivate devuelvan una nueva instancia del objeto, pero podrían lanzar una excepción, o simplemente ser declarados como abstractos, lo que te obliga a implementarlos en cualquier subclase.
Pues con esto, ya tenemos un ejemplo de un patrón State muy simple, de dos estados con dos transformaciones posibles.
Máquinas más complejas
La complejidad de una máquina de estados es una función de los estados que puede mantener y de las transformaciones que puede realizar. En el ejemplo anterior, teníamos dos estados y dos transformaciones. En un caso más complejo, podríamos tener muchos más estados y muchas más transformaciones. Por ejemplo, podríamos tener un objeto que represente un pedido, que puede estar en estado nuevo, pagado, enviado o entregado. Las transformaciones posibles podrían ser pagar, enviar y entregar. En este caso, la máquina de estados sería más compleja, pero el patrón seguiría siendo el mismo.
1 abstract class OrderStatus
2 {
3 public static function new(): self
4 {
5 return new NewOrderStatus();
6 }
7
8 public static function paid(): self
9 {
10 return new PaidOrderStatus();
11 }
12
13 public static function sent(): self
14 {
15 return new SentOrderStatus();
16 }
17
18 public static function delivered(): self
19 {
20 return new DeliveredOrderStatus();
21 }
22
23 abstract public function pay(): self;
24
25 abstract public function send(): self;
26
27 abstract public function deliver(): self;
28 }
En este ejemplo, las transformaciones nos llevan a un nuevo estado, pero no todas ellas están permitidas. Por ejemplo, un pedido nuevo no puede ser entregado si no ha sido pagado ni enviado. O no se puede enviar un pedido que no haya sido pagado. Estos son ejemplos de transformaciones que se pueden validar con la información del propio objeto. Esto es: cada objeto representando un estado sabe por sí mismo qué transformaciones puede hacer y cuáles no.
Pero en muchos casos de uso, las transformaciones de estado dependen de más cuestiones. Por ejemplo, un pedido no se puede enviar si no se ha pagado, pero tampoco se puede enviar si no hay stock, o si no tenemos una dirección de envío, o no se puede pagar si el pedido no contiene productos. En estos casos, el objeto representando el estado no tiene toda la información que necesita y debe recibir algún tipo de contexto para tomar la decisión, que puede ser distinto para cada transformación.
1 abstract class OrderStatus
2 {
3 public static function new(): self
4 {
5 return new NewOrderStatus();
6 }
7
8 public static function paid(): self
9 {
10 return new PaidOrderStatus();
11 }
12
13 public static function sent(): self
14 {
15 return new SentOrderStatus();
16 }
17
18 public static function delivered(): self
19 {
20 return new DeliveredOrderStatus();
21 }
22
23 abstract public function pay(Order $order): self;
24
25 abstract public function send(Order $order): self;
26
27 abstract public function deliver(Order $order): self;
28 }
La forma concreta de pasar ese contexto puede ser diferente según los casos. Para nuestro ejemplo, vamos a suponer que el objeto Order expone métodos que nos dicen si los productos están en stock, si tiene una dirección de envío, detalles de pago, etc. En otros casos, el contexto podría ser un objeto que represente esas condiciones que cada estado debe tener en cuenta para decidir si la transformación es válida.
1 class Order
2 {
3 private $status;
4
5 private function __construct(ActivationStatus $status)
6 {
7 $this->status = $status;
8 }
9
10 public static function new(): self
11 {
12 return new self(ActivationStatus::new());
13 }
14
15 public function pay(): void
16 {
17 $this->status = $this->status->pay($this);
18 }
19
20 public function send(): void
21 {
22 $this->status = $this->status->send($this);
23 }
24
25 public function deliver(): void
26 {
27 $this->status = $this->status->deliver($this);
28 }
29
30 public function isValid(): bool
31 {
32 return !$this->products->empty() ;
33 }
34
35 public function hasShippingAddress(): bool
36 {
37 return $this->shippingAddress->isValid();
38 }
39
40 public function hasPaymentDetails(): bool
41 {
42 return $this->paymentDetails->isValid();
43 }
44 }
Así que veamos cómo sería cada estado concreto:
1 class NewOrderStatus extends OrderStatus
2 {
3 public function pay(Order $order): self
4 {
5 if ($order->hasPaymentDetails()) {
6 return OrderStatus::paid();
7 }
8 throw new \DomainException('Can not pay an order without paymen\
9 t details');
10 }
11
12 public function send(Order $order): self
13 {
14 throw new \DomainException('Can not send an unpaid order');
15 }
16
17 public function deliver(Order $order): self
18 {
19 throw new \DomainException('Can not deliver an unpaid order');
20 }
21 }
El pedido ya pagado no se puede pagar de nuevo, pero sí enviar, siempre que tenga una dirección de envío.
1 class PaidOrderStatus extends OrderStatus
2 {
3 public function pay(Order $order): self
4 {
5 throw new \DomainException('Can not pay an already paid order');
6 }
7
8 public function send(Order $order): self
9 {
10 if ($order->hasShippingAddress()) {
11 return OrderStatus::sent();
12 }
13 throw new \DomainException('Can not send an order without a shi\
14 pping address');
15 }
16
17 public function deliver(Order $order): self
18 {
19 throw new \DomainException('Can not deliver an order that has n\
20 ot been sent');
21 }
22 }
Cuando un pedido se ha enviado, ya no se puede enviar de nuevo, ni pagar, pero sí entregar.
1 class SentOrderStatus extends OrderStatus
2 {
3 public function pay(Order $order): self
4 {
5 throw new \DomainException('Can not pay an order that has been \
6 sent');
7 }
8
9 public function send(Order $order): self
10 {
11 throw new \DomainException('Can not send an order that has been\
12 sent');
13 }
14
15 public function deliver(Order $order): self
16 {
17 return OrderStatus::delivered();
18 }
19 }
Y, finalmente, DeliveredOrderStatus, que es quien que finaliza el proceso, garantizando que no es posible volver a un estado anterior.
1 class DeliveredOrderStatus extends OrderStatus
2 {
3 public function pay(Order $order): self
4 {
5 throw new \DomainException('Can not pay an order that has been \
6 delivered');
7 }
8
9 public function send(Order $order): self
10 {
11 throw new \DomainException('Can not send an order that has been\
12 delivered');
13 }
14
15 public function deliver(Order $order): self
16 {
17 throw new \DomainException('Can not deliver an order that has b\
18 een delivered');
19 }
20 }
Por supuesto, nos han quedado algunos estados en el tintero, como CancelledOrderStatus, que podría ser útil para representar un pedido que ha sido cancelado. Incluso podría aplicarse ese estado en lugar de lanzar excepciones cuando no se dispone de medio de pago o de dirección de envío. Otro estado interesante sería ReturnedOrderStatus, representando un pedido devuelto. Pero con esto, ya tienes una idea de cómo puedes implementar una máquina de estados más compleja y te propongo realizar el ejercicio de proseguir con la implementación de estos estados. Imagina, también donde pondrías un estado ValidatedOrderStatus que represente un estado en el que el pedido ha sido validado en todos los detalles necesarios.
Resumen del capítulo
El patrón State nos permite modelar y tener bajo control los estados y posibles transformaciones de un objeto a lo largo de su ciclo de vida en un sistema.