Ir al contenido

Bloque E · Composición flexible · Parte 1 de 2

Chain of Responsibility

Pasa la petición por una cadena de handlers hasta que uno la maneje · ~30 min de lectura

Encadenar para desacoplar

Esta es tu clase de Chain of Responsibility — no hay sesión en vivo de este tema. Va en dos mitades: acá (parte 1) aprendés el patrón; en el taller (parte 2) lo aplicás con tres ejercicios. El miércoles 8 lo integramos con el resto del Bloque E; para entonces se da por visto.

CoR cierra el Bloque E — composición flexible. Ya vieron Decorator (agregar capas) y Composite (árboles parte-todo). Este completa el bloque: ¿cómo paso una petición por una serie de handlers sin que el cliente sepa quién la va a resolver? Ya lo usás sin saberlo — el middleware stack de Laravel y Express es CoR, los Servlet Filters de Java también.

La idea en una línea

Cada handler sabe solo una cosa: si puede procesar la petición, la procesa.
Si no (o si quiere continuar), la pasa al siguiente. El cliente no sabe quién va a resolverla.

Order
StockHandler ¿hay stock? no → rechaza
VendorHoursHandler ¿vendor abierto? no → rechaza
PaymentHandler ¿pago válido? no → rechaza
aprobada
La petición entra por la cabeza y avanza mientras cada handler la deja pasar. El primero que no puede, corta. El cliente solo llama al primero — no sabe cuántos hay ni quién resuelve.

Tip de lectura: tocá, enfocá o pasá el mouse sobre las líneas marcadas para ver por qué importan.

Conceptos rápidos

En las anotaciones aparecen algunos términos. Si no los tenés frescos, esta es la versión corta:

  • Handler: el objeto que recibe una petición, decide si puede procesarla y la pasa al siguiente si no (o si quiere continuar).
  • Cadena: la secuencia de handlers enlazados. Se configura desde afuera (ServiceProvider, factory). El cliente solo conoce el primero.
  • passToNext / next(): el mecanismo de "pasar al siguiente". Si no se llama, la cadena se detiene ahí.
  • Pipeline: en Laravel, app(Pipeline::class)->send()->through()->thenReturn(). La implementación framework de CoR.
  • OCP (abierto/cerrado): agregar un handler nuevo no modifica los existentes. La cadena crece sin romper nada.
  • Fluent interface: setNext($b)->setNext($c) — encadenamiento de llamadas porque cada método retorna el objeto que recibió.

No tenés que memorizarlos. Si una anotación menciona uno y no lo recordás, volvé acá un momento y seguís.

1 — El if/else que no escala

Chained ifs that break on every change

Cuando un cliente hace un pedido en el marketplace, hay varias validaciones que deben pasar en orden: ¿hay stock? ¿el vendor tiene horario activo? ¿el medio de pago es válido? La primera solución —la que casi todos escribiríamos— es una cadena de if o una secuencia de llamadas dentro del servicio que procesa la orden.

El problema aparece cuando esa cadena cambia. Agregar una validación nueva (fraude, límite de crédito, zona de entrega) significa abrir el método y meterla. Reordenarlas también. Si la misma lógica se necesita en otro flujo (reorden, suscripción), la cadena se copia. Cada variante del flujo es una copia de esa secuencia con pequeñas diferencias.

// En OrderService — toda la cadena en un método
class OrderService {
    public function placeOrder(Order $order): void {
        if (!$this->inventory->hasStock($order)) {
            throw new OutOfStockException();
        }
        if (!$this->vendor->isOpen($order->vendorId)) {
            throw new VendorClosedException();
        }
        if (!$this->payment->isValid($order->paymentMethod)) {
            throw new InvalidPaymentException();
        }
        // ... procesar la orden ...
    }
}

El problema de fondo: el orden de las validaciones y la lógica de cada una viven juntos en el mismo método. Son dos preocupaciones distintas: qué validar (cada handler) y en qué orden encadenarlos (la configuración). Cuando se mezclan, cualquier cambio requiere tocar el método — y testearlo entero de nuevo.

La pregunta de diseño: ¿cómo separo "qué valida cada paso" de "en qué orden se encadenan", de forma que agregar, quitar o reordenar pasos no obligue a tocar el código existente? Eso es Chain of Responsibility.

Señales rápidas
  • Una secuencia de ifs de validación en un único método que crece cada vez que aparece una regla nueva.
  • La misma cadena (o variantes de ella) se copia en múltiples flujos del sistema.
  • Reordenar validaciones o saltear una en cierto contexto requiere tocar el código existente.
Autoevaluación

¿Cuál es el problema de diseño de fondo en la cadena de ifs de arriba?

2 — Un handler por responsabilidad

One handler per check, linked as a chain

La idea de Chain of Responsibility es simple: cada validación se convierte en un handler independiente. Cada handler sabe solo una cosa: si puede procesar la petición (y la procesa) o si debe pasarla al siguiente handler de la cadena. Ningún handler sabe cuántos hay ni quién es el penúltimo.

La cadena se arma desde afuera —en el ServiceProvider, en un factory— como una configuración. El cliente solo conoce el primer handler y le pasa la petición. Si pasa todas las validaciones, llega al final. Si alguna falla, el handler lanza una excepción o devuelve un resultado de error antes de pasar.

// El contrato de un handler
abstract class OrderHandler {
    private ?OrderHandler $next = null;

