Aplicando el patrón de diseño State en laravel

Spread the love

Existen muchos casos en los que nuestra aplicación realiza acciones de acuerdo a un “estado”.

Un ejemplo típico de esto es cuando compras cosas en línea y dejas el carrito lleno sin completar la compra.

De forma interna el sistema detecta este carrito en el estado “no completado” y te hace recordatorios regulares vía E-mail para que finalices la compra.

Otro caso común es el control remoto de tu pantalla, no puedes cambiar de canal o regular el volumen si tu pantalla no esta en estado de “encendido”.

Lo típico de estas aplicaciones que depende de un estado en concreto para realizar una acción, es que suelen tener tantas condiciones como estados requiera el sistema.

Veamos esto con un ejemplo.

Vamos a suponer que tienes una aplicación que tiene la facilidad de permitir que un usuario pueda ser bloqueado de la aplicación

namespace App\Http\Controllers;

use App\Mail\UserWasLocked;
use App\Mail\UserWasUnlocked;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;

class UserStatusController extends Controller
{
    /**
     * Handle the incoming request.
     *
     * @param \Illuminate\Http\Request $request
     * @param User $user
     * @return \Illuminate\Http\JsonResponse
     */
    public function __invoke(Request $request, User $user)
    {
        $valid = $request->validate(['status' => 'in:lock,unlock']);

        switch ($valid['status']) {
            case 'lock':
                $user->status = $request->status;
                $user->save();
                Mail::to($user)->send(new UserWasLocked);
                break;

            case 'unlock':
                $user->status = $request->status;
                $user->save();
                Mail::to($user)->send(new UserWasUnlocked);
                break;
        }

        return response()->json(['status' => (string) $user->status]);
    }
}

Para cada estado el controlador que vemos arriba manda un E-mail para notificar al usuario que su cuenta a sido bloqueada.

Además como tenemos dos estados (lock y unlock) requerimos de dos condiciones para controlar las acciones de cada estado.

el punto débil de este código es que agregar nuevos estados genera una nueva condición en el switch.

Pero además si quieres cambiar la lógica actual, digamos que ahora quieres que solo los supervisores puedan bloquear y desbloquear una cuenta…

termina en una condición nueva en el switch o que tengas que reescribir esta parte usando la sentencia if.

En cualquiera de los dos casos vas a terminar con una serie de condiciones difíciles de mantener y de extender.

¿Cuál es la solución?

Utilzar una mas orientada a objetos y es ahí donde entra en juego el Patrón de diseño State.

Este patrón resulta útil cuando necesitamos que un objeto se comporte de forma diferente dependiendo del estado interno en el que se encuentre en cada momento.

Con lo cual podemos rescribir nuestro controlador para que sea la clase User la que se encargue de ejecutar la acción correcta de acuerdo al estado que le solicitemos.

/**
     * Handle the incoming request.
     *
     * @param \Illuminate\Http\Request $request
     * @param User $user
     * @return \Illuminate\Http\JsonResponse
     */
    public function __invoke(Request $request, User $user)
    {
        $user->changeStatus();

        return response()->json(['status' => (string) $user->status]);
    }

¿Te parece interesante?

Si es así, veamos como aprovechar este patrón para mejorar nuestro código.

Video

Si quieres ver con mas detalle como se implementa este ejemplo puede ver el siguiente vídeo.

Implementación del patrón State en Laravel

Repositorio

Si quieres ver todos los detalles de la implementación Seba Carrasco Poblete a realizado un gran trabajo creando un repositorio que recrea este ejemplo basándose en esta explicación 👍🏼

¿Qué es el patrón de diseño State ?

State es un patrón de diseño que permite a un objeto alterar su comportamiento cuando su estado interno cambia. Parece como si el objeto cambiara su clase.

GOF

Y esto se debe a que las acciones para cada estado, son movidas a clases que representan los estados que puede tener un objeto.

Para nuestro ejemplo esos estados son lock y unlock

Estructura del patrón State

El Patrón State cuenta con tres elementos:

  • Context(Contexto): Este integrante define la interfaz con el cliente y Mantiene una instancia de ConcreteState que indica el estado actual del contexto.
  • StateInterface (Estado): Define una interfaz para encapsular la responsabilidad asociada con un estado particular de Context.
  • Subclase ConcreteState: Cada una de estas subclases implementa el comportamiento o la acción que puede realizar el contexto en cada estado.

Muy bien ahora veamos como se ve esto en un diagrama.

