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.