Más allá de CRUDs: [05] Estados cover image

Más allá de CRUDs: [05] Estados

Kenny Horna Lectura de 8 minutos

laravel php programming

El patrón de diseño "Estado" es una de las mejores formas de agregar comportamientos específicos de estado al modelo, mientras los mantiene limpios.


Este es el artículo #05 de la serie Laravel: Más allá de CRUDs. Fue originalmente publicado por Brent en su blog (puedes encontrar ahí la serie en su idioma original).

La tabla de contenido que conforma esta serie la tienes aquí.

Dicho esto, comencemos 😉.


Este capítulo hablará sobre el patrón Estado y específicamente cómo aplicarlo a modelos. Puedes pensar en este capítulo como una extensión del anterior (modelos), donde escribí sobre cómo pretendemos mantener nuestros modelos manejables al evitar que manejen lógica de negocio.

Sin embargo, alejar la lógica del negocio de los modelos plantea un problema con un caso de uso muy común: ¿qué hacer con los estados del modelo?

Una factura puede estar pendiente o pagada, un pago puede fallar o tener éxito. Dependiendo del estado, un modelo debe comportarse de manera diferente; ¿Cómo podemos cerrar esta brecha entre los modelos y la lógica empresarial?

Los estados -y las transiciones entre ellos- son un caso de uso frecuente en grandes proyectos; tan frecuentes que merecen un capítulo propio.

El patrón Estado

En esencia, el patrón Estado es un patrón simple, pero permite una funcionalidad muy poderosa. Tomemos nuevamente el ejemplo de las facturas: una factura puede estar pendiente o pagada. Para empezar, daré un ejemplo muy simple, porque quiero que entiendas cómo el patrón de estado nos permite mucha flexibilidad.

Digamos que el resumen de la factura debe mostrar una insignia que represente el estado de esa factura, es de color naranja cuando está pendiente y verde cuando se paga.

Un enfoque de "modelo gordo" ingenuo haría algo como esto:

class Invoice extends Model
{
    // …

    public function getStateColour(): string
    {
        if ($this->state->equals(InvoiceState::PENDING())) {
            return 'orange';
        }

        if ($this->state->equals(InvoiceState::PAID())) {
            return 'green';
        }

        return 'gray';
    }
}

Como estamos utilizando algún tipo de clase enum para representar el valor del estado, podríamos mejorar esto de la siguiente manera:

class Invoice extends Model
{
    // …

    public function getStateColour(): string
    {
        return $this->state->getColour();
    }
}
/**
 * @method static self PENDING()
 * @method static self PAID()
 */
class InvoiceState extends Enum
{
    private const PENDING = 'pending';
    private const PAID = 'paid';

    public function getColour(): string
    {
        if ($this->value === self::PENDING) {
            return 'orange';
        }

        if ($this->value === self::PAID) {
            return 'green'
        }

        return 'gray';
    }
}

Como nota aparte, estoy asumiendo el empleo del paquete myclabs/php-enum. Como mejora adicional, para una mejor medición, podríamos escribir lo anterior de modo más corto utilizando arreglos.

class InvoiceState extends Enum
{
    public function getColour(): string
    {
        return [
            self::PENDING => 'orange',
            self::PAID => 'green',
        ][$this->value] ?? 'gray';
    }
}

Cualquiera sea el enfoque que prefieras, en esencia estás enumerando todas las opciones disponibles, verificando si una de ellas coincide con la actual y haciendo algo en función del resultado. Es una gran declaración if/else, con cualquier "azúcar sintáctico" que prefieras.

Con este enfoque, agregamos una responsabilidad, ya sea al modelo o la clase enum: tiene que saber qué debe hacer un estado específico, tiene que saber cómo funciona un estado. El patrón Estado cambia esto al revés: trata a "un estado" como un ciudadano-de-primera-clase de nuestra base de código. Cada estado está representado por una clase separada, y cada una de estas clases actúa sobre un tema.

