Strategy Pattern: il pattern che permette di cambiare l’algoritmo a runtime

Strategy Pattern: il pattern che permette di cambiare l’algoritmo a runtime

Come sostituire un comportamento senza toccare chi lo usa, perché Laravel lo applica già in mezzo framework senza chiamarlo così, e sei modi concreti di implementarlo in un progetto reale

C’è un metodo, in quasi ogni applicazione di una certa età, che tutti riconoscono appena lo vedono. Comincia così:

public function calcolaSpedizione(Ordine $ordine): int
{
    if ($ordine->metodo === 'standard') {
        return $ordine->peso <= 2000 ? 590 : 890;
    }

    if ($ordine->metodo === 'express') {
        $base = $ordine->peso <= 2000 ? 1290 : 1890;
        return $ordine->zona === 'isole' ? $base + 500 : $base;
    }

    if ($ordine->metodo === 'ritiro') {
        return 0;
    }

    if ($ordine->metodo === 'internazionale') {
        // ... quaranta righe con fasce di peso, paesi, dogana ...
    }

    throw new \Exception('Metodo non gestito');
}

È nato con due casi, ne ha quattro, e nel prossimo trimestre ne avrà sei. Ogni nuovo metodo di spedizione significa riaprire questo file, aggiungere un ramo e sperare di non aver rotto gli altri. Il test di questo metodo è un elenco di combinazioni che cresce in modo moltiplicativo, e nessuno sa dire con certezza se il ramo “internazionale” sia ancora corretto, perché è stato scritto da una persona che non lavora più qui.

Lo Strategy Pattern è la risposta diretta a questa situazione. Nella formulazione della Gang of Four: definisce una famiglia di algoritmi, ne incapsula ciascuno in una classe e li rende interscambiabili, permettendo all’algoritmo di variare indipendentemente da chi lo usa. Tradotto nel caso sopra: quattro classi al posto di quattro rami, una scelta fatta in un punto solo, e un quinto metodo di spedizione che si aggiunge creando un file senza aprire quelli esistenti.

La parte interessante, per chi lavora con Laravel, è che il framework è costruito su questo pattern — solo che lo chiama in un altro modo. Capirlo significa smettere di scrivere l’infrastruttura a mano e usare quella che c’è già.

Il pattern in tre pezzi

Lo Strategy ha una struttura minima che vale la pena fissare, perché i nomi dei ruoli chiariscono dove va cosa.

RuoloChe cos’èNell’esempio delle spedizioni
ContestoChi ha bisogno dell’algoritmo e non sa quale siaL’action che crea l’ordine
ContrattoL’interfaccia che tutte le varianti rispettanoCalcolatoreSpedizione
StrategieLe implementazioni concrete, interscambiabiliSpedizioneStandard, SpedizioneExpress, …
SelettoreChi decide quale strategia usare (non previsto dal GoF, indispensabile nella pratica)Una factory, un enum o il container

Il quarto ruolo merita una precisazione, perché è dove si concentrano i fraintendimenti. Il GoF descrive il pattern assumendo che qualcuno, da fuori, passi la strategia giusta al contesto. Nella realtà quel “qualcuno” va scritto, e la scelta della strategia resta inevitabilmente un match da qualche parte. Lo Strategy non elimina la condizione: la riduce a una sola, la isola in un punto e la rende banale — una corrispondenza fra una chiave e una classe, senza logica dentro.

È la distinzione fra “ho un match nel selettore” e “ho un match nel calcolo, un altro nella validazione, un terzo nella vista e un quarto nel job notturno”. Il pattern serve a passare dalla seconda situazione alla prima.

Laravel usa già lo Strategy: si chiama driver

Prima di scriverne uno, conviene riconoscere quelli esistenti, perché l’architettura dei driver di Laravel è lo Strategy Pattern applicato con grande coerenza.

// Lo stesso codice applicativo, due algoritmi completamente diversi sotto
Cache::put('chiave', $valore, 600);     // Redis? File? Memcached? Array?
Storage::put('file.pdf', $contenuto);   // Disco locale? S3? FTP?
Mail::to($utente)->send($messaggio);    // SMTP? SES? Postmark? Log?
Queue::push($job);                      // Database? Redis? SQS?

