Patrón cadena de responsabilidad en PHP

El patrón cadena de responsabilidad (chain of responsibility en inglés), es especialmente útil cuando tenemos diferentes reglas para resolver un problema que deben ser aplicadas secuancialmente pero no sabemos cual de ellas cumplirá los requisitos hasta el tiempo de ejecución. Además es muy útil en escenarios en el que estas reglas son muy variables, pues permite agregar nuevas reglas sin modificar la lógica de aplicación de estas, dejando el sistema abierto a nuevas funcionalidades sin modificacioens.

Un ejemplo podría ser un sistema de descuentos, en el que el departamento de negocio nos pide que, si la compra supera los 100€, se aplique un descuento del 5%, pero también que si la compra incluye más de 30 artículos, aplique un 10% de descuento sea cual sea el importe de la misma. Además, estos dos descuentos no son acumulables.

La implementación pordría ser simplemente un par de condicionales, y cumpliría con su función.

<?php
class Discount
{
  public function getDiscount(float $amount, int $units): int
  {
    if ($units > 30) {
      return 10;
    }
    if ($amount > 100) {
      return 5;
    }
    return 0;
  }
}

El problema del código anterior es que, si bien es muy sencillo y rápido de implementar, no está abierto a cambios, y todos los desarrolladores sabemos bien que desde negocio siempre llegan cambios.

Si desde el departamento de negocio nos indican que ahora, si tiene más de 30 artículos aplicaremos un 10% de descuento, pero si tiene más de 50 artículos tiene un 12% de descuento, y se mantiene la regla de aplicar un 5% de descuento si el importe es superior a 100€, tendremos que ir y modificar esta clase, agregando las nuevas condiciones.

<?php
class Discount
{
  public function getDiscount(float $amount, int $units): int
  {
    if ($units > 50) {
      return 12;
    }
    if ($units > 30) {
      return 10;
    }
    if ($amount > 100) {
      return 5;
    }
    return 0;
  }
}

Imaginad que ahora, nos piden que si se superan las 50 unidades y el importe es mayor que 300€, debemos aplicar un 15% de descuento, y si las unidades superan las 50, pero el importe es menor de 300€ debe aplicar el 12%.

<?php
class Discount
{
  public function getDiscount(float $amount, int $units): int
  {
    if ($units > 50) {
      if ($amount > 300) {
        return 15;
      }
      return 12;
    }
    if ($units > 30) {
      return 10;
    }
    if ($amount > 100) {
      return 5;
    }
    return 0;
  }
}

La cosa se va complicando y vamos consiguiendo un código cada vez más enrevesado y complicado de seguir. Y esto no para de crecer, aumentando las posibilidades de errores y dificultando la legibilidad y mantenibilidad del código.

Aplicando una cadena de responsabilidad.

Lo que vamos a hacer es generar un sistema que permita crear y comprobar reglas de forma genérica, de modo que cuando queramos agregar una regla o modificarla, sea más sencillo y mantenible. Estas reglas se definirán en base a unos parámetros de entrada, y podemos crear varios tipos de reglas. En esta caso, nos interesa una regla en base al importe y la cantidad de artículos, pero podríamos crear reglas en base a la fecha, u otros muchos tipos.

Por ello, vamos a crear una clase para implementar las reglas, esta tendrá un método chain para encadenar reglas y un método apply para aplicarlas de forma secuencial. Así mismo, define dos métodos privados para saber si aplica y el resultado que debe dar. De este modo, podemos definir una regla, y encadenar después otras reglas.

El método apply, tratará de aplicar la regla actual, y en caso de no ser aplicable, tratará de aplicar la siguiente si es que existe, o retornará 0 si no quedan más reglas a aplicar.

class Rule
{
    private int $result;
    private float $minAmount;
    private int $minUnits;
    private ?self $next = null;

    public function __construct(int $result, float $minAmount, int $minUnits)
    {
        $this->result = $result;
        $this->minAmount = $minAmount;
        $this->minUnits = $minUnits;
    }

    public function chain(self $rule): void
    {
        if ($this->next) {
            $this->next->chain($rule);
        } else {
            $this->next = $rule;
        }
    }

    public function apply(float $amount, int $units): int
    {
        if ($this->isApplicable($amount, $units)) {
            return $this->getResult();
        }
        return $this->next ? $this->next->apply($amount, $units) : 0;
    }

    private function isApplicable(float $amount, int $units): bool
    {
        return $amount > $this->minAmount && $units > $this->minUnits;
    }

    private function getResult(): int
    {
        return $this->result;
    }
}

Ya sólo debemos implementar la clase Discount que aplicará la primera regla, y esta aplicará de manera secuencial las siguientes reglas hasta que una de ellas cumpla la condición.

class Discount
{
  private Rule $rule;

  public function __construct(Rule $rule) {
    $this->rule = $rule;
  }

  public function getDiscount(float $amount, int $units): int
  {
    return $this->rule->apply($amount, $units);
  }
}

Por último, debemos configurar las reglas en el orden que deben ser aplicadas, así en el primer caso trataremos de aplicar la regla más restrictiva, después la siguiente, y así hasta configurar todas las reglas. Para las reglas anteriores podríamos encadenarlas así:

$rule = new AmountAndUnitsRule(15, 300, 50);      // (15%) MÁS DE 300€ Y 50 UNIDADES
$rule->chain(new AmountAndUnitsRule(12, 0, 50));  // (12%) MÁS DE 50 UNIDADES
$rule->chain(new AmountAndUnitsRule(10, 0, 30));  // (10%) MÁS DE 30 UNIDADES
$rule->chain(new AmountAndUnitsRule(5, 100, 0));  // (5%)  MÁS DE 100€

$discount = new Discount($rule);
$percent = $discount->getDiscount(301, 51); // Result 15
$percent = $discount->getDiscount(300, 51); // Result 12
$percent = $discount->getDiscount(1, 31);   // Result 10
$percent = $discount->getDiscount(101, 1);  // Result 5

Con este patrón, si se agregan nuevas reglas, sólo debemos encadenarlas. Incluso, si se diese la necesidad, podríamos crear una clase abstracta de Rule, y extenderla con distintas implementaciones, para tener en cuenta distintos parámetros, como fechas. Llegados a ese punto, sería mejor pasar el pedido completo a getDiscount para mantener una interface coherente para todas las posibles implementaciones.

Pero este ejercicio, te lo dejo a ti, que seguro que lo haces mucho mejor que yo. Aquí tienes el repositorio del proyecto por si queréis tratear con él.