¿Es difícil de entender? Vamos a hacerlo paso a paso.

Comenzaremos con una clase abstracta InvoiceState, esta clase describirá toda la funcionalidad que los estados de factura concretos pueden proporcionar. En nuestro caso, queremos que un estado proporcione un color.

abstract class InvoiceState
{
    abstract public function colour(): string;
}

A continuación, crearemos dos clases, cada una de estas representará un estado concreto.

class PendingInvoiceState extends InvoiceState
{
    public function colour(): string
    {
        return 'orange';
    }
}
class PaidInvoiceState extends InvoiceState
{
    public function colour(): string
    {
        return 'green';
    }
}

Lo primero que debes notar es que cada una de estas clases se puede probar fácilmente por su cuenta.

class InvoiceStateTest extends TestCase
{
    /** @test */
    public function the_colour_of_pending_is_orange
    {   
        $state = new PendingInvoiceState();

        $this->assertEquals('orange', $state->colour());
    }
}

En segundo lugar, debes tener en cuenta que los colores son un ejemplo ingenuo que estamos utilizando para explicar el patrón. También podrías tener lógica de negocio más compleja encapsulada por un estado. Toma este ejemplo: ¿se debe pagar una factura?. Esto, por supuesto, depende del estado, si ya fue pagado o no, pero también podría depender del tipo de factura con la que estamos tratando. Digamos que nuestro sistema admite notas de crédito que no tienen que pagarse, o permite facturas con un precio de 0. Esta lógica de negocio puede ser encapsulada por las clases de estado.

Sin embargo, hay una cosa que falta para que esta funcionalidad funcione: necesitamos poder ver el modelo desde nuestra clase de estado, si vamos a decidir si esa factura debe pagarse o no. Es por eso que tenemos nuestra clase padre abstracta InvoiceState; agreguemos los métodos requeridos allí.

abstract class InvoiceState
{
    /** @var Invoice */
    protected $invoice;

    public function __construct(Invoice $invoice) { /* … */ }

    abstract public function mustBePaid(): bool;

    // …
}

Por lo que ahora deberemos implementar en las clases abstractas:

class PendingInvoiceState extends InvoiceState
{
    public function mustBePaid(): bool
    {
        return $this->invoice->total_price > 0
            && $this->invoice->type->equals(InvoiceType::DEBIT());
    }

    // …
}
class PaidInvoiceState extends InvoiceState
{
    public function mustBePaid(): bool
    {
        return false;
    }

    // …
}

Nuevamente, podemos escribir pruebas unitarias simples para cada estado, y nuestro modelo de factura simplemente puede hacer esto:

class Invoice extends Model
{
    public function getStateAttribute(): InvoiceState
    {
        return new $this->state_class($this);
    }

    public function mustBePaid(): bool
    {
        return $this->state->mustBePaid();
    } 
}

Finalmente, en la base de datos podemos guardar la clase de estado del modelo concreto en el campo state_class y listo. Obviamente, hacer este mapeo manualmente (guardar y cargar desde y hacia la base de datos) se vuelve tedioso muy rápidamente. Es por eso que creé un paquete que se encarga de todo el trabajo duro por ti.

Sin embargo, el comportamiento específico del estado, en otras palabras "el patrón Estado", es solo la mitad de la solución; todavía tenemos que manejar la transición del estado de la factura de uno a otro, y asegurarnos de que solo estados específicos puedan pasar a otros. Así que echemos un vistazo a las transiciones de estado.

Transiciones

¿Recuerdas cómo hablé sobre alejar la lógica de negocio de los modelos y solo permitirles proporcionar datos de la base de datos de una manera viable? El mismo pensamiento puede aplicarse a estados y transiciones. Deberíamos evitar los efectos secundarios al usar estados, cosas como hacer cambios en la base de datos, enviar correos, etc. Los estados deben usarse para leer o proporcionar datos. Las transiciones, por otro lado, no proporcionan nada. Por el contrario, se aseguran de que nuestro estado del modelo se transite correctamente de uno a otro, lo que lleva a efectos secundarios aceptables.