In nessuna di queste righe il codice sa quale implementazione sta usando. La scelta è altrove — in un file di configurazione — e cambiarla non richiede di toccare una riga di codice applicativo:

// config/cache.php
'default' => env('CACHE_STORE', 'database'),

// config/filesystems.php
'default' => env('FILESYSTEM_DISK', 'local'),

Questo è esattamente lo Strategy: un contratto (Illuminate\Contracts\Cache\Store), molte implementazioni interscambiabili, e un selettore che le risolve a runtime. Il selettore, in Laravel, è una classe Manager — CacheManager, FilesystemManager, MailManager, SessionManager — e tutte derivano dalla stessa astrazione.

Riconoscere il meccanismo ha un vantaggio pratico immediato: per le proprie strategie si può usare la stessa infrastruttura, invece di inventarne una. Lo vedremo nel quarto esempio.

Implementazione 1 — Interfaccia e factory: la forma base

Partiamo dal caso delle spedizioni e smontiamo il metodo dell’introduzione. Primo passo: il contratto.

namespace App\Spedizioni\Contracts;

use App\Spedizioni\ContestoSpedizione;
use App\Support\Denaro;

interface CalcolatoreSpedizione
{
    public function calcola(ContestoSpedizione $contesto): Denaro;

    /** Giorni lavorativi stimati, per mostrarli nel carrello */
    public function tempiDiConsegna(ContestoSpedizione $contesto): int;
}

Il parametro è un oggetto, non un elenco di argomenti, e la ragione è strutturale: strategie diverse hanno bisogno di dati diversi. La spedizione standard guarda solo il peso, quella internazionale ha bisogno del paese e del valore della merce per la dogana, il ritiro in negozio del punto vendita scelto. Con argomenti posizionali, l’interfaccia dovrebbe contenere l’unione di tutti i bisogni di tutte le strategie — e crescerebbe a ogni strategia nuova, che è il problema da cui si voleva uscire.

namespace App\Spedizioni;

/** Tutto ciò che QUALUNQUE strategia potrebbe servirgli, in un oggetto solo */
final readonly class ContestoSpedizione
{
    public function __construct(
        public int     $pesoGrammi,
        public Denaro  $valoreMerce,
        public string  $paese,
        public string  $codicePostale,
        public ?string $puntoRitiro = null,
        public bool    $contieneArticoliFragili = false,
    ) {}

    public static function daOrdine(Ordine $ordine): self
    {
        return new self(
            pesoGrammi:  $ordine->pesoTotale(),
            valoreMerce: $ordine->totaleMerce(),
            paese:       $ordine->indirizzoSpedizione->paese,
            codicePostale: $ordine->indirizzoSpedizione->cap,
            puntoRitiro: $ordine->puntoRitiro?->codice,
            contieneArticoliFragili: $ordine->haArticoliFragili(),
        );
    }

    public function zonaItalia(): ZonaItalia
    {
        return ZonaItalia::daCodicePostale($this->codicePostale);
    }
}

Poi le strategie. Ognuna è una classe piccola, con un solo motivo per cambiare — la tariffa di quel corriere — e si legge dall’inizio alla fine senza scorrere.

namespace App\Spedizioni\Strategie;

final readonly class SpedizioneStandard implements CalcolatoreSpedizione
{
    public function __construct(private TariffarioStandard $tariffario) {}

    public function calcola(ContestoSpedizione $contesto): Denaro
    {
        $base = $this->tariffario->perFasciaDiPeso($contesto->pesoGrammi);

        return $contesto->zonaItalia() === ZonaItalia::Isole
            ? $base->piu(Denaro::daCentesimi(200))
            : $base;
    }

    public function tempiDiConsegna(ContestoSpedizione $contesto): int
    {
        return $contesto->zonaItalia() === ZonaItalia::Isole ? 5 : 3;
    }
}
final readonly class RitiroInNegozio implements CalcolatoreSpedizione
{
    public function calcola(ContestoSpedizione $contesto): Denaro
    {
        return Denaro::zero();
    }

    public function tempiDiConsegna(ContestoSpedizione $contesto): int
    {
        return 1;
    }
}

