Chain of Responsibility Pattern: passare una richiesta lungo una catena di gestori

Come Laravel implementa il pattern con la Pipeline, perché il cuore di tutto è array_reduce combinato con le Closure, e come si scrive un middleware per ognuna delle forme che questo meccanismo può prendere

C’è un momento, imparando Laravel, in cui si guarda un middleware e si capisce che qualcosa non torna. Il codice è questo:

public function handle($request, Closure $next)
{
    // ... qualcosa prima

    $response = $next($request);

    // ... qualcosa dopo

    return $response;
}

La domanda è: chi è $next? Non è un parametro che qualcuno passa esplicitamente, non è un metodo di una classe, e la stessa variabile dentro dieci middleware diversi punta a dieci cose diverse. Eppure la sequenza funziona, l’ordine è quello dichiarato nel kernel, e il codice dopo $next($request) viene eseguito in ordine inverso rispetto a quello prima.

La risposta è in trenta righe del framework, e sono trenta righe che vale la pena leggere perché mostrano il Chain of Responsibility Pattern realizzato nel modo più elegante che PHP consenta: una funzione nativa, array_reduce, e una pila di Closure che si chiudono l’una sull’altra. Capito quel meccanismo, il middleware HTTP smette di essere magia e — più utile ancora — diventa uno strumento che si può usare in dieci altri punti dell’applicazione, dove quasi nessuno lo usa.

Il pattern: che cos’è la Chain of Responsibility

Nella formulazione classica — quella del Design Patterns della Gang of Four, 1994 — la Chain of Responsibility è un pattern comportamentale che evita di accoppiare il mittente di una richiesta al suo destinatario, dando a più oggetti la possibilità di gestirla. La richiesta viaggia lungo una catena di gestori: ognuno decide se gestirla o passarla al successivo.

L’esempio canonico è l’escalation di una richiesta di assistenza. Arriva un ticket: il primo livello prova a risolverlo, se non è competente lo passa al secondo, che lo passa al terzo. Il mittente non sa chi lo risolverà, e aggiungere un livello non richiede di modificare nessuno degli altri.

I benefici sono due e sono entrambi di disaccoppiamento. Chi invia non conosce la catena, quindi non va modificato quando la catena cambia. E ogni gestore conosce solo il proprio compito e l’esistenza di un “successivo”, quindi può essere scritto, collaudato e sostituito da solo.

Le due varianti, e perché la distinzione conta

Qui serve una precisazione che molti articoli saltano, e che chiarisce esattamente cosa sia il middleware di Laravel.

Catena classica (GoF)Catena a cipolla (pipeline)
Quanti gestori agisconoAl massimo uno: il primo competenteTutti, in sequenza
PercorsoSolo in avantiIn avanti e, al ritorno, indietro
Il gestore può agire dopo?No: quando passa, esce di scenaSì: riprende il controllo quando il resto ha finito
Caso d’uso tipicoEscalation, dispatch per tipo, risoluzione del primo competenteMiddleware, filtri, trasformazioni concatenate
Nomi che si incontranoChain of ResponsibilityPipeline, Intercepting Filter, Russian Doll, Onion

Il middleware di Laravel è la seconda variante. È legittimo descriverlo come Chain of Responsibility — la famiglia è quella, il principio di disaccoppiamento è identico — a patto di sapere che ha una proprietà in più: il percorso di ritorno. È ciò che permette a un middleware di misurare il tempo di una richiesta, aggiungere un’intestazione alla risposta o chiudere una transazione: cose impossibili in una catena che va solo avanti.

Entrambe le varianti servono, e più avanti vedremo esempi di ciascuna. Ma il meccanismo interessante, quello che Laravel costruisce con array_reduce, è il secondo.

Il cuore del meccanismo: costruirlo da zero

Il modo più rapido per capire la Pipeline è ricostruirla in quattro passaggi, partendo da ciò che si vuole ottenere e arrivando alla riga di codice che lo produce.

1. L’obiettivo: una pila di funzioni annidate

Immaginiamo tre stadi, ognuno dei quali fa qualcosa prima e qualcosa dopo, e una destinazione finale. Scritta a mano, la struttura che vogliamo è questa:

// Quello che vogliamo ottenere, scritto a mano
$risultato = $stadioA($richiesta, function ($richiesta) use ($stadioB, $stadioC, $destinazione) {
    return $stadioB($richiesta, function ($richiesta) use ($stadioC, $destinazione) {
        return $stadioC($richiesta, function ($richiesta) use ($destinazione) {
            return $destinazione($richiesta);
        });
    });
});

Funziona, e si vede bene la forma a cipolla: A contiene B, che contiene C, che contiene la destinazione. Il problema è che questa struttura è scritta a mano per tre stadi. Con un elenco di stadi variabile — che è il caso reale, perché i middleware si aggiungono e si togliono — serve costruirla dinamicamente.

2. Gli stadi come Closure a due parametri

Prima, fissiamo la convenzione. Ogni stadio è un callable che riceve due cose: l’oggetto che viaggia e la funzione da chiamare per proseguire.

$registraTempo = function (string $testo, Closure $prosegui) {
    $inizio = microtime(true);

    $risultato = $prosegui($testo);        // passa il controllo al resto della catena

    $durata = round((microtime(true) - $inizio) * 1000, 2);
    echo "Durata totale: {$durata} ms\n";  // questo viene eseguito al RITORNO

    return $risultato;
};

$ripulisci = function (string $testo, Closure $prosegui) {
    return $prosegui(trim($testo));        // trasforma e prosegue
};

$maiuscolo = function (string $testo, Closure $prosegui) {
    return strtoupper($prosegui($testo));  // trasforma il RISULTATO, non l'ingresso
};

Si notino le tre forme diverse già presenti in questi tre stadi: il primo agisce prima e dopo, il secondo trasforma ciò che entra, il terzo trasforma ciò che esce. Sono esattamente le tre cose che fanno i middleware HTTP.

3. array_reduce: costruire la cipolla un guscio per volta

Ora il punto centrale. array_reduce scorre un array accumulando un valore: parte da un valore iniziale e, per ogni elemento, lo combina con l’accumulatore. L’intuizione che rende tutto possibile è questa: l’accumulatore non deve essere un numero o una stringa, può essere una funzione. E la combinazione non è una somma: è un avvolgimento.

$avvolgi = function (Closure $accumulato, Closure $stadio): Closure {
    // Restituisce una NUOVA funzione che chiama lo stadio
    // passandogli l'accumulato come "prosegui"
    return function ($passante) use ($accumulato, $stadio) {
        return $stadio($passante, $accumulato);
    };
};

$destinazione = fn (string $testo) => "[{$testo}]";

$pipeline = array_reduce(
    [$registraTempo, $ripulisci, $maiuscolo],   // gli stadi
    $avvolgi,                                   // la funzione che avvolge
    $destinazione,                              // il nucleo della cipolla
);

echo $pipeline('  ciao mondo  ');

Il codice gira, ma l’ordine è sbagliato. Seguiamo la costruzione passo per passo:

partenza:   destinazione
primo giro: registraTempo( ..., destinazione )            ← registraTempo avvolge la destinazione
secondo:    ripulisci( ..., registraTempo(...) )           ← ripulisci avvolge registraTempo
terzo:      maiuscolo( ..., ripulisci(...) )               ← maiuscolo avvolge tutto

risultato:  maiuscolo → ripulisci → registraTempo → destinazione

L’ultimo stadio dell’array è finito all’esterno della cipolla, e il primo è finito a contatto con la destinazione. È l’inverso di ciò che serve: l’elenco dei middleware si legge dall’alto verso il basso e il primo della lista deve essere il primo a vedere la richiesta.

4. array_reverse: la riga che sistema tutto

La soluzione è invertire l’array prima di ridurlo. Poiché la costruzione avvolge dall’interno verso l’esterno, per avere il primo stadio all’esterno bisogna aggiungerlo per ultimo.

$pipeline = array_reduce(
    array_reverse([$registraTempo, $ripulisci, $maiuscolo]),   // ← la riga decisiva
    $avvolgi,
    $destinazione,
);

echo $pipeline('  ciao mondo  ');

// Esecuzione:
// registraTempo  (avvia il cronometro)
//   ripulisci    (trim)
//     maiuscolo  (chiama il resto, poi trasforma il risultato)
//       destinazione → "[ciao mondo]"
//     maiuscolo  → "[CIAO MONDO]"
//   ripulisci    (non fa nulla al ritorno)
// registraTempo  (ferma il cronometro e stampa)
//
// Output: Durata totale: 0.04 ms
//         [CIAO MONDO]