Diagrama de Clases

Diagrama de clases del patrón state
Diagrama de clases patrón State

Primero el contexto (Context) se encarga de modificar su estado actual usando el método request(), para saber en que estado se encuentra, utiliza la variable state que regresa una implementación (instancia) de StatetInterface.

Cada implementación de StateInterface (ConcreteState) tiene como mínimo un método (handle()) que representa la acción que se puede realizar en ese estado, y es necesario que la subclase mantenga una referencia al contexto eso se indicamos con la variable context en el diagrama.

cuando usemos esto, la única clase que vamos a controlar será el contexto, si trasladamos todo esto a código, la llamada al contexto se vería como sigue.

$context = new Context();
$context->request(); // llama a $state->handle()

Adaptando el patrón state

Diagrama de ejemplo del patrón state
Adaptando el patrón state al problema

Volviendo a nuestro ejemplo nuestra clase User será el contexto y mediante el método changeState va a controlar los cambios de estado, Cada llamada a este método en realidad hace una llamada a la clase que este como estado actual (UnlockStatus o LockStatus).

La interfaz StateInterface representa los posibles estados que puede tener User y para nuestro caso serán dos estados. Así que tenemos que implementar dos clases UnlockStatus para representar el estado unlock y LockStatus para el estado lock.

Si traslado esta idea a código, cuando uses la clase User, tu solo tendrás que llamar al método changeStatus().

$user = User::find(1);
$user->changeStatus(); // llama a $this->status->handle();

Implementación del Patrón State

Por fin llegamos a la parte que todos queremos hacer, programar la solución y esto lo vamos a hacer paso a paso.

Modificar la clase User (Contexto)

De acuerdo al diagrama tenemos que tener una forma de guardar el ultimo estado que tiene nuestra clase, para eso ya tenemos la propiedad status.

$user->status = $request->status;

Asi que solo tenemos que agregar el método changeStatus de la siguiente forma:

    /**
     * changes to the next valid status
     */
    public function changeStatus() : void
    {
        $this->status->handle();
    }

pero espera… ¿no hay algo raro en esta método?

Y tienes razón, el método llama a $this->status->handle() como dice el diagrama, pero resulta que la propiedad status en User guarda el string lock o unlock según sea el caso.

Así que tenemos que buscar una forma de que status nos regrese una clase. Afortunadamente esto es muy sencillo de resolver si usamos un Accessor de eloquent:

    /**
     * @param $status
     * @return mixed
     */
    public function getStatusAttribute($status)
    {
        return new $status($this);
    }

De esta forma solo tenemos que guardar el namespace de la clase y cuando lo recuperemos el mutator nos devolvera una nueva instancia del estado actual.

Creamos la interfaz StateInterface

Para nuestro ejemplo la interfaz solo debe de contener el método handle.

namespace App\Contracts;


interface StateInterface
{
    public function handle() : void;
}

Implementación de StateInterface

Nuestro ejemplo cuenta con dos estados así que tenemos que crear dos clases que implemeten la interfaz una para el estado lock y para unlock.

También tenemos que mover las acciones que están en el switch a cada clase dependiendo de que estado estamos implementando.

Veamos el ejemplo:

Clase LockStatus

namespace App\Models\Status;

use App\Models\User;
use App\Mail\UserWasUnlocked;
use App\Contracts\StateInterface;
use Illuminate\Support\Facades\Mail;

class LockStatus implements StateInterface
{
    private $user;
    
    public function __construct(User $user)
    {
        $this->user = $user;
    }

    public function handle(): void
    {
        $this->unlock();
    }

    public function unlock() : void
    {
        $this->nextStatus(UnlockStatus::class)->save();
        Mail::to($this->user)->send(new UserWasUnlocked);
    }

    private function nextStatus($status): User
    {
        return tap($this->user, function($user) use($status){
            $user->status = $status;
        });
    }

    public function __toString() : String
    {
        return 'locked';
    }
}

El diagrama de clase dice que solo tenemos un método(handle) pero para el ejemplo decidí que llame al método lock donde puedes meter la lógica que estaba en el switch.

El método lock es publico ya que es posible tener casos, donde tengas que cambiar el estado del objeto de forma explicita. sin este método no podrías hacerlo.

El método nextStatus esta para indicar que en cada llamada a handle se tiene que actualizar el estado de User al siguiente que sea valido. Para este caso el siguiente estado valido sería unlock.