Infine il selettore. Qui sta l’unico match rimasto, e si noti che non contiene logica: solo una corrispondenza fra una chiave e una classe, risolta dal container così che le strategie possano avere le proprie dipendenze.

namespace App\Spedizioni;

use Illuminate\Contracts\Container\Container;

final readonly class FactoryCalcolatoreSpedizione
{
    public function __construct(private Container $container) {}

    public function per(MetodoSpedizione $metodo): CalcolatoreSpedizione
    {
        return $this->container->make(match ($metodo) {
            MetodoSpedizione::Standard       => SpedizioneStandard::class,
            MetodoSpedizione::Express        => SpedizioneExpress::class,
            MetodoSpedizione::Ritiro         => RitiroInNegozio::class,
            MetodoSpedizione::Internazionale => SpedizioneInternazionale::class,
        });
    }
}
// Il contesto: non sa quale algoritmo stia usando
final readonly class PreparaPreventivoSpedizione
{
    public function __construct(private FactoryCalcolatoreSpedizione $factory) {}

    public function esegui(Ordine $ordine): PreventivoSpedizione
    {
        $calcolatore = $this->factory->per($ordine->metodoSpedizione);
        $contesto    = ContestoSpedizione::daOrdine($ordine);

        return new PreventivoSpedizione(
            costo: $calcolatore->calcola($contesto),
            giorni: $calcolatore->tempiDiConsegna($contesto),
        );
    }
}

Il guadagno è misurabile: aggiungere un corriere significa creare una classe e aggiungere una riga al match. Nessun file esistente cambia comportamento, e il rischio di rompere le spedizioni standard mentre si aggiunge quella internazionale scende a zero.

Perché l’enum invece delle stringhe

Il match sopra è su un enum, non su una stringa, e la differenza è concreta: con un match esaustivo su enum, aggiungere un caso all’enum senza gestirlo nella factory produce un errore. Con le stringhe, produce un ramo mancante che si scopre in produzione.

enum MetodoSpedizione: string
{
    case Standard       = 'standard';
    case Express        = 'express';
    case Ritiro         = 'ritiro';
    case Internazionale = 'internazionale';

    public function etichetta(): string
    {
        return match ($this) {
            self::Standard       => 'Spedizione standard',
            self::Express        => 'Spedizione espressa',
            self::Ritiro         => 'Ritiro in negozio',
            self::Internazionale => 'Spedizione internazionale',
        };
    }
}

Implementazione 2 — L’enum che conosce la propria strategia

Variante più compatta della precedente: si elimina la factory e si mette la corrispondenza dentro l’enum. Funziona bene quando le strategie non hanno dipendenze complesse, ed è il modo più leggibile di scriverla in PHP moderno.

enum FormatoEsportazione: string
{
    case Csv  = 'csv';
    case Xlsx = 'xlsx';
    case Pdf  = 'pdf';
    case Json = 'json';

    /** @return class-string<Esportatore> */
    public function strategia(): string
    {
        return match ($this) {
            self::Csv  => EsportatoreCsv::class,
            self::Xlsx => EsportatoreXlsx::class,
            self::Pdf  => EsportatorePdf::class,
            self::Json => EsportatoreJson::class,
        };
    }

    public function esportatore(): Esportatore
    {
        return app($this->strategia());
    }

    public function mimeType(): string
    {
        return match ($this) {
            self::Csv  => 'text/csv',
            self::Xlsx => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
            self::Pdf  => 'application/pdf',
            self::Json => 'application/json',
        };
    }
}
// Nel controller: una riga, nessun condizionale
final class EsportaFattureController
{
    public function __invoke(EsportaFattureRequest $request, ElencoFattureQuery $query): Response
    {
        $formato = FormatoEsportazione::from($request->validated('formato'));
        $righe   = $query->esegui(FiltroFatture::daRequest($request));

        return response()->streamDownload(
            fn () => print($formato->esportatore()->genera($righe)),
            "fatture.{$formato->value}",
            ['Content-Type' => $formato->mimeType()],
        );
    }
}