Sono venti righe in tutto, e sono una Chain of Responsibility completa con percorso di ritorno. Nessuna interfaccia, nessuna classe astratta, nessun registro di gestori: solo una funzione nativa di PHP e la proprietà delle Closure di catturare il contesto in cui sono state create. È questo che rende il meccanismo di Laravel così compatto.

La Pipeline di Laravel: l’implementazione reale

La classe Illuminate\Pipeline\Pipeline è esattamente lo schema visto sopra, con l’aggiunta di quattro comodità: la risoluzione degli stadi dal service container, il supporto a nomi di metodo configurabili, i parametri per stadio e la gestione delle eccezioni. Il metodo centrale è questo:

public function then(Closure $destination)
{
    $pipeline = array_reduce(
        array_reverse($this->pipes()),   // l'inversione che abbiamo appena visto
        $this->carry(),                  // la funzione che avvolge
        $this->prepareDestination($destination),
    );

    return $pipeline($this->passable);
}

È la stessa riga del nostro esempio. La funzione che avvolge — carry() — è un po’ più lunga solo perché accetta tre forme diverse di stadio:

protected function carry()
{
    return function ($stack, $pipe) {
        return function ($passable) use ($stack, $pipe) {
            // 1) una Closure: viene chiamata direttamente
            if (is_callable($pipe)) {
                return $pipe($passable, $stack);
            }

            // 2) una stringa 'Classe:parametro1,parametro2':
            //    la classe viene risolta dal container e i parametri aggiunti
            if (! is_object($pipe)) {
                [$name, $parameters] = $this->parsePipeString($pipe);
                $pipe = $this->getContainer()->make($name);
                $parameters = array_merge([$passable, $stack], $parameters);
            } else {
                // 3) un oggetto già istanziato
                $parameters = [$passable, $stack];
            }

            // Chiama $pipe->handle(...) se il metodo esiste, altrimenti $pipe(...)
            return method_exists($pipe, $this->method)
                ? $pipe->{$this->method}(...$parameters)
                : $pipe(...$parameters);
        };
    };
}

Tre righe meritano di essere notate, perché spiegano comportamenti che si incontrano nell’uso quotidiano.

La risoluzione dal container è il motivo per cui un middleware può dichiarare le proprie dipendenze nel costruttore: lo stadio non viene istanziato con new, ma risolto, quindi l’iniezione funziona come in qualsiasi altra classe.

La sintassi con i due punti — 'throttle:60,1' — nasce da parsePipeString(): tutto ciò che segue i due punti diventa un parametro aggiuntivo del metodo handle, dopo $passable e $next. È il meccanismo dietro i middleware con parametri, e si può usare in qualsiasi pipeline, non solo in quelle HTTP.

Il nome del metodo è configurabile: $this->method vale handle per default e si cambia con via(). È quello che permette di scrivere stadi con un verbo di dominio invece di handle.

Usare la Pipeline direttamente

use Illuminate\Pipeline\Pipeline;

$risultato = app(Pipeline::class)
    ->send($ordine)                      // l'oggetto che viaggia
    ->through([                          // gli stadi, nell'ordine di esecuzione
        VerificaDisponibilita::class,
        ApplicaScontiListino::class,
        CalcolaSpedizione::class,
        CalcolaImposte::class,
    ])
    ->via('esegui')                      // il metodo da chiamare (default: handle)
    ->then(fn (Ordine $ordine) => $ordine->totale());

// Quando non serve una destinazione, thenReturn() restituisce l'oggetto trasformato
$ordine = app(Pipeline::class)
    ->send($ordine)
    ->through([ApplicaScontiListino::class, CalcolaImposte::class])
    ->thenReturn();

Nelle versioni recenti di Laravel esiste anche una facade Pipeline, che evita il passaggio da app() e rende la chiamata più leggibile. Il comportamento è identico: è la stessa classe.

Il middleware HTTP: dove si inserisce in questo schema

Con il meccanismo chiaro, il middleware HTTP si colloca senza misteri. Laravel costruisce una pipeline in cui l’oggetto che viaggia è la Request, gli stadi sono i middleware e la destinazione è il dispatcher della rotta — quello che chiama il controller e produce la Response.