    public function setNext(OrderHandler $h): OrderHandler {
        $this->next = $h;
        return $h;
    }

    protected function passToNext(Order $order): void {
        $this->next?->handle($order);
    }

    abstract public function handle(Order $order): void;
}

Lo que importa de este contrato: el handler base provee setNext y passToNext. Los hijos solo sobreescriben handle. Si el hijo llama passToNext, la petición sigue; si no la llama (porque falló o porque quiso cortar), la cadena se detiene ahí.

Señales rápidas
  • Cada handler conoce solo a su sucesor inmediato, no al resto de la cadena.
  • El mecanismo de "pasar al siguiente" está en la clase base — los hijos solo escriben su lógica.
  • La cadena puede terminar sin que nadie la maneje (petición cae al vacío) — planificá un handler final si eso importa.
Autoevaluación

¿Por qué setNext() devuelve el handler que recibió como parámetro?

3 — Handlers concretos y la cadena

Concrete handlers + wiring the chain

Con el contrato base listo, cada validación se convierte en una clase pequeña e independiente. El handler de stock solo sabe chequear stock. El de horario del vendor solo sabe verificar horarios. Eso los hace testeables unitariamente, sin necesidad de mockear los otros handlers.

La cadena se arma en el ServiceProvider (o en un factory), no dentro del servicio que procesa la orden. El cliente solo conoce el primero.

// Los handlers concretos
class StockHandler extends OrderHandler {
    public function __construct(private Inventory $inventory) {}
    public function handle(Order $order): void {
        if (!$this->inventory->hasStock($order))
            throw new OutOfStockException();
        $this->passToNext($order);
    }
}

class VendorHoursHandler extends OrderHandler {
    public function __construct(private VendorService $vendor) {}
    public function handle(Order $order): void {
        if (!$this->vendor->isOpen($order->vendorId))
            throw new VendorClosedException();
        $this->passToNext($order);
    }
}

// Armar la cadena (en el ServiceProvider)
$stock->setNext($vendor)->setNext($payment);
$stock->handle($order); // el cliente solo conoce la cabeza

El termómetro: agregar un handler nuevo es una clase nueva + una línea en la configuración de la cadena. Ningún handler existente se toca. OCP aplicado a la cadena de procesamiento.

Un detalle importante: si ningún handler llama passToNext, la cadena se detiene ahí (ya sea porque se lanzó una excepción o porque el handler decidió cortar). Si querés garantizar que siempre llegue a un "procesador final", agregá un ApproveHandler al final de la cadena.

Señales rápidas
  • Cada handler es una clase pequeña con un único chequeo. Se testea sola, sin los otros handlers.
  • La configuración de la cadena (el orden) vive en el ServiceProvider o factory, no en el servicio.
  • Agregar una validación nueva = nueva clase + una línea de setNext. Cero modificaciones a handlers existentes.
Autoevaluación

¿Cuántos archivos existentes se modifican para agregar un FraudHandler a la cadena?

4 — CoR ya estaba en el framework

CoR in Laravel · when not to use

El ejemplo más claro en Laravel es el Pipeline: app(Pipeline::class)->send($request)->through([$mw1, $mw2, $mw3])->thenReturn(). Cada middleware es un handler. La cadena se configura en Kernel.php. El cliente (el router) solo llama al primero. Ya lo venís usando.

Cuando tenés que elegir entre un if/else simple y CoR, el criterio clave es cuánto varía la cadena y si cada paso necesita ser testeable por separado.

// Laravel Pipeline — CoR provisto por el framework
use IlluminatePipelinePipeline;

app(Pipeline::class)
    ->send($order)
    ->through([
        StockHandler::class,
        VendorHoursHandler::class,
        PaymentHandler::class,
    ])
    ->thenReturn();

// ⚠ Cuándo NO usar CoR
if (!$cond1) throw ...; // 2 validaciones fijas → if/else es más honesto
if (!$cond2) throw ...;

El costo de CoR es real: más clases, más indirección. Una cadena de tres handlers simples que nunca va a cambiar es sobre-engineering. CoR paga cuando la cadena varía, crece, o cada paso necesita ser testeable de forma independiente.

Con esto ya tenés el patrón entero. La segunda mitad de la sesión es práctica: andá al taller de Chain of Responsibility — tres ejercicios donde detectás el smell, escribís tu propio handler y decidís cuándo CoR es sobre-ingeniería. Ahí lo fijás con las manos.

Señales rápidas
  • Un pipeline de middlewares o filtros: CoR clásico. Ya lo usás en Laravel y Express.
  • La cadena tiene más de 3-4 pasos y puede variar por contexto o crecer con el tiempo.
  • Dos validaciones fijas que nunca cambian: un if/else directo es más honesto que CoR.
No confundir CoR con Decorator: en Decorator todos los wrappers procesan la petición y la devuelven modificada (apilan responsabilidades). En CoR, cada handler decide si procesa O pasa — solo uno (o ninguno) "maneja" definitivamente. La estructura es similar; la intención es distinta.
Autoevaluación

¿Cuándo es un error aplicar Chain of Responsibility?

Para ir más lejos

La fuente y material para seguir:

  • Refactoring.guru. Chain of Responsibility — el patrón con diagramas y ejemplos paso a paso.
  • Gamma, Helm, Johnson, Vlissides (1994). Design Patterns. El capítulo de Chain of Responsibility, para la formulación original GoF.
  • Laravel. Middleware y Pipeline — CoR provisto por el framework.