Il compromesso: l’enum — che è un oggetto del dominio — adesso conosce i nomi di classi dell’infrastruttura. È accettabile in un’applicazione Laravel e sarebbe sbagliato in un dominio che si vuole indipendente dal framework. Su progetti dove quell’indipendenza conta, la factory del primo esempio è la scelta più pulita.

Implementazione 3 — La strategia che dichiara cosa sa gestire

Variante utile quando la scelta non dipende da una chiave esplicita ma da una condizione sul contesto. Ogni strategia espone un metodo che dichiara la propria competenza, e il selettore scorre l’elenco e prende la prima adatta.

interface GatewayPagamento
{
    /** Questa strategia è adatta a gestire questo pagamento? */
    public function supporta(RichiestaPagamento $richiesta): bool;

    public function autorizza(RichiestaPagamento $richiesta): EsitoPagamento;

    public function rimborsa(Pagamento $pagamento, Denaro $importo): EsitoRimborso;
}
final readonly class GatewayCartaDiCredito implements GatewayPagamento
{
    public function __construct(private ClientStripe $client, private Config $config) {}

    public function supporta(RichiestaPagamento $richiesta): bool
    {
        return $richiesta->strumento === StrumentoPagamento::Carta
            && $richiesta->importo->minoreDi(Denaro::daEuro(10_000))
            && in_array($richiesta->valuta, ['EUR', 'USD', 'GBP'], true);
    }

    public function autorizza(RichiestaPagamento $richiesta): EsitoPagamento { /* ... */ }
    public function rimborsa(Pagamento $pagamento, Denaro $importo): EsitoRimborso { /* ... */ }
}

final readonly class GatewayBonificoBancario implements GatewayPagamento
{
    public function supporta(RichiestaPagamento $richiesta): bool
    {
        // Il bonifico accetta qualunque importo, ma solo per clienti con partita IVA
        return $richiesta->strumento === StrumentoPagamento::Bonifico
            && $richiesta->cliente->haPartitaIva();
    }

    // ...
}
final readonly class SelettoreGateway
{
    /** @param iterable<GatewayPagamento> $gateway — l'ORDINE è la priorità */
    public function __construct(private iterable $gateway) {}

    public function per(RichiestaPagamento $richiesta): GatewayPagamento
    {
        foreach ($this->gateway as $candidato) {
            if ($candidato->supporta($richiesta)) {
                return $candidato;
            }
        }

        throw new NessunGatewayDisponibile($richiesta);
    }
}

Questa forma assomiglia alla Chain of Responsibility, e la somiglianza è reale: in entrambi i casi si scorre un elenco cercando il primo elemento competente. La differenza è nell’esito — qui il selettore restituisce una strategia che il chiamante poi userà come vuole, mentre nella catena il gestore esegue e la catena termina. Quando serve scegliere l’algoritmo e poi usarlo più volte, questa è la forma giusta.

Registrare l’elenco con il container tagging

Laravel ha un meccanismo apposito per iniettare un insieme di implementazioni: le etichette del container. È più ordinato di un array scritto a mano, e mantiene la risoluzione pigra — le strategie vengono istanziate solo quando servono.

// app/Providers/PagamentiServiceProvider.php
public function register(): void
{
    // L'ordine di registrazione è l'ordine di valutazione: è la priorità
    $this->app->tag([
        GatewayCartaDiCredito::class,
        GatewayPayPal::class,
        GatewayBonificoBancario::class,
        GatewayContrassegno::class,
    ], 'gateway.pagamento');

    $this->app->singleton(SelettoreGateway::class, fn ($app) =>
        new SelettoreGateway($app->tagged('gateway.pagamento'))
    );
}

Implementazione 4 — Il Manager, come lo fa Laravel

Quando le strategie sono configurabili dall’esterno e si vuole che altri — un pacchetto, un modulo, un cliente — possano aggiungerne, conviene estendere l’astrazione che il framework usa per i propri driver. Si ottengono gratis la risoluzione da configurazione, la memorizzazione delle istanze e il meccanismo di estensione.