Request
  │
  ├─► TrustProxies            ─┐
  ├─► HandleCors               │  middleware globali
  ├─► ValidatePostSize         │
  ├─► TrimStrings             ─┘
  │
  ├─► EncryptCookies          ─┐
  ├─► StartSession             │  gruppo "web"
  ├─► VerifyCsrfToken         ─┘
  │
  ├─► Authenticate            ─┐  middleware di rotta
  ├─► EnsureEmailIsVerified   ─┘
  │
  └─► [ controller ]  ← la destinazione, il nucleo della cipolla
  ┌──────────┘
  │   e poi tutto all'indietro, in ordine inverso
Response

La Closure $next che si riceve come secondo parametro è l’accumulatore costruito da array_reduce: contiene, impacchettato in una funzione, tutto il resto della catena più il controller. Chiamarla significa dire “procedi”; non chiamarla significa interrompere la catena e produrre una risposta al suo posto.

Le sei forme di un middleware

Tutti i middleware hanno la stessa firma, ma fanno cose strutturalmente diverse a seconda di dove mettono il codice rispetto a $next($request). Conoscere le forme serve a scriverne uno corretto al primo tentativo.

Forma 1 — Solo prima: ispezionare o arricchire

Il codice sta tutto prima della chiamata. È la forma più semplice e la più frequente.

class ImpostaLinguaDaProfilo
{
    public function handle(Request $request, Closure $next)
    {
        if ($utente = $request->user()) {
            app()->setLocale($utente->lingua ?? config('app.locale'));
        }

        return $next($request);
    }
}

Forma 2 — Prima e dopo: la forma che solo una cipolla consente

Il middleware riprende il controllo quando il resto della catena ha finito. È qui che il percorso di ritorno guadagna il suo valore.

class MisuraTempoRisposta
{
    public function handle(Request $request, Closure $next)
    {
        $inizio = hrtime(true);

        $response = $next($request);   // ← qui dentro gira TUTTO il resto, controller incluso

        $ms = (hrtime(true) - $inizio) / 1_000_000;

        Log::channel('performance')->info('richiesta servita', [
            'rotta'      => $request->route()?->getName(),
            'durata_ms'  => round($ms, 2),
            'stato'      => $response->getStatusCode(),
            'query_db'   => count(DB::getQueryLog()),
        ]);

        return $response->header('Server-Timing', "app;dur={$ms}");
    }
}

Forma 3 — Cortocircuito: interrompere la catena

Il middleware decide che la richiesta non deve proseguire e restituisce una risposta propria, senza mai chiamare $next. Tutto ciò che c’è a valle — middleware successivi e controller — non viene eseguito.

class BloccaDuranteManutenzione
{
    public function __construct(private StatoManutenzione $stato) {}

    public function handle(Request $request, Closure $next)
    {
        if ($this->stato->attiva() && ! $this->stato->consentito($request->ip())) {
            // $next NON viene chiamato: la catena si ferma qui
            return $request->expectsJson()
                ? response()->json(['messaggio' => 'Servizio in manutenzione'], 503)
                : response()->view('manutenzione', [], 503);
        }

        return $next($request);
    }
}

È la forma che più assomiglia alla Chain of Responsibility classica: un gestore prende in carico la richiesta e la catena si interrompe.

Forma 4 — Trasformare ciò che entra

Il middleware modifica l’oggetto in viaggio prima di passarlo. Da questo punto in poi, tutta la catena vedrà la versione modificata.

class NormalizzaPartitaIva
{
    public function handle(Request $request, Closure $next)
    {
        if ($request->has('partita_iva')) {
            $request->merge([
                'partita_iva' => strtoupper(
                    preg_replace('/[^A-Za-z0-9]/', '', $request->input('partita_iva'))
                ),
            ]);
        }

        return $next($request);
    }
}

Forma 5 — Trasformare ciò che esce

L’opposto: la richiesta passa intatta, e il middleware agisce sulla risposta che torna indietro.

class AggiungiIntestazioniSicurezza
{
    public function handle(Request $request, Closure $next)
    {
        $response = $next($request);

        return $response
            ->header('X-Content-Type-Options', 'nosniff')
            ->header('Referrer-Policy', 'strict-origin-when-cross-origin')
            ->header('Permissions-Policy', 'geolocation=(), camera=()');
    }
}

Forma 6 — Con parametri

Grazie a parsePipeString(), un middleware può ricevere argomenti dalla dichiarazione. Tutto ciò che segue i due punti arriva come parametro aggiuntivo, dopo $next.