__toString nos va a ayudar a no romper las llamadas donde se espera ver la cadena “lock” o “unlock” en lugar del namespace de la clase, como por ejemplo en las vistas de blade

Clase UnlockStatus

namespace App\Models\Status;


use App\Models\User;
use App\Mail\UserWasLocked;
use App\Contracts\StateInterface;
use Illuminate\Support\Facades\Mail;

class UnlockStatus implements StateInterface
{
    private $user;

    public function __construct(User $user)
    {
        $this->user = $user;
    }

    public function handle(): void
    {
        $this->lock();
    }

    public function lock() : void
    {
        $this->nextStatus(LockStatus::class)->save();

        Mail::to($this->user)->send(new UserWasLocked);
    }

    private function nextStatus($status): User
    {
        return tap($this->user, function($user) use($status){
            $user->status = $status;
        });
    }

    public function __toString() : String
    {
        return 'unlocked';
    }
}

Al igual que la clase LockStatus aquí solo debiera de estar el método handle pero para el ejemplo decidí que llame al método unlock que donde metí la lógica que estaba en el switch.

El método lock también es publico ya que es posible que se tenga que actualizar el estado de User de forma explicita. sin este método no podríamos hacerlo.

El método nextStatus al igual que antes, esta para indicar que la llamada a handle actualiza el estado de User al siguiente estado valido. Para este caso el siguiente estado valido será lock.

__toString nos va a ayudar a no romper las llamadas donde se espera ver la cadena “lock” o “unlock” en lugar del namespace de la clase, como por ejemplo en las vistas de blade

Actualizando nuestro código con el patrón State

Llegamos a la ultimo paso y es actualizar nuestro código original:

    /**
     * Handle the incoming request.
     *
     * @param \Illuminate\Http\Request $request
     * @param User $user
     * @return \Illuminate\Http\JsonResponse
     */
    public function __invoke(Request $request, User $user)
    {
        $user->changeStatus();

        return response()->json(['status' => (string) $user->status]);
    }

Notaras que ahora el código solo tiene una llamada a User y que la validación también desapareció.

Esto pasa porque ahora User sabe en que estado se encuentra en cada llamada y ejecuta el código correspondiente sin necesidad de que lo indiquemos.

Esto se logra con este código en cada estado:

$this->nextStatus(UnlockStatus::class)->save();

En cada llamada le indicamos el siguiente estado posible.

También recuerda agregar un estado inicial para cada usuario, esto se puede hacer cambiando el schema para la clase User:

$table->string('status')->default(UnlockStatus::class);

Si no quieres hacerlo desde el Schema tambien es posible desde el modelo user agregando la propiedad $attributes:

    protected $attributes = [
        'status' => UnlockStatus::class,
    ];

Para otras opciones de valores por default puedes ver este articulo

Ahora, digamos que necesitas más control y quieres indicar de forma explicita el estado que quieres en cada llamada.

Eso lo podemos hacer agregando un cambio mínimo a User.

    /**
     * changes to a specific state
     *
     * @param $status
     */
    public function changeTo($status) : void
    {
        $this->status->{$status}();
    }

De esta forma podemos indicar el estado al que queremos cambiar si así se requiere.

Actualizando nuestro controlador ahora las cosas quedarían como sigue:

    /**
     * Handle the incoming request.
     *
     * @param \Illuminate\Http\Request $request
     * @param User $user
     * @return \Illuminate\Http\JsonResponse
     */
    public function __invoke(Request $request, User $user)
    {
        $valid = $request->validate(['status' => 'in:lock,unlock']);

        $user->changeTo($valid['status']);

        return response()->json(['status' => (string) $user->status]);
    }

Conclusión

El patrón State nos permite con cierta facilidad eliminar o reducir condiciones complejas, relacionadas con el cambio de estado de un objeto.

Proporciona código más claro ya que encapsula la lógica necesaria para cada estado en su propia clase.

Facilita la creación de nuevos estados con solo implementar la clase StateInterface.

Pero como todo en la vida.. siempre existe un pero y el problema o la debilidad mas grande con este patrón, es que para escenarios con muchos estados tienes que ir agregando mas clases y método en el contexto y esto hace que tengas que mantener mas código.

En algunas situaciones el código termina siendo mas complejo en los estados concretos, porque algunas decisiones se trasladan a estas clases. Afortunadamente existen otras soluciones como usar la implementación de una maquina de estados.

¿Te gusto el articulo?

No olvides compartir y comentar !