namespace App\Documenti;

use Illuminate\Support\Manager;

/**
 * Gestisce le strategie di firma digitale dei documenti.
 * Estendendo Manager si eredita driver(), extend() e la cache delle istanze.
 */
final class FirmaManager extends Manager
{
    public function getDefaultDriver(): string
    {
        return $this->config->get('firma.driver', 'locale');
    }

    protected function createLocaleDriver(): FirmaDigitale
    {
        return new FirmaLocaleConCertificato(
            percorsoCertificato: $this->config->get('firma.locale.certificato'),
            passphrase:          $this->config->get('firma.locale.passphrase'),
        );
    }

    protected function createRemotoDriver(): FirmaDigitale
    {
        return new FirmaRemotaViaApi(
            client:  $this->container->make(HttpClient::class),
            baseUrl: $this->config->get('firma.remoto.url'),
            token:   $this->config->get('firma.remoto.token'),
        );
    }

    protected function createFintoDriver(): FirmaDigitale
    {
        return new FirmaFinta();   // per i test e l'ambiente di sviluppo
    }
}
// config/firma.php
return [
    'driver' => env('FIRMA_DRIVER', 'locale'),

    'locale' => [
        'certificato' => storage_path('certificati/azienda.p12'),
        'passphrase'  => env('FIRMA_PASSPHRASE'),
    ],

    'remoto' => [
        'url'   => env('FIRMA_API_URL'),
        'token' => env('FIRMA_API_TOKEN'),
    ],
];
// Uso: identico a Cache, Storage, Mail
$firmato = app(FirmaManager::class)->firma($documento);              // driver predefinito
$firmato = app(FirmaManager::class)->driver('remoto')->firma($doc);   // driver esplicito

// Estensione dall'esterno, senza toccare il Manager
app(FirmaManager::class)->extend('namirial', fn ($app) =>
    new FirmaViaNamirial($app->make(ClientNamirial::class))
);

Il metodo extend() è il pezzo che distingue questa forma dalle precedenti: permette di aggiungere una strategia dall’esterno del codice che la usa, che è precisamente il requisito quando si costruisce un pacchetto riutilizzabile o un’applicazione con moduli opzionali.

Implementazione 5 — La strategia scelta dal contesto applicativo

Caso frequente nelle applicazioni multi-cliente: la strategia non dipende da un parametro della richiesta ma da chi sta usando il sistema. Il binding contestuale del container risolve il caso senza che nessun chiamante se ne accorga.

// app/Providers/TariffazioneServiceProvider.php
public function register(): void
{
    // Il listino applicato dipende dal contratto del cliente corrente
    $this->app->bind(StrategiaTariffazione::class, function ($app) {
        $contratto = $app->make(ContestoCliente::class)->contrattoAttivo();

        return $app->make(match ($contratto->tipoListino) {
            TipoListino::Pubblico     => TariffazionePubblica::class,
            TipoListino::Rivenditore  => TariffazioneRivenditore::class,
            TipoListino::Convenzione  => TariffazioneDaConvenzione::class,
            TipoListino::Personalizzato => TariffazionePersonalizzata::class,
        });
    });

    // Un'eccezione mirata: il simulatore mostra sempre il listino pubblico
    $this->app->when(SimulatorePrezziController::class)
              ->needs(StrategiaTariffazione::class)
              ->give(TariffazionePubblica::class);
}
// Chi la usa non sa nulla di contratti e listini
final readonly class CalcolaPrezzoCarrello
{
    public function __construct(private StrategiaTariffazione $tariffazione) {}

    public function esegui(Carrello $carrello): Preventivo
    {
        return $this->tariffazione->applica($carrello);
    }
}

Una cautela su questa forma, che è potente e per questo pericolosa: la strategia è ora decisa da uno stato implicito, cioè dal cliente corrente. È comodissimo e rende il flusso difficile da seguire leggendo il codice, perché la risposta alla domanda “quale listino è stato applicato?” non è nel punto in cui il prezzo viene calcolato. Vale la pena usarla solo per strategie che valgono per tutta la durata della richiesta — tariffazione, lingua, valuta, fuso — e registrare nei log quale strategia ha risolto, almeno per le operazioni che toccano importi.