class RichiedePiano
{
    public function handle(Request $request, Closure $next, string ...$pianiAmmessi)
    {
        $piano = $request->user()?->abbonamento?->piano;

        if (! in_array($piano, $pianiAmmessi, true)) {
            abort(403, 'Funzionalità non disponibile nel tuo piano');
        }

        return $next($request);
    }
}
// routes/web.php
Route::get('/report/avanzati', ReportAvanzatiController::class)
     ->middleware('piano:business,enterprise');

Una forma a parte — Il middleware terminabile

C’è un caso che la pipeline da sola non copre: fare qualcosa dopo che la risposta è stata inviata al browser. Laravel lo gestisce con un meccanismo separato — il metodo terminate() — invocato dopo l’invio della risposta, fuori dalla catena.

class RegistraAccesso
{
    public function handle(Request $request, Closure $next)
    {
        return $next($request);
    }

    /** Eseguito dopo che la risposta è già partita: l'utente non ne attende il tempo */
    public function terminate(Request $request, $response): void
    {
        AccessoUtente::create([
            'utente_id'  => $request->user()?->id,
            'percorso'   => $request->path(),
            'stato'      => $response->getStatusCode(),
            'ip'         => $request->ip(),
        ]);
    }
}

La distinzione pratica: il codice dopo $next($request) ritarda la risposta; quello in terminate() no. Qualsiasi scrittura non essenziale — log di accesso, statistiche, pulizie — appartiene al secondo.

La pipeline fuori dalle richieste HTTP

Questa è la parte che nella pratica viene usata meno di quanto meriti. La Pipeline è una classe generica: l’oggetto che viaggia può essere qualunque cosa, e in un’applicazione ci sono molti flussi che hanno la stessa struttura di una catena di middleware.

Catena di regole: validazione di dominio a stadi

Una pratica che deve superare sei controlli, dove ogni controllo può bocciarla e fermare tutto. Lo stesso schema del cortocircuito, applicato al dominio.

final class ValutaRichiestaFinanziamento
{
    private const CONTROLLI = [
        VerificaEtaRichiedente::class,
        VerificaRedditoMinimo::class,
        VerificaRapportoRataReddito::class,
        VerificaSegnalazioniEsterne::class,
        VerificaEsposizioneComplessiva::class,
    ];

    public function __construct(private Pipeline $pipeline) {}

    public function esegui(Richiesta $richiesta): Esito
    {
        return $this->pipeline
            ->send(new ContestoValutazione($richiesta))
            ->through(self::CONTROLLI)
            ->via('controlla')
            ->then(fn (ContestoValutazione $c) => $c->esitoFinale());
    }
}
final class VerificaRapportoRataReddito
{
    public function controlla(ContestoValutazione $contesto, Closure $prosegui)
    {
        $incidenza = $contesto->rataMensile()->diviso($contesto->redditoMensile());

        if ($incidenza->maggioreDi(Percentuale::di(35))) {
            // Cortocircuito: nessun controllo successivo viene eseguito
            return $contesto->respingi(
                motivo: 'Incidenza rata su reddito superiore al 35%',
                codice: 'RATA_REDDITO',
            );
        }

        return $prosegui($contesto->conPunteggio($contesto->punteggio() + 10));
    }
}

Il vantaggio rispetto a cinque if in sequenza è concreto: ogni controllo è una classe collaudabile da sola, l’ordine è un dato modificabile, e aggiungere un controllo non richiede di toccare nessuno degli altri — che è letteralmente la promessa del pattern.

Catena di trasformazioni: il calcolo di un prezzo

Qui nessuno stadio interrompe: ognuno modifica l’oggetto in viaggio e lo passa avanti. L’ordine è parte della regola, e averlo in un array lo rende leggibile — cosa che una catena di metodi concatenati non offre.

$preventivo = Pipeline::send(new Preventivo($articoli, $cliente))
    ->through([
        ApplicaPrezzoListino::class,
        ApplicaScontoVolume::class,
        ApplicaScontoFedelta::class,
        ApplicaPromozioneAttiva::class,
        ApplicaSpeseSpedizione::class,
        ApplicaImposte::class,            // sempre per ultimo, e il perché è nell'ordine
    ])
    ->thenReturn();

Catena di pulizia: importazione di dati

