Command Query Separation en Laravel

Spread the love

Tomate un momento y mira el siguiente código:

class Counter
{
    public int $counter;

    public function __construct()
    {
        $this->counter = 0;
    }

    public function increase(): int
    {
        return $this->counter++;
    }
}

Es una clase muy sencilla que solo incrementa una propiedad y que el método increase() regresa como resultado.

En apariencia todo esta bien y no debe de haber mayor problema, ¿verdad?

Pero si lo analizamos un poco mas, vemos que ese método esta haciendo dos cosas; por un lado altera la variable y al mismo tiempo nos regresa un resultado.

Y es aqui donde viene la pregunta:

¿Es valido que un método que regresa un resultado también altere el estado de un objeto o clase?


La respuesta corta es no,

Pero primero debemos entender el papel que juega algo que se conoce como efectos secundarios (Side effects).

Efectos Secundarios (Side Effects)

Una función produce un efecto secundario concreto si su cuerpo contiene alguno de los siguientes elementos:

Una asignación o instrucción de creación cuyo objetivo es un atributo.

Una llamada a un procedimiento .

Object Oriented Software Construction

En términos generales cualquier operación que termina asignando un valor a una propiedad de un objeto, se considera un efecto secundario.

Es evidente que tarde o temprano tenemos que cambiar el estado de un objeto, eso no se puede evitar, pero si podemos evitar o reducir un efecto secundario que nos de un resultado inesperado.

Volvamos a nuestro ejemplo.

Como el método increment() afecta el estado y además nos regresa un resultado, es mas probable que tengamos en el, un resultado no esperado. Y para nuestro ejemplo es así ya que si ejecutas el código, después de unas cuantas pruebas te das cuenta que nunca regresa el incremento real de la propiedad counter.

Pro ejemplo. cuando el valor de la propiedad counter vale 0 después del primer incremento debe valer 1 pero el método te regresa el valor de 0.

En otras palabras el código nos esta mintiendo. así que una prueba como la siguiente falla.

class CounterTest extends TestCase
{
    public function test_it_return_the_current_increment()
    {
        $counter = new Counter();
        $this->assertEquals(1, $counter->increase());
        $this->assertEquals(2, $counter->increase());
    }
}

porque nunca obtenemos el incremento real después de ejecutar la operación.

¿Cómo podemos resolver esto?

Lo más simple es solo cambiar el incremento, pero no resuelve el problema real de tener un método que sigue haciendo dos tareas.

Asi que para esto Bertrand Meyer propuso en su libro: “Object Oriented Software Construction” un principio que llamo CQS o Commando Query Separation:

Command Query Separation (CQS)

La idea fundamental es que debemos dividir los métodos de un objeto en dos categorías claramente separadas:

  • Consultas(Query): Devuelven un resultado y no cambian el estado observable del sistema (están libres de efectos secundarios).
  • Comandos(Command): Cambian el estado de un sistema pero no devuelven un valor.

De esta forma podemos ver claramente las operaciones que afectan el estado de una clase, de las que no tiene un efecto secundario inesperado (consultas).

Si aplicamos este principio a nuestra clase inicial, esta quedaría como sigue:

class Counter
{
    public int $counter;

    public function __construct()
    {
        $this->counter = 0;
    }

    public function increase(): int
    {
        $this->counter++;
    }

    public function getCurrent(): int
    {
        return $this->counter;
    }
}

Ahora el método increment() solo afecta a la propiedad counter y el método getCurrent() me devuelve el valor actual.

Muy bien, ya que vimos como funciona el principio en algo sencillo, ahora vemos otro ejemplo:

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Product extends Model
{
    use HasFactory;

    protected $guarded = ['id'];

    public function applyDiscount(float $discount): float
    {
        $this->price -= $this->price * $discount;
        return $this->price;
    }
}

Igual que antes este código parece inofensivo y que no tiene ningún problema. Pero de entrada vemos algo raro, ya que el método decrementa el precio del producto después de aplicar el descuento.

Esto puede generar muchos errores difíciles de detectar a simple vista en algunas condiciones de prueba.

veamos esto con una prueba rápida.

class ProductTest extends TestCase
{
    use RefreshDatabase;

    public function test_it_applies_a_discount()
    {
        $product = new Product(['price' => 100.00]);

        $this->assertEquals(90, $product->applyDiscount(0.10));
        $this->assertEquals(80, $product->applyDiscount(0.20));
    }
}

Esta prueba falla con el siguiente mensaje:

There was 1 failure:

1) Tests\Feature\ProductTest::test_it_applies_a_discount
Failed asserting that 72.0 matches expected 80.

La primera afirmación paso sin problema, pero la segunda falla generando un descuento mayor al esperado, Pero esto solo lo notamos después del segundo calculo.

¿Qué esta pasando?

Obviamente estamos afectando la propiedad cuando solo debemos calcular y regresar el resultado (El método es de consulta), pero no lo notamos a simple vista.

Para evitar este molesto efecto secundario, solo tenemos que calcular el descuento sin cambiar el estado.

Así que solo tenemos que modificar nuestro método de la siguiente forma:

    public function applyDiscount(float $discount): float
    {
        return $this->price * (1 - $discount);
    }

Ahora si podemos aplicar nuevos descuentos sin afectar el estado del modelo Product.

Conclusión:

Creo que el valor real de este principio esta en el diseño de nuestra API, al darnos una forma clara de separar las operaciones que son de consulta y libres de efectos secundarios, de las que afectan el estado interno de nuestros objetos. Además al tener separadas las operaciones podemos probar nuestras clases de forma mas sencilla.

¿Te gusto este articulo?

Recuerda compartirlo y dejarnos tus comentarios.