Implementazione 6 — La strategia come Closure, quando l’algoritmo è una riga

Non tutte le strategie meritano una classe. Quando l’algoritmo è un’espressione e le varianti sono poche, una mappa di Closure configurate in un service provider è più leggibile di quattro file.

// app/Providers/ArrotondamentoServiceProvider.php
public function register(): void
{
    $this->app->singleton('arrotondamenti', fn () => [
        'nessuno'      => fn (int $cent) => $cent,
        'cinque_cent'  => fn (int $cent) => (int) (round($cent / 5) * 5),
        'decina'       => fn (int $cent) => (int) (round($cent / 10) * 10),
        'euro_difetto' => fn (int $cent) => intdiv($cent, 100) * 100,
        'euro_eccesso' => fn (int $cent) => (int) (ceil($cent / 100) * 100),
    ]);
}
final readonly class ArrotondaImporto
{
    public function __construct(private array $strategie, private string $predefinita) {}

    public function applica(Denaro $importo, ?string $strategia = null): Denaro
    {
        $funzione = $this->strategie[$strategia ?? $this->predefinita]
            ?? throw new \InvalidArgumentException("Arrotondamento sconosciuto: {$strategia}");

        return Denaro::daCentesimi($funzione($importo->inCentesimi()));
    }
}

Il criterio per scegliere fra classe e Closure è semplice: se la strategia ha dipendenze, uno stato o più di cinque righe, è una classe; se è una funzione pura e breve, la Closure è sufficiente e costa meno in navigazione del codice. Quello che non funziona è il misto non dichiarato — metà strategie come classi e metà come Closure nello stesso insieme, senza un criterio.

Testare le strategie

Qui si raccoglie il beneficio principale del pattern, e la strategia di test si divide su tre livelli.

1. Ogni strategia da sola

È il livello che prima non esisteva: nel metodo con i quattro if, l’unico modo di collaudare il calcolo internazionale era chiamare il metodo intero con i parametri giusti. Ora si istanzia una classe e si verifica un’espressione.

it('aggiunge il supplemento isole alla spedizione standard', function () {
    $calcolatore = new SpedizioneStandard(new TariffarioStandard());

    $costo = $calcolatore->calcola(new ContestoSpedizione(
        pesoGrammi:    1_500,
        valoreMerce:   Denaro::daEuro(80),
        paese:         'IT',
        codicePostale: '09100',     // Cagliari
    ));

    expect($costo->inCentesimi())->toBe(790);   // 590 + 200 di supplemento
});

2. Il selettore, da solo

it('sceglie il bonifico solo per clienti con partita IVA', function () {
    $selettore = app(SelettoreGateway::class);

    $conPartitaIva = RichiestaPagamentoFactory::bonifico(cliente: ClienteFactory::azienda());
    $senzaPartitaIva = RichiestaPagamentoFactory::bonifico(cliente: ClienteFactory::privato());

    expect($selettore->per($conPartitaIva))->toBeInstanceOf(GatewayBonificoBancario::class);

    expect(fn () => $selettore->per($senzaPartitaIva))
        ->toThrow(NessunGatewayDisponibile::class);
});

3. Il test di conformità: tutte le strategie, lo stesso contratto

È il livello che quasi nessuno scrive e che vale più degli altri due. Un’interfaccia descrive una firma, non un comportamento: niente impedisce a una strategia di restituire un importo negativo, o di lanciare un’eccezione diversa dalle altre a parità di input invalido. Il test di conformità fissa le regole che valgono per tutte, e si esegue automaticamente su ogni implementazione.

dataset('calcolatori', [
    'standard'       => fn () => app(SpedizioneStandard::class),
    'express'        => fn () => app(SpedizioneExpress::class),
    'ritiro'         => fn () => app(RitiroInNegozio::class),
    'internazionale' => fn () => app(SpedizioneInternazionale::class),
]);