final class ImportaRigaFornitore
{
    public function esegui(array $rigaGrezza): ?ProdottoNormalizzato
    {
        return Pipeline::send(new RigaImportazione($rigaGrezza))
            ->through([
                ScartaRigheVuote::class,
                NormalizzaCodiceArticolo::class,
                ConvertiUnitaDiMisura::class,
                RisolviCategoriaDaDescrizione::class,
                ValidaCampiObbligatori::class,
            ])
            ->then(fn (RigaImportazione $r) => $r->valida() ? $r->prodotto() : null);
    }
}

Catena classica: l’escalation del primo competente

Per completezza, la variante GoF pura — quella in cui un solo gestore agisce. Non serve la Pipeline: è ancora più semplice, perché non c’è percorso di ritorno.

interface GestoreTicket
{
    public function gestisci(Ticket $ticket): ?Risoluzione;
}

final class CatenaGestoriTicket
{
    /** @param list<GestoreTicket> $gestori */
    public function __construct(private array $gestori) {}

    public function instrada(Ticket $ticket): Risoluzione
    {
        foreach ($this->gestori as $gestore) {
            if ($risoluzione = $gestore->gestisci($ticket)) {
                return $risoluzione;   // il primo competente chiude la catena
            }
        }

        return Risoluzione::escalationUmana($ticket);
    }
}
// Registrazione nel container: l'ordine è la priorità
$this->app->bind(CatenaGestoriTicket::class, fn ($app) => new CatenaGestoriTicket([
    $app->make(RispostaAutomaticaDaFaq::class),
    $app->make(ResetPasswordAutomatico::class),
    $app->make(AssegnaASupportoTecnico::class),
    $app->make(AssegnaAAmministrazione::class),
]));

Vale la pena notare quando scegliere questa forma invece della pipeline: se un solo gestore deve agire, il ciclo foreach è più chiaro della cipolla. Usare la Pipeline per una catena senza ritorno aggiunge annidamento di Closure senza ottenere niente in cambio.

Il middleware dei job in coda

Un ultimo uso, già pronto nel framework e spesso ignorato: i job in coda hanno il proprio middleware, che gira sulla stessa meccanica. È il posto corretto per limitazioni di frequenza, lock di unicità e prevenzione delle sovrapposizioni.

class SincronizzaCatalogoFornitore implements ShouldQueue
{
    use Queueable;

    public function middleware(): array
    {
        return [
            // Un solo job per fornitore alla volta
            new WithoutOverlapping($this->fornitoreId),
            // Rispetta il limite di chiamate imposto dall'API del fornitore
            new RateLimited('api-fornitori'),
        ];
    }

    public function handle(): void { /* ... */ }
}
// Un middleware di job scritto a mano: identica struttura, oggetto diverso
class SaltaSeFornitoreDisattivato
{
    public function handle(object $job, Closure $next): void
    {
        if (Fornitore::find($job->fornitoreId)?->attivo !== true) {
            $job->delete();   // cortocircuito: il job non viene eseguito
            return;
        }

        $next($job);
    }
}

Cinque errori classici

  1. Dimenticare il return. $next($request); senza return esegue tutta la catena e poi butta via la risposta: il browser riceve una pagina bianca o un errore di risposta nulla. È l’errore più frequente in assoluto e il più disorientante, perché il codice “funziona” — gli effetti collaterali avvengono, solo la risposta sparisce.
  2. Ignorare l’ordine. Un middleware che legge la sessione posto prima di quello che la avvia non trova niente, e il bug si manifesta come “a volte l’utente risulta non autenticato”. L’ordine non è una preferenza: è una dipendenza, e va trattato come tale.
  3. Mettere operazioni lente dopo $next. Scrivere statistiche o chiamare un’API nel percorso di ritorno ritarda la risposta dell’utente. Quella roba va in terminate() o in coda.
  4. Usare lo stato condiviso per comunicare fra stadi. Un middleware che scrive in una proprietà statica e un altro che la legge crea una dipendenza invisibile e un test che passa solo in un certo ordine. La comunicazione avviene attraverso l’oggetto in viaggio, che è lì esattamente per questo.
  5. Catene troppo lunghe senza bisogno. Ogni stadio è una Closure annidata, quindi un livello in più nella traccia di stack: una pipeline di quaranta stadi produce tracce illeggibili e un costo di navigazione reale per chi legge il codice. Se gli stadi diventano molti, spesso è il segnale che alcuni sono in realtà un unico passo logico.

