Come sostituire un’applicazione legacy un pezzo per volta, dal primo proxy fino alla cancellazione dell’ultimo file, con esempi concreti in Laravel
C’è un momento, nella vita di ogni applicazione che ha avuto successo, in cui qualcuno pronuncia la frase: “tanto vale rifarla da zero”. Di solito arriva dopo un bug corretto in tre punti diversi, o dopo aver scoperto che la funzione che calcola il totale dell’ordine è lunga ottocento righe e viene richiamata da undici file. La frase sembra ragionevole. È quasi sempre l’inizio del progetto più costoso che quell’azienda affronterà.
Il motivo è noto e documentato da vent’anni: durante una riscrittura completa il sistema vecchio continua a vivere, a ricevere correzioni e nuove funzionalità, mentre quello nuovo insegue un bersaglio mobile. Il giorno del passaggio — che arriva sempre in ritardo — si concentra tutto il rischio accumulato in mesi di lavoro in un’unica notte. E la conoscenza sedimentata nel codice legacy, quella fatta di casi particolari che nessuno ha documentato ma che qualcuno, tre anni fa, ha aggiunto per un motivo preciso, va perduta in silenzio.
Lo Strangler Fig Pattern propone l’alternativa: invece di sostituire l’applicazione in un colpo solo, la si avvolge, se ne sostituisce un pezzo per volta e si lascia che il vecchio sistema si riduca progressivamente fino a scomparire. Il nome viene da Martin Fowler, che nel 2004 lo derivò dal fico strangolatore australiano: una pianta che germoglia sui rami di un albero ospite, cala le radici fino al suolo, lo avvolge e alla fine resta in piedi da sola, nella forma dell’albero che l’ha sostenuto.
La metafora è precisa anche in un dettaglio che spesso si trascura: il fico non abbatte l’albero. Lo sostituisce così gradualmente che, quando l’ospite muore, la struttura portante è già altrove. Applicato al software, significa che non esiste un “giorno del passaggio”: esistono cinquanta piccoli passaggi, ognuno dei quali reversibile in pochi minuti.
Che cosa dice il pattern, in tre movimenti
Sotto le numerose varianti, il pattern si riduce sempre a tre movimenti che si ripetono in ciclo.
- Intercettare. Si inserisce un punto di controllo davanti all’applicazione legacy — un proxy, un router, una facciata — che riceve tutte le richieste e le inoltra al vecchio sistema. Al termine di questo movimento nulla è cambiato per l’utente, ed è esattamente il punto: ora esiste un interruttore.
- Sostituire. Si sceglie una funzionalità, la si reimplementa nel sistema nuovo e si dirotta su di essa il traffico corrispondente. Il resto continua ad andare al legacy.
- Eliminare. Quando la nuova implementazione è stabile, si cancella il codice legacy che serviva quella funzionalità. È il movimento che quasi tutti saltano ed è quello che determina se il progetto finirà davvero.
Il valore del pattern non sta nella tecnologia, che è banale, ma nella proprietà che ne deriva: ogni passo è piccolo, verificabile e reversibile. Un problema in produzione non richiede un rollback di sei mesi di lavoro, ma un’interruttore riportato su “legacy”. È questa proprietà che permette di modernizzare un sistema che deve restare in funzione ventiquattr’ore al giorno.
Quando conviene e quando no
Non è la risposta a tutto. Lo Strangler Fig conviene quando il sistema è grande, in produzione, con utenti reali e un flusso continuo di richieste di modifica: cioè quando fermarsi non è un’opzione. Su un’applicazione piccola — poche migliaia di righe, un dominio che una persona tiene in testa — la riscrittura diretta è più rapida e il pattern aggiunge solo complessità infrastrutturale.
Ha inoltre un prerequisito organizzativo severo: per un periodo che si misura in mesi, e talvolta in anni, esisteranno due sistemi contemporaneamente, entrambi da mantenere, monitorare e rilasciare. Chi non è pronto a sostenere questo costo temporaneo troverà il pattern frustrante, e finirà nello scenario peggiore di tutti: due sistemi permanenti, con il legacy mai rimosso.
Passo 1 — Mappare il legacy per capacità di business
Il primo lavoro non è tecnico. Prima di decidere cosa sostituire, serve sapere che cosa fa l’applicazione, espresso nel linguaggio di chi la usa e non in quello delle sue cartelle. L’output è una mappa delle capacità: “emissione preventivo”, “gestione carrello”, “fatturazione ricorrente”, “esportazione contabile”, “area riservata rivenditori”.
Per ogni capacità servono cinque informazioni, ed è utile raccoglierle in una tabella condivisa con chi conosce il business:
| Informazione | Perché serve |
|---|---|
| Rotte e URL coinvolti | Sono il confine tecnico su cui il proxy potrà intercettare |
| Tabelle lette e scritte | È il vero indicatore di accoppiamento: due capacità che scrivono la stessa tabella non sono separabili con leggerezza |
| Volume di traffico | Decide quanto rischio comporta sbagliare quella fetta |
| Frequenza di modifica | Il codice che cambia ogni settimana è quello che ripaga prima la sostituzione |
| Grado di dolore | Quante segnalazioni, quante ore di manutenzione, quanti “non si può fare” |
Le prime due si ricavano dal codice, e vale la pena automatizzarle: un’analisi dei log del server dà le rotte realmente usate (spesso un terzo delle pagine non viene chiamato da anni), e una ricerca sulle query dà la mappa tabelle-funzionalità. Le altre tre si ricavano parlando con le persone, e sono quelle che decidono l’ordine di lavoro.
La scoperta che cambia i piani
In questa fase emerge quasi sempre un fatto scomodo: una parte consistente dell’applicazione non serve più. Funzionalità costruite per un cliente che non è più cliente, report che nessuno apre dal 2019, tre sistemi di esportazione dove ne basta uno. Prima di sostituire qualcosa, conviene verificare se si può semplicemente spegnerlo: la fetta più economica da migrare è quella che si cancella. Un contatore di utilizzo per rotta, lasciato girare un paio di mesi, è l’investimento con il ritorno più alto dell’intero progetto.
Passo 2 — Inserire il punto di intercettazione
È il movimento che abilita tutti gli altri: mettere davanti al legacy uno strato che riceve ogni richiesta e decide dove mandarla. Esistono due approcci, e la scelta dipende da quanto controllo si ha sull’infrastruttura.
Approccio A: il router sta nel web server
È la soluzione più pulita quando si può mettere mano alla configurazione. Nginx riceve tutto e smista per prefisso di percorso: ciò che è già stato migrato va alla nuova applicazione Laravel, tutto il resto al legacy.
upstream legacy_app { server 127.0.0.1:8080; } # la vecchia applicazione PHP
upstream laravel_app { server 127.0.0.1:9000; } # la nuova
server {
listen 443 ssl;
server_name app.esempio.it;
# --- Fette già migrate: elenco che cresce a ogni rilascio ---
location /fatture { proxy_pass http://laravel_app; }
location /api/v2 { proxy_pass http://laravel_app; }
location /area-clienti { proxy_pass http://laravel_app; }
# --- Tutto il resto resta al legacy ---
location / {
proxy_pass http://legacy_app;
proxy_set_header X-Request-Id $request_id; # tracciabilità fra i due sistemi
}
}Il pregio è la semplicità e l’assenza di costo a runtime. Il limite è la granularità: si instrada per percorso, non per utente o per percentuale di traffico, e ogni modifica richiede un rilascio di configurazione.
Approccio B: il router è Laravel stesso
Qui si inverte la prospettiva: la nuova applicazione Laravel diventa il punto d’ingresso, serve le rotte che ha già implementato e inoltra al legacy tutto ciò che non riconosce. È l’opzione che dà più controllo, perché la decisione di instradamento diventa codice PHP: si può decidere in base all’utente, a un feature flag, a una percentuale.
// routes/web.php — le rotte già migrate stanno sopra, il legacy raccoglie il resto
Route::get('/fatture', [FatturaController::class, 'index']);
Route::get('/fatture/{uuid}', [FatturaController::class, 'show']);
// Qualunque rotta non dichiarata sopra finisce qui
Route::fallback(LegacyProxyController::class);namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpFoundation\StreamedResponse;
class LegacyProxyController extends Controller
{
/** Header che non vanno mai inoltrati così come sono */
private const HOP_BY_HOP = ['host', 'connection', 'content-length', 'transfer-encoding'];
public function __invoke(Request $request): StreamedResponse
{
$target = rtrim(config('legacy.base_url'), '/') . '/' . ltrim($request->path(), '/');
$response = Http::withHeaders($this->forwardableHeaders($request))
->withOptions([
'allow_redirects' => false, // i redirect li gestisce il browser, non il proxy
'stream' => true, // i download di grandi file non devono passare in memoria
])
->withBody($request->getContent(), $request->header('Content-Type', 'application/octet-stream'))
->timeout(config('legacy.timeout', 30))
->send($request->method(), $target, ['query' => $request->query()]);
return response()->stream(
fn () => print($response->body()),
$response->status(),
$this->forwardableResponseHeaders($response),
);
}
private function forwardableHeaders(Request $request): array
{
$headers = collect($request->headers->all())
->reject(fn ($v, $k) => in_array(strtolower($k), self::HOP_BY_HOP, true))
->map(fn ($v) => is_array($v) ? implode(', ', $v) : $v)
->all();
// Identificativo di correlazione: permette di seguire una richiesta nei log di entrambi i sistemi
$headers['X-Request-Id'] = $request->header('X-Request-Id') ?? (string) str()->uuid();
return $headers;
}
}Tre avvertenze pratiche su questo controller, tutte imparate sul campo. I cookie vanno inoltrati in entrambe le direzioni, altrimenti la sessione legacy si rompe al primo giro. Il timeout deve essere esplicito e più corto di quello del web server, così che un legacy bloccato non trascini giù anche la nuova applicazione. E i file di grandi dimensioni vanno trasmessi in streaming: un proxy che carica in memoria un PDF da ottanta megabyte funziona benissimo in sviluppo e satura la RAM il primo giorno in produzione.
La regola d’oro di questo passo
Al termine del passo 2, il comportamento visibile dell’applicazione deve essere identico a prima. Nessuna funzionalità migrata, nessuna schermata rifatta, nessun miglioramento colto al volo. Sembra una giornata sprecata ed è la fondazione dell’intero progetto: si sta verificando che l’intercettazione funzioni, da sola, senza altre variabili in gioco. Chi approfitta del proxy per “già che ci siamo” sistemare due cose si ritrova a fare il debug di due problemi intrecciati.
Passo 3 — Scegliere la prima fetta
La scelta della prima funzionalità da sostituire decide la percezione del progetto in azienda, e va fatta con criteri diversi da quelli che si userebbero per la seconda o la ventesima.
La prima fetta ideale ha quattro caratteristiche: è a bassa criticità (se sbaglia, nessuno perde soldi), è poco accoppiata ai dati (legge molto e scrive poco, meglio se su tabelle sue), è visibile (qualcuno si accorge che è migliorata) ed è completa, cioè attraversa tutti gli strati — rotta, logica, dati, interfaccia. Quest’ultimo punto è il più importante: la prima fetta serve a costruire e collaudare l’intera catena di lavoro, dal routing al deploy al monitoraggio. Una fetta che tocca solo il livello di presentazione lascia scoperti proprio i problemi difficili.
I candidati tipici sono l’esportazione di un report, una pagina di consultazione ad alto traffico, un’area informativa, un endpoint API di sola lettura. I candidati sbagliati sono altrettanto riconoscibili: il flusso di pagamento, la procedura di chiusura mensile, la funzione che tutti citano come “quella che non osiamo toccare”. Arriveranno, ma non per prime.
Passo 4 — Decidere la strategia sui dati
Questo è il passo dove i progetti di modernizzazione falliscono davvero. Il codice si sostituisce con relativa facilità; il database condiviso, no. Le strategie possibili sono tre e vanno scelte consapevolmente, perché hanno conseguenze molto diverse.
| Strategia | Come funziona | Quando sceglierla | Prezzo da pagare |
|---|---|---|---|
| Database condiviso | Vecchio e nuovo leggono e scrivono le stesse tabelle | Fase iniziale, quasi sempre obbligata | Il nuovo codice eredita lo schema vecchio; nessuno dei due può cambiarlo liberamente |
| Sincronizzazione | Ogni sistema ha il suo schema, allineati da un processo di replica o da eventi | Quando lo schema nuovo diverge in modo sostanziale | Coerenza differita: va deciso chi è la fonte di verità per ogni entità |
| Passaggio di proprietà | La fetta migrata diventa proprietaria dei suoi dati; il legacy vi accede solo tramite API | Obiettivo finale di ogni fetta | Richiede di modificare il legacy, che è precisamente ciò che si voleva evitare |
Il percorso realistico attraversa tutte e tre nell’ordine, per ogni fetta. Si parte condividendo il database, si introduce la sincronizzazione quando il modello nuovo diverge, si arriva al passaggio di proprietà quando la fetta è matura. Chi tenta di saltare direttamente alla terza si ferma al primo caso in cui il legacy scrive una colonna che credeva di aver abbandonato.
Convivere con lo schema legacy in Laravel
Nella fase di database condiviso, Eloquent va istruito a parlare con tabelle che non seguono nessuna delle sue convenzioni. Si dichiara una connessione dedicata e si scrivono modelli espliciti, tenendoli separati da quelli del dominio nuovo.
// config/database.php
'connections' => [
'legacy' => [
'driver' => 'mysql',
'host' => env('LEGACY_DB_HOST'),
'database' => env('LEGACY_DB_DATABASE'),
'username' => env('LEGACY_DB_USERNAME'), // in sola lettura finché è possibile
'password' => env('LEGACY_DB_PASSWORD'),
'charset' => 'latin1', // sì, capita ancora
'collation' => 'latin1_swedish_ci',
'strict' => false, // lo schema vecchio non sopravvive alla modalità strict
],
],namespace App\Legacy\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Mappatura fedele della tabella legacy. Nessuna logica di dominio qui dentro:
* questa classe esiste solo per leggere righe, non per rappresentare concetti.
*/
class TblFatture extends Model
{
protected $connection = 'legacy';
protected $table = 'TBL_FATTURE';
protected $primaryKey = 'ID_FATT';
public $timestamps = false;
protected $casts = [
'DT_EMISS' => 'date',
'IMP_TOT' => 'decimal:2',
'FLG_ANNULL' => 'boolean',
];
}L’anti-corruption layer: il pezzo che salva il progetto
Qui arriva il concetto più importante dell’intero passo. Se il codice nuovo usa direttamente i modelli legacy — con i loro nomi di colonna incomprensibili, i flag a tre stati e le convenzioni degli anni Duemila — non si è costruita un’applicazione nuova: si è costruita un’estensione di quella vecchia, con una sintassi più moderna.
L’anti-corruption layer è lo strato di traduzione che impedisce questo contagio. Concretamente, in Laravel, è un repository che restituisce oggetti del dominio nuovo e un traduttore che fa la conversione. Tutto ciò che sa di legacy resta chiuso lì dentro.
namespace App\Fatturazione\Domain;
/** Oggetto del dominio nuovo: nomi comprensibili, invarianti garantite */
final readonly class Fattura
{
public function __construct(
public string $numero,
public \DateTimeImmutable $dataEmissione,
public Denaro $imponibile,
public Denaro $imposta,
public StatoFattura $stato,
) {}
public function totale(): Denaro
{
return $this->imponibile->piu($this->imposta);
}
}namespace App\Fatturazione\Infrastructure;
use App\Legacy\Models\TblFatture;
use App\Fatturazione\Domain\{Fattura, Denaro, StatoFattura, FatturaRepository};
final class FatturaRepositoryLegacy implements FatturaRepository
{
public function perNumero(string $numero): ?Fattura
{
$riga = TblFatture::query()
->where('NUM_FATT', $numero)
->first();
return $riga ? $this->traduci($riga) : null;
}
/**
* Tutta la bruttezza del legacy finisce e muore in questo metodo.
* Il resto dell'applicazione non sa che TBL_FATTURE esista.
*/
private function traduci(TblFatture $r): Fattura
{
return new Fattura(
numero: trim($r->NUM_FATT),
dataEmissione: \DateTimeImmutable::createFromInterface($r->DT_EMISS),
imponibile: Denaro::daEuro($r->IMP_IMPON),
imposta: Denaro::daEuro($r->IMP_IVA),
stato: match (true) {
$r->FLG_ANNULL => StatoFattura::Annullata,
$r->DT_PAGAM !== null => StatoFattura::Pagata,
$r->DT_SCAD < now() => StatoFattura::Scaduta,
default => StatoFattura::Emessa,
},
);
}
}Il beneficio si vede il giorno in cui la fetta diventa proprietaria dei propri dati: si scrive una seconda implementazione dello stesso contratto, che legge dalle tabelle nuove, si cambia il binding nel service container e non si tocca una riga del resto dell’applicazione. Senza questo strato, quello stesso giorno significa riscrivere ogni controller e ogni vista che menzionava IMP_TOT.
// app/Providers/AppServiceProvider.php
public function register(): void
{
$this->app->bind(
FatturaRepository::class,
fn () => config('fatturazione.origine_dati') === 'nuova'
? new FatturaRepositoryEloquent()
: new FatturaRepositoryLegacy(),
);
}Passo 5 — Risolvere sessione e autenticazione condivise
È l’ostacolo che ferma più progetti alla seconda settimana, e merita un passo a sé. Finché i due sistemi convivono, l’utente deve poter passare da una pagina legacy a una pagina Laravel senza accorgersi di nulla, e soprattutto senza doversi autenticare due volte.
La sessione
La strada più solida è far leggere a Laravel la sessione del legacy, non il contrario: si evita di toccare il codice vecchio, che è l’obiettivo. Se il legacy usa le sessioni native di PHP su file o su Redis, un middleware può leggere il cookie di sessione, recuperare il contenuto e ricostruire l’identità dell’utente per la durata della richiesta.
namespace App\Http\Middleware;
use App\Legacy\LegacySessionReader;
use Illuminate\Support\Facades\Auth;
class AutenticaDaSessioneLegacy
{
public function __construct(private LegacySessionReader $sessioni) {}
public function handle($request, \Closure $next)
{
if (Auth::check()) {
return $next($request);
}
$idSessione = $request->cookie(config('legacy.session_cookie', 'PHPSESSID'));
if ($idSessione && $dati = $this->sessioni->leggi($idSessione)) {
// Il legacy salva l'id utente in $_SESSION['utente_id']
if ($idUtente = $dati['utente_id'] ?? null) {
Auth::onceUsingId($idUtente); // valida per questa richiesta, nessuna sessione duplicata
}
}
return $next($request);
}
}L’uso di onceUsingId anziché di un login completo è deliberato: la fonte di verità della sessione resta una sola, quella legacy. Due sessioni parallele che possono divergere — l’utente fa logout dal vecchio sistema e resta autenticato sul nuovo — sono una categoria di bug che è meglio non far nascere.
Le credenziali
Quando invece è il login stesso a essere migrato, il problema diventa la verifica delle password storiche, spesso salvate con algoritmi che oggi non si userebbero. La soluzione elegante è un user provider che accetta l’hash vecchio, lo verifica e lo riscrive nel formato moderno al primo accesso riuscito: la migrazione delle credenziali avviene da sola, utente per utente, senza forzare un cambio password di massa.
namespace App\Auth;
use Illuminate\Auth\EloquentUserProvider;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Facades\Hash;
class LegacyUserProvider extends EloquentUserProvider
{
public function validateCredentials(Authenticatable $user, array $credentials): bool
{
$hash = $user->getAuthPassword();
// Password già migrata al formato corrente
if (str_starts_with($hash, '$2y$') || str_starts_with($hash, '$argon2')) {
return parent::validateCredentials($user, $credentials);
}
// Formato legacy: sha1 con salt per utente, come lo faceva il vecchio sistema
$atteso = sha1($user->salt_legacy . $credentials['password']);
if (! hash_equals($atteso, $hash)) {
return false;
}
// Riscrittura silenziosa nel formato moderno: succede una volta sola per utente
$user->forceFill([
'password' => Hash::make($credentials['password']),
'salt_legacy' => null,
])->save();
return true;
}
}// app/Providers/AppServiceProvider.php
public function boot(): void
{
Auth::provider('legacy', fn ($app, array $config) =>
new LegacyUserProvider($app['hash'], $config['model'])
);
}
// config/auth.php → 'providers' => ['users' => ['driver' => 'legacy', 'model' => User::class]]Passo 6 — Implementare la fetta dietro un feature flag
La nuova implementazione non arriva in produzione con un rilascio: arriva spenta, e viene accesa in un secondo momento. La distinzione fra “rilasciare” e “attivare” è ciò che rende il pattern sicuro, perché permette di portare il codice in produzione quando è pronto e di esporlo agli utenti quando si è pronti a sorvegliarlo.
In Laravel lo strumento naturale è Pennant, che gestisce flag con risoluzione per utente e memorizzazione persistente.
// app/Providers/AppServiceProvider.php
use Laravel\Pennant\Feature;
Feature::define('fatture-nuove', fn (User $utente) => match (true) {
$utente->isStaffInterno() => true, // prima i colleghi
$utente->isBetaTester() => true, // poi i volontari
default => Lottery::odds(5, 100), // poi il 5% degli altri
});// routes/web.php — la stessa rotta, due implementazioni
Route::get('/fatture', function (FatturaRepository $repo, Request $request) {
return Feature::active('fatture-nuove')
? app(FatturaController::class)->index($request)
: app(LegacyProxyController::class)($request);
});
// In alternativa, con il middleware fornito dal pacchetto:
Route::middleware('features:fatture-nuove')->group(function () {
Route::get('/fatture', [FatturaController::class, 'index']);
});La disciplina che i flag richiedono
I feature flag hanno un costo: ogni flag è un ramo condizionale che vive nel codice, e venti flag dimenticati producono un’applicazione con un milione di percorsi possibili di cui nessuno è testato. Due regole bastano a tenerli sotto controllo. La prima: ogni flag nasce con una data di scadenza scritta nel codice, e superata quella data o viene rimosso o viene discusso. La seconda: un flag si rimuove insieme al ramo che disattiva, mai lasciando il if con un ramo morto — altrimenti la pulizia non avviene mai.
Passo 7 — Deviare il traffico gradualmente
Con la fetta in produzione e il flag in mano, si apre il rubinetto per gradi. La sequenza che funziona è sempre la stessa e vale la pena rispettarla anche quando si è sicuri.
- Solo interno. Il team e i colleghi che conoscono il sistema. Durata: qualche giorno di uso reale, non una demo.
- Utenti selezionati. Un gruppo ristretto, avvisato, con un canale diretto per segnalare. Qui emergono i casi particolari che nessuna specifica conteneva.
- Percentuale crescente. 5%, 25%, 50%, 100%, con almeno una giornata piena a ogni scalino — le anomalie che contano si manifestano nel picco di traffico, non nel test.
- Totale, flag ancora presente. Per un paio di settimane il vecchio percorso resta disponibile e riaccendibile in trenta secondi. È l’assicurazione più economica del progetto.
Un accorgimento poco intuitivo ma importante: l’assegnazione dev’essere stabile per utente, non casuale a ogni richiesta. Un utente che vede la schermata nuova, ricarica e ritrova quella vecchia apre un ticket, e ha ragione. Pennant risolve il punto memorizzando la decisione, ma se si implementa a mano il criterio deve essere deterministico — tipicamente un hash dell’identificativo utente.
Passo 8 — Verificare con l’esecuzione in parallelo
Per le fette che producono numeri — importi, calcoli tariffari, totali di fattura, saldi — il collaudo migliore non è un test automatico ma il confronto con il sistema vecchio sui dati veri. La tecnica si chiama parallel run o shadow mode: entrambe le implementazioni girano, all’utente si mostra ancora quella legacy e le differenze vengono registrate.
namespace App\Fatturazione\Verifica;
use Illuminate\Support\Facades\Log;
class ConfrontoImplementazioni
{
public function __construct(
private CalcolatoreLegacy $vecchio,
private CalcolatoreNuovo $nuovo,
) {}
public function calcolaTotale(Ordine $ordine): Denaro
{
$risultatoLegacy = $this->vecchio->calcola($ordine);
// Il nuovo calcolo non deve mai far fallire la richiesta dell'utente
rescue(function () use ($ordine, $risultatoLegacy) {
$risultatoNuovo = $this->nuovo->calcola($ordine);
if (! $risultatoLegacy->equals($risultatoNuovo)) {
Log::channel('confronto')->warning('Divergenza nel calcolo totale', [
'ordine_id' => $ordine->id,
'legacy' => $risultatoLegacy->inCentesimi(),
'nuovo' => $risultatoNuovo->inCentesimi(),
'delta' => $risultatoNuovo->inCentesimi() - $risultatoLegacy->inCentesimi(),
'request_id' => request()->header('X-Request-Id'),
]);
}
}, report: true);
return $risultatoLegacy; // all'utente va ancora il risultato del sistema vecchio
}
}Nelle prime settimane le divergenze saranno molte, e la sorpresa ricorrente è che una parte di esse rivela bug nel sistema legacy, non nel nuovo. A quel punto si apre una decisione che è di business e non tecnica: replicare il comportamento sbagliato per non cambiare i numeri storici, o correggerlo e gestire la discontinuità. Va posta a chi di dovere, mai risolta in autonomia da chi scrive il codice.
Quando il tasso di divergenza arriva a zero e ci resta per un periodo che copra tutti i casi ricorrenti — tipicamente una chiusura mensile — la fetta è pronta a diventare quella servita davvero. Quando è pesante da calcolare, il confronto si fa su un campione: anche il 5% del traffico, su volumi reali, scopre in una settimana ciò che una suite di test non troverebbe.
Passo 9 — Potare, cioè rimuovere il legacy
È il passo che chiude il ciclo ed è quello che, statisticamente, non viene eseguito. Il motivo è umano: la fetta nuova funziona, l’attenzione si sposta sulla successiva, e cancellare codice non produce nulla di visibile. Ma un progetto Strangler Fig in cui non si cancella mai nulla non è una modernizzazione: è un raddoppio permanente della superficie da mantenere.
La rimozione va trattata come un’attività pianificata, con tre passaggi verificabili.
- Verificare che sia davvero morto. Prima di cancellare, si strumenta il codice legacy perché registri ogni propria esecuzione. Se dopo un ciclo di business completo — un mese, un trimestre se ci sono attività trimestrali — il contatore è a zero, è morto. Se non lo è, qualcosa lo chiama ancora: un cron, un’integrazione, una pagina dimenticata.
- Rimuovere in blocco, non a pezzi. Rotta, controller, funzioni di supporto, template, file CSS, voci di menu, job schedulati, e le colonne di database usate solo da quel codice. Le rimozioni parziali lasciano dietro di sé frammenti che nessuno saprà più collegare a nulla.
- Rimuovere anche il flag. Una volta cancellato il percorso legacy, il flag non ha più due rami: va tolto insieme al suo
if, altrimenti resterà lì per anni a suggerire che esista ancora un’alternativa.
// Strumentazione temporanea prima della potatura: sta nel legacy per un ciclo,
// poi sparisce insieme al codice che sorvegliava.
function legacy_calcola_totale($ordine) {
error_log(sprintf(
'[ZOMBIE] %s chiamata da %s — ordine %d',
__FUNCTION__,
$_SERVER['REQUEST_URI'] ?? 'CLI',
$ordine['id']
));
// ... implementazione originale, ancora intatta ...
}Un consiglio che sembra banale e non lo è: il conteggio del codice legacy rimosso va reso visibile — righe cancellate, file eliminati, tabelle dismesse, percentuale di rotte migrate. È l’unico indicatore che dice se il progetto sta davvero convergendo, ed è anche ciò che permette di rispondere alla domanda che arriverà inevitabilmente da chi finanzia il lavoro: “a che punto siamo?”.
Passo 10 — Ripetere, e riconoscere la fine
Dal passo 3 in poi il ciclo si ripete, e diventa progressivamente più veloce perché l’infrastruttura è già in piedi. Due considerazioni valgono per la corsa lunga.
La prima riguarda il ritmo. Conviene alternare fette impegnative e fette semplici, e mantenere un flusso continuo di rilasci anche piccoli. Un progetto di modernizzazione che passa tre mesi senza mostrare nulla perde il sostegno di chi lo finanzia, indipendentemente da quanto lavoro utile sia stato fatto sotto la superficie.
La seconda riguarda la fine. Non sempre “tutto migrato” è l’obiettivo giusto: capita che una porzione del legacy sia stabile, poco toccata e perfettamente funzionante, e che sostituirla non produca alcun beneficio. Fermarsi consapevolmente, dichiarando che quella parte resta dov’è e viene isolata dietro un’interfaccia, è una decisione legittima — a patto che sia una decisione dichiarata e non un abbandono silenzioso. La differenza fra le due, a distanza di due anni, è enorme.
Branch by Abstraction: il pattern gemello
Vale la pena conoscere la tecnica complementare, perché spesso si usa la parola “strangler” per indicare cose diverse. Lo Strangler Fig opera dall’esterno, intercettando le richieste in ingresso: è la scelta giusta quando si sostituiscono funzionalità visibili all’utente, con URL propri.
Branch by Abstraction opera dall’interno: si introduce un’astrazione fra chi chiama e l’implementazione esistente, si scrive una seconda implementazione dietro la stessa astrazione, si commuta e si elimina la vecchia. È la scelta giusta per i componenti interni che non hanno un URL — il sistema di invio email, il livello di accesso ai dati, il motore di regole, il generatore di PDF.
In pratica i due si usano insieme, e in Laravel il secondo è quasi gratuito grazie al service container: l’astrazione è un’interfaccia, la commutazione è un binding condizionale, esattamente come nel repository del passo 4. Il modo più semplice di distinguerli: se ciò che si sostituisce ha un indirizzo web, è Strangler Fig; se ha solo un nome di classe, è Branch by Abstraction.
Sei errori che fanno fallire una migrazione incrementale
- Migrare per strati anziché per capacità. “Prima rifacciamo tutto il frontend, poi il backend” non è Strangler Fig: è un big bang diviso in due, con gli stessi rischi e in più il costo dell’integrazione. Ogni fetta deve attraversare tutti gli strati e funzionare da sola.
- Portarsi dietro il modello dati legacy. Senza anti-corruption layer il codice nuovo eredita le convenzioni vecchie e, dopo diciotto mesi, si scopre di aver ricostruito lo stesso sistema con un framework diverso.
- Non rimuovere mai il vecchio codice. È l’errore più comune in assoluto. Trasforma la migrazione in un accumulo, e raddoppia stabilmente il costo di manutenzione anziché ridurlo.
- Lasciare il proxy senza osservabilità. Con due sistemi in produzione, un identificativo di correlazione propagato in entrambi i log non è un lusso: senza, il primo bug che attraversa il confine costa una giornata di indagine invece di dieci minuti.
- Migliorare mentre si migra. Cambiare comportamento e implementazione nello stesso rilascio rende impossibile capire se una differenza è un bug o la nuova funzionalità richiesta. Prima si sostituisce a parità di comportamento, poi si migliora. Sono due rilasci, e devono restare due.
- Non dichiarare quando finisce. Senza un indicatore di avanzamento condiviso — percentuale di rotte migrate, righe legacy rimosse — il progetto diventa uno stato permanente delle cose, e la prima revisione di budget lo interromperà a metà, nella configurazione peggiore possibile: due sistemi, entrambi incompleti.
Checklist prima di iniziare la prossima fetta
- La fetta corrisponde a una capacità di business, non a uno strato tecnico.
- Sono note le rotte coinvolte e le tabelle lette e scritte.
- È stato verificato che la funzionalità sia ancora usata davvero.
- La strategia sui dati è dichiarata: condivisione, sincronizzazione o proprietà.
- Esiste un anti-corruption layer fra il modello legacy e il dominio nuovo.
- Sessione e autenticazione funzionano attraversando i due sistemi in entrambe le direzioni.
- La nuova implementazione sta dietro un feature flag con una data di scadenza.
- L’assegnazione al percorso nuovo è stabile per utente.
- Se la fetta produce numeri, è previsto un periodo di esecuzione in parallelo con registrazione delle divergenze.
- Un identificativo di correlazione è propagato nei log di entrambi i sistemi.
- Il rollback è un’operazione da trenta secondi e qualcuno l’ha provata.
- La rimozione del codice legacy è pianificata, con una data e un responsabile.
Conclusioni
Lo Strangler Fig Pattern non rende la modernizzazione più rapida. Nella somma delle ore è quasi sempre più lento di una riscrittura completa che vada tutto bene — solo che le riscritture complete che vanno tutto bene sono rare, e quando falliscono falliscono per intero.
Quello che il pattern cambia è la distribuzione del rischio. Invece di un unico momento in cui tutto può andare storto, si hanno cinquanta momenti in cui può andare storto poco, ognuno annullabile con un interruttore. Invece di un valore che arriva tutto alla fine, si ha valore rilasciato ogni due settimane. E invece di ricostruire a memoria la conoscenza sedimentata nel codice vecchio, la si estrae un pezzo per volta, con l’originale ancora lì a fare da riferimento.
Il consiglio operativo per partire è di restringere al minimo il primo ciclo: mettere il proxy davanti al legacy senza migrare nulla, scegliere una funzionalità di sola lettura e a basso traffico, portarla in produzione dietro un flag, aprirla al 5% degli utenti e — questo è il punto che separa i progetti che finiscono da quelli che si arenano — cancellare il codice vecchio che serviva quella funzionalità. Quel primo file cancellato è il vero inizio del progetto: da lì in avanti è ripetizione, e la ripetizione si può pianificare.