it('non restituisce mai un costo negativo', function (CalcolatoreSpedizione $c) {
    foreach (ContestoSpedizioneFactory::casiLimite() as $contesto) {
        expect($c->calcola($contesto)->inCentesimi())->toBeGreaterThanOrEqual(0);
    }
})->with('calcolatori');

it('restituisce sempre almeno un giorno di consegna', function (CalcolatoreSpedizione $c) {
    $giorni = $c->tempiDiConsegna(ContestoSpedizioneFactory::tipico());

    expect($giorni)->toBeGreaterThanOrEqual(1)->toBeLessThanOrEqual(60);
})->with('calcolatori');

it('è deterministico a parità di contesto', function (CalcolatoreSpedizione $c) {
    $contesto = ContestoSpedizioneFactory::tipico();

    expect($c->calcola($contesto)->inCentesimi())
        ->toBe($c->calcola($contesto)->inCentesimi());
})->with('calcolatori');

Il vantaggio strutturale: quando si aggiunge la quinta strategia, si aggiunge una riga al dataset e si eredita l’intera suite. È il meccanismo che impedisce alla nuova strategia di comportarsi in modo sottilmente diverso dalle altre — che è il modo in cui lo Strategy Pattern degrada nella pratica.

Sostituire una strategia nei test

it('non chiama il gateway reale quando il pagamento è in contrassegno', function () {
    $finto = new GatewayFinto();
    $this->app->instance(GatewayPagamento::class, $finto);

    // ... esegue l'operazione ...

    expect($finto->autorizzazioniRicevute())->toBeEmpty();
});

Strategy e i pattern che gli assomigliano

Quattro pattern vengono confusi con lo Strategy con una certa regolarità, e distinguerli serve a non scegliere quello sbagliato.

PatternChe cosa variaChi decideCome distinguerlo
StrategyL’algoritmoChi usa l’oggetto, da fuoriLe varianti fanno la stessa cosa in modi diversi
StateIl comportamento, al variare dello statoL’oggetto stesso, cambiando statoLe varianti si sostituiscono a vicenda durante il ciclo di vita
Template MethodAlcuni passi di un algoritmo fissoLa sottoclasse, a tempo di compilazioneUsa l’ereditarietà, non la composizione: non si cambia a runtime
Chain of ResponsibilityChi gestisce la richiestaI gestori, in sequenzaPiù gestori possono intervenire; la catena viene percorsa
FactoryQuale oggetto viene creatoLa factoryCrea, non esegue: è spesso il selettore di uno Strategy

La confusione più costosa è quella fra Strategy e State, perché i due si implementano in modo quasi identico e si usano per cose opposte. Il discriminante pratico: se è l’oggetto a decidere di passare a un’altra variante, è State. Un ordine che passa da bozza a confermato e cambia comportamento di conseguenza è una macchina a stati; un ordine che calcola la spedizione in uno dei quattro modi scelti dall’utente è uno Strategy.

Sei errori che vanificano il pattern

  1. Un’interfaccia con una sola implementazione. È generalità speculativa: si paga l’indirezione per una flessibilità che nessuno ha chiesto. La seconda implementazione è ciò che giustifica la prima astrazione — prima di quella, il codice diretto è migliore.
  2. L’interfaccia che tradisce una implementazione. Un metodo tokenStripe() nel contratto dei pagamenti costringe tutte le altre strategie a implementarlo a vuoto. Il contratto descrive ciò che il chiamante chiede, non ciò che una strategia possiede.
  3. Argomenti posizionali al posto di un contesto. Strategie diverse hanno bisogni diversi: con gli argomenti la firma diventa l’unione di tutti, e cresce a ogni strategia nuova. Un oggetto di contesto risolve il problema in modo definitivo.
  4. Il match duplicato. Isolare la scelta nella factory e poi riscriverla nella vista per mostrare l’etichetta, e ancora nel job per il ricalcolo, riporta il problema iniziale. La corrispondenza esiste in un punto solo; tutto il resto chiede a quel punto.
  5. Strategie con stato condiviso. Una strategia registrata come singleton che accumula dati fra chiamate produce risultati che dipendono dall’ordine di esecuzione — e test che passano solo in isolamento. Le strategie sono senza stato, o si registrano come bind.
  6. Usare lo Strategy per una macchina a stati. Se le varianti si sostituiscono a vicenda in base al ciclo di vita dell’oggetto, il pattern giusto è State, e forzare lo Strategy porta a un selettore che decide in base allo stato — cioè alla logica di transizione sparsa fuori dall’oggetto che la possiede.