Testare una pipeline e i suoi stadi

Il beneficio pratico del pattern si vede qui: ogni stadio si collauda da solo, passando una Closure finta come “prosegui”. Non serve né la pipeline né il framework.

it('respinge quando la rata incide per più del 35% sul reddito', function () {
    $contesto = ContestoFactory::con(rata: 700, reddito: 1_800);
    $proseguitoChiamato = false;

    $esito = (new VerificaRapportoRataReddito())->controlla(
        $contesto,
        function ($c) use (&$proseguitoChiamato) {
            $proseguitoChiamato = true;
            return $c;
        },
    );

    expect($proseguitoChiamato)->toBeFalse()           // il cortocircuito ha funzionato
        ->and($esito->respinta())->toBeTrue()
        ->and($esito->codiceRifiuto())->toBe('RATA_REDDITO');
});

it('prosegue e assegna punteggio quando l\'incidenza è accettabile', function () {
    $contesto = ContestoFactory::con(rata: 300, reddito: 2_500);

    $esito = (new VerificaRapportoRataReddito())
        ->controlla($contesto, fn ($c) => $c);

    expect($esito->respinta())->toBeFalse()
        ->and($esito->punteggio())->toBe($contesto->punteggio() + 10);
});

Per la catena completa basta un test che verifichi la composizione — che l’ordine sia quello previsto e che il cortocircuito interrompa davvero — senza riverificare le regole dei singoli stadi, già coperte sopra.

it('non esegue i controlli successivi dopo un rifiuto', function () {
    $spia = new ControlloSpia();

    $esito = Pipeline::send(ContestoFactory::con(rata: 900, reddito: 1_000))
        ->through([VerificaRapportoRataReddito::class, $spia])
        ->via('controlla')
        ->thenReturn();

    expect($esito->respinta())->toBeTrue()
        ->and($spia->eseguito())->toBeFalse();
});

Quando non usarla

Il pattern ha un costo di indirezione, e in tre casi non si ripaga.

Quando gli stadi sono due. Due if in sequenza sono più leggibili di una pipeline con due elementi. Il pattern comincia a guadagnare dal quarto o quinto stadio, o prima se l’elenco deve essere configurabile.

Quando l’ordine non conta e nessuno interrompe. Se gli stadi sono indipendenti e tutti vengono eseguiti sempre, un semplice ciclo su un array di oggetti fa la stessa cosa con meno annidamento — e una traccia di stack leggibile.

Quando il flusso si dirama. La pipeline è lineare per costruzione. Un flusso con condizioni che portano su rami diversi, forzato in una pipeline, produce stadi che si saltano a vicenda controllando una variabile nel contesto: è una macchina a stati scritta male. Se ci sono rami, serve una macchina a stati o un flusso esplicito, non una catena.

Conclusioni

Il Chain of Responsibility è uno di quei pattern che si usano prima di conoscerli: chiunque abbia scritto un middleware in Laravel lo ha già applicato. Capire che sotto c’è array_reduce con array_reverse e una pila di Closure cambia però due cose concrete.

La prima è che il middleware diventa prevedibile. Perché $next contiene tutto il resto della catena, perché l’ordine conta, perché il codice dopo la chiamata gira all’indietro, perché un middleware che non chiama $next ferma tutto: sono tutte conseguenze dirette di come la cipolla viene costruita, non convenzioni da ricordare a memoria.

La seconda, più utile, è che la Pipeline esce dal perimetro HTTP. Catene di controlli di dominio, calcoli a stadi, pulizia di dati in importazione, middleware dei job: sono tutti flussi con la stessa struttura, e in tutti il beneficio è lo stesso — ogni stadio è una classe piccola e collaudabile da sola, l’ordine è un dato leggibile, e aggiungere un passo non richiede di toccare quelli esistenti.

Il consiglio operativo per iniziare è di cercare nel progetto il metodo con la sequenza più lunga di if che possono interrompere un flusso — tipicamente una validazione di dominio o un calcolo a più fasi — e trasformare ogni condizione in uno stadio. Il risultato, se il flusso ne aveva cinque o più, è un elenco di classi che si legge come la descrizione del processo. Se ne aveva due, la risposta è altrettanto chiara: quei due if stavano bene dov’erano.