Dividir estas dos preocupaciones en clases separadas nos da las mismas ventajas sobre las que escribí una y otra vez: mejor capacidad de prueba y reducción de la carga cognitiva. Permitir que una clase solo tenga una responsabilidad hace que sea más fácil dividir un problema complejo en varias partes fáciles de entender.

Entonces, transiciones: una clase que tomará un modelo, una factura en nuestro caso, y cambiará el estado de esa factura, si está permitido, a otra. En algunos casos, puede haber pequeños efectos secundarios como escribir un mensaje de registro o enviar una notificación sobre la transición de estado. Una implementación ingenua podría verse más o menos así.

class PendingToPaidTransition
{
    public function __invoke(Invoice $invoice): Invoice
    {
        if (! $invoice->mustBePaid()) {
            throw new InvalidTransitionException(self::class, $invoice);
        }

        $invoice->status_class = PaidInvoiceState::class;
        $invoice->save();

        History::log($invoice, "Pending to Paid");
    }
}

Nuevamente, hay muchas cosas que puedes hacer con este patrón básico:

  • Definir todas las transiciones permitidas en el modelo.
  • Transición de un estado directamente a otro, mediante el uso de una clase de transición bajo el capó
  • Determine automáticamente a qué estado pasar en función de un conjunto de parámetros

Nuevamente, el paquete que mencioné antes agrega soporte para las transiciones, así como la gestión básica de la transición. Sin embargo, si deseas máquinas de estado complejas, es posible que desee ver otras soluciones.

Estados sin transiciones

Cuando pensamos en "estado", a menudo pensamos que no pueden existir sin transiciones. Sin embargo, eso no es cierto: un objeto puede tener un estado que nunca cambia y no se requieren transiciones para aplicar el patrón Estado. ¿Porque es esto importante? Bueno, eche un vistazo nuevamente a nuestra implementación PendingInvoiceState::mustBePaid:

class PendingInvoiceState extends InvoiceState
{
    public function mustBePaid(): bool
    {
        return $this->invoice->total_price > 0
            && $this->invoice->type->equals(InvoiceType::DEBIT());
    }
}

Dado que queremos usar el patrón Estado para reducir los bloques frágiles if/else en nuestro código, ¿puedes adivinar a dónde voy con esto? ¿Has considerado que $this->invoice->type->equals(InvoiceType::DEBIT()) es de hecho una declaración if disfrazada?

¡De hecho,InvoiceType también podría aplicar el patrón Estado! Es simplemente un estado que probablemente nunca cambiará para un objeto dado. Mira esto:

abstract class InvoiceType
{
    /** @var Invoice */
    protected $invoice;

    // …

    abstract public function mustBePaid(): bool;
}
class CreditInvoiceType extends InvoiceType
{
    public function mustBePaid(): bool
    {
        return false
    }
}
class DebitInvoiceType extends InvoiceType
{
    public function mustBePaid(): bool
    {
        return true;
    }
}

Ahora podemos refactorizar nuestro PendingInvoiceState::mustBePaid de esta manera.

class PendingInvoiceState extends InvoiceState
{
    public function mustBePaid(): bool
    {
        return $this->invoice->total_price > 0
            && $this->invoice->type->mustBePaid();
    }
}

La reducción de las declaraciones if/else en nuestro código permite que el código sea más lineal, lo que a su vez es más fácil de razonar. Recomiendo echar un vistazo a la charla de Sandi Metz sobre este tema en específico.


El patrón Estado es, en mi opinión, impresionante. Nunca volverás a atascarte escribiendo enormes declaraciones if/else, en la vida real a menudo hay más de dos estados de factura, y permite un código limpio y comprobable.

Es un patrón que puedes introducir gradualmente en tus bases de código existentes, y estoy seguro de que será de gran ayuda para mantener el proyecto mantenible a largo plazo.