Quando non usarlo

Tre casi in cui l’indirezione non si ripaga.

Due rami che non cresceranno. Un if fra due comportamenti stabili da anni è più leggibile di due classi e una factory. Il pattern comincia a guadagnare dal terzo caso, o dal secondo se è già chiaro che ne arriveranno altri.

Varianti che differiscono per un valore, non per un algoritmo. Se le “strategie” sono la stessa formula con coefficienti diversi, non servono classi: serve una tabella di configurazione. Quattro classi che differiscono per un numero sono quattro file da mantenere al posto di quattro righe in un array.

Varianti che vengono eseguite tutte. Se non si sceglie fra gli algoritmi ma li si applica in sequenza — ogni passo trasforma il risultato del precedente — quello non è uno Strategy ma una pipeline, e forzare il primo pattern produce un selettore che non seleziona niente.

Checklist di uno Strategy fatto bene

  • Esistono almeno due implementazioni reali del contratto.
  • Nessun metodo dell’interfaccia esiste per i bisogni di una sola strategia.
  • I dati arrivano in un oggetto di contesto, non in una lista di argomenti.
  • La scelta della strategia avviene in un punto solo: factory, enum, Manager o container.
  • La chiave di selezione è un enum, così che un caso non gestito sia un errore.
  • Le strategie sono senza stato, oppure non sono registrate come singleton.
  • Le strategie si risolvono dal container, così da poter avere dipendenze proprie.
  • Ogni strategia ha i suoi test, indipendenti dalle altre.
  • Esiste una suite di conformità eseguita su tutte le implementazioni.
  • Aggiungere una strategia non richiede di modificare il comportamento di quelle esistenti.
  • Se la strategia dipende dal contesto applicativo, viene registrato nei log quale è stata applicata.

Conclusioni

Lo Strategy Pattern è probabilmente il pattern con il miglior rapporto fra semplicità e beneficio: un’interfaccia, due o più classi, un punto di selezione. Non richiede infrastruttura, non introduce coerenza differita, non cambia il modo in cui si testa — migliora soltanto quello che già si faceva, perché un algoritmo isolato in una classe si collauda per quello che è.

Il punto che vale la pena ricordare è che il pattern non elimina il condizionale: lo sposta e lo riduce a una corrispondenza. Chi lo adotta aspettandosi di far sparire ogni match resta deluso e conclude che non serve. Chi lo adotta per avere un solo punto in cui la scelta avviene, e nessuna logica dentro quel punto, ottiene esattamente ciò che il pattern promette: aggiungere un comportamento creando un file, invece di modificarne quattro.

E per chi lavora con Laravel c’è la scorciatoia: il framework lo applica già in Cache, Storage, Mail, Queue e in ogni altro sistema a driver. Estendere Illuminate\Support\Manager per le proprie strategie significa ereditare risoluzione da configurazione, memorizzazione delle istanze e punto di estensione — infrastruttura già scritta, collaudata su milioni di installazioni e familiare a qualunque sviluppatore Laravel che aprirà quel codice dopo di noi.

Il consiglio operativo per partire è di cercare nel progetto il metodo con la catena di if più lunga su un campo di tipo — metodo di spedizione, canale di notifica, formato di export, tipo di cliente — e di estrarre un solo ramo in una classe, lasciando gli altri dove sono con un ramo di fallback. Se quella classe risulta più chiara da leggere e più rapida da collaudare del ramo che ha sostituito, si estraggono gli altri. Se no, il match stava bene dov’era, e la risposta è arrivata al costo di una classe.