Architettura esagonale (Ports & Adapters) in Laravel: separare dominio da infrastruttura

Architettura esagonale (Ports & Adapters) in Laravel: separare dominio da infrastruttura

A ottobre 2025 ho iniziato un refactoring progressivo su un'applicazione Laravel 9 di un'azienda del settore servizi di gestione risorse umane - fatturato annuo circa 7 milioni di euro, circa 45 dipendenti interni, e una base di clienti di 130 PMI italiane che usano il SaaS per elaborare buste paga, gestire presenze, e automatizzare processi di onboarding/offboarding. L'applicazione era stata sviluppata in cinque anni da un team piccolo, con un pattern MVC Laravel standard dove i controller contenevano centinaia di righe di logica di business (calcolo trattenute IRPEF, elaborazione cedolino, gestione straordinari e festività), i model Eloquent erano utilizzati come "oggetti dominio" con tutti i loro side-effect database, e i test erano praticamente inesistenti (2% di coverage, quasi tutti su funzioni helper banali). Quando il CTO mi ha contattato la richiesta era chiara: "abbiamo bisogno di aggiungere il supporto per contratti di apprendistato con regole fiscali differenti, ma ogni volta che tocchiamo la logica di calcolo del cedolino rompiamo qualcosa in produzione. I test non esistono, e scriverli è impossibile perché ogni metodo richiede il database popolato con condizioni specifiche". Era la diagnosi classica di un'applicazione dove dominio e infrastruttura sono fusi insieme.

Il refactoring verso l'architettura esagonale ha richiesto cinque mesi di lavoro distribuito parallelamente allo sviluppo delle feature pianificate, con effort complessivo di circa 35 giornate-uomo mie più 20 giornate dei tre developer interni. Il risultato al sesto mese: il 78% della logica di business è stata estratta in moduli di dominio puri, testabili senza database né framework, con una coverage di test unitari oltre il 90%. Il tempo di esecuzione della suite di test è passato da 18 minuti (tutti test di integrazione lenti che richiedevano il DB) a 2 minuti e 40 secondi (in maggioranza test unitari veloci sul dominio puro). L'introduzione del supporto per contratti di apprendistato - il motivo originale del refactoring - ha richiesto due giorni di sviluppo invece dei 10-15 preventivati con l'architettura vecchia, e zero regressioni in produzione perché ogni regola fiscale era coperta da test dedicati scritti prima dell'implementazione. Questo articolo descrive il pattern operativo che ho applicato, con le decisioni concrete di modellazione e gli errori di cui il team avrebbe dovuto essere consapevole prima di iniziare.

Il pattern Ports & Adapters spiegato con le mani, non con gli UML

La letteratura sull'architettura esagonale (anche chiamata Ports & Adapters, formalizzata da Alistair Cockburn nel 2005 con la sua guida originale "Hexagonal Architecture" che rimane la referenza canonica del pattern) tende a essere astratta e gli esempi dei libri usano diagrammi UML che scoraggiano i developer. La versione pratica è semplice: il pattern separa una applicazione in tre strati concentrici, con regole rigorose su chi può parlare con chi.

Lo strato centrale è il dominio: contiene le entità di business (Contratto, Dipendente, Cedolino, Turno), i value object (Euro, Ore, Data, CodiceFiscale), i servizi di dominio che implementano regole di business pure (CalcolatoreIrpef, ApplicatoreStraordinario, GeneratoreCedolino). Questo strato non conosce Laravel, non conosce MySQL, non conosce HTTP. Le sue classi sono PHP puro - un Contratto è un oggetto con un costruttore, metodi che applicano regole di business, e nessuna dipendenza verso servizi esterni. Si può eseguire una suite di test unitari sul dominio in isolamento assoluto, senza nemmeno aver installato Laravel sulla macchina.

Lo strato intermedio contiene i port: interfacce che il dominio dichiara per accedere a servizi esterni. Se il dominio ha bisogno di salvare un cedolino, dichiara un'interfaccia CedolinoRepository con metodi save(Cedolino $cedolino): void e findById(CedolinoId $id): ?Cedolino. Non dice niente su come questi metodi debbano essere implementati - solo cosa fanno. Se il dominio ha bisogno di inviare un'email al dipendente quando il cedolino è pronto, dichiara NotificatorePagamento con metodo notifica(Dipendente $dipendente, Cedolino $cedolino): void. Sono i "port" attraverso cui il dominio comunica con l'esterno.

Lo strato esterno contiene gli adapter: implementazioni concrete dei port, che usano infrastruttura reale. EloquentCedolinoRepository è un adapter che implementa CedolinoRepository usando Eloquent per salvare nel database MySQL. SendGridNotificatorePagamento è un adapter che implementa NotificatorePagamento usando le API di SendGrid. Potresti avere altri adapter: InMemoryCedolinoRepository per i test (che salva in un array PHP, nessun database), TelegramNotificatorePagamento che invia via Telegram invece che email. Il dominio non cambia - cambiano gli adapter usati.

La regola di direzione delle dipendenze è assoluta: il dominio non dipende da nessuno, i port sono dichiarati dal dominio e quindi sono parte di esso, gli adapter dipendono dai port (li implementano). Le dipendenze puntano verso l'interno: l'esterno dipende dall'interno, mai il contrario. Questo è il motivo per cui si chiama "esagonale" - il dominio è il centro, le porte sono i lati dell'esagono attraverso cui comunica, gli adapter sono collegati ai lati esternamente.

La struttura delle directory: Laravel non scompare, ma si riorganizza

L'implementazione pratica in un progetto Laravel richiede una struttura di directory che distingua chiaramente i tre strati. Il pattern che ho applicato sul cliente HR è questo:

app/
├── Domain/                      <- STRATO DOMINIO (PHP puro)
│   ├── Cedolino/
│   │   ├── Cedolino.php         <- entita
│   │   ├── CedolinoId.php       <- value object
│   │   ├── VoceCedolino.php     <- value object
│   │   ├── CedolinoRepository.php  <- PORT (interface)
│   │   └── GeneratoreCedolino.php  <- servizio di dominio
│   ├── Calcolo/
│   │   ├── CalcolatoreIrpef.php    <- servizio puro
│   │   ├── AliquotaIrpef.php       <- value object
│   │   └── Euro.php                <- value object monetario
│   └── Notifica/
│       └── NotificatorePagamento.php  <- PORT
│
├── Application/                 <- USE CASE (orchestrazione)
│   ├── GeneraCedolinoMensile/
│   │   ├── GeneraCedolinoMensileCommand.php
│   │   ├── GeneraCedolinoMensileHandler.php
│   │   └── GeneraCedolinoMensileResult.php
│   └── ... altri use case
│
├── Infrastructure/              <- ADAPTER (implementazioni)
│   ├── Persistence/
│   │   └── Eloquent/
│   │       ├── EloquentCedolinoRepository.php
│   │       └── CedolinoEloquent.php  <- model Eloquent
│   ├── Notifica/
│   │   └── SendGridNotificatorePagamento.php
│   └── ServiceProvider/
│       └── DomainBindingServiceProvider.php
│
└── Http/                        <- STRATO HTTP Laravel
    └── Controllers/
        └── CedolinoController.php  <- chiama use case

La chiave di questa struttura è che le classi in Domain/ non importano nulla da Illuminate\ o da Eloquent o da qualunque altra parte di Laravel. Un PHPStan configurato correttamente verifica questa regola tramite il plugin deptrac che analizza le dipendenze architetturali e impone regole di layer, documentato su qossmic/deptrac. Il file deptrac.yaml dichiara i layer e le regole di dipendenza consentite:

# deptrac.yaml
parameters:
  paths:
    - ./app
  layers:
    - name: Domain
      collectors:
        - { type: directory, value: app/Domain/.* }
    - name: Application
      collectors:
        - { type: directory, value: app/Application/.* }
    - name: Infrastructure
      collectors:
        - { type: directory, value: app/Infrastructure/.* }
    - name: Http
      collectors:
        - { type: directory, value: app/Http/.* }
    - name: Laravel
      collectors:
        - { type: className, value: ^Illuminate\\.* }
        - { type: className, value: ^Eloquent.* }
  ruleset:
    Domain: []
    Application: [Domain]
    Infrastructure: [Domain, Application, Laravel]
    Http: [Domain, Application, Infrastructure, Laravel]

La regola dice: il Domain non può dipendere da nessuno. L'Application può dipendere solo dal Domain. L'Infrastructure può dipendere da Domain, Application e Laravel (usa Eloquent per implementare i port). L'Http può dipendere da tutto (è il layer più esterno). Un developer che accidentalmente importa Illuminate\Support\Facades\DB in un file di Domain/ viene fermato in CI prima del merge, perché deptrac segnala la violazione del layer.

Stai cercando un Consulente Informatico esperto per refactorizzare una codebase Laravel verso un'architettura esagonale senza interrompere lo sviluppo delle feature, introducendo test di dominio e separazione strutturata fra business logic e infrastruttura? Nel mio profilo professionale trovi l'esperienza concreta su refactoring strategico, pattern architetturali enterprise e supporto a team di sviluppo Laravel italiani che gestiscono SaaS B2B complessi.

Un esempio concreto: il calcolatore di IRPEF come servizio di dominio puro

Per rendere il pattern tangibile, guardiamo al componente di calcolo IRPEF che era il cuore del problema del cliente HR. Nell'architettura vecchia, il codice era questo (semplificato):

<?php
// app/Http/Controllers/CedolinoController.php - VERSIONE VECCHIA
class CedolinoController extends Controller
{
    public function genera(Request $request)
    {
        $dipendente = Dipendente::find($request->dipendente_id);
        $oreMese = TurniMensile::where('dipendente_id', $dipendente->id)
            ->where('mese', $request->mese)
            ->sum('ore');

        $tariffaOraria = $dipendente->contratto->tariffa_oraria;
        $lordoMese = $oreMese * $tariffaOraria;

        // 150 righe di calcolo IRPEF, contributi INPS, gestione scaglioni,
        // deduzioni fiscali, carichi familiari, tutto mescolato con query
        // Eloquent per leggere tabelle di aliquote, anagrafica comune di nascita
        // per addizionale comunale, etc.

        // ... e infine:
        $cedolino = Cedolino::create([
            'dipendente_id' => $dipendente->id,
            'mese' => $request->mese,
            'lordo' => $lordoMese,
            'netto' => $nettoCalcolato,
            // ... 40 altri campi
        ]);

        Mail::to($dipendente->email)->send(new CedolinoPronto($cedolino));
        return response()->json($cedolino);
    }
}

Questo codice era impossibile da testare in isolamento: richiedeva il database popolato con aliquote, scaglioni, il comune di nascita del dipendente, i suoi carichi familiari, tutte le ore turno del mese. Ogni test aveva 50-80 righe di setup prima di poter verificare una sola regola di calcolo. Nessun developer trovava il tempo di scrivere quei test.

Il refactoring in architettura esagonale estrae il servizio di calcolo in una classe di dominio pura:

<?php
// app/Domain/Calcolo/CalcolatoreIrpef.php
namespace App\Domain\Calcolo;

use App\Domain\Cedolino\DipendentePensionabile;
use App\Domain\Calcolo\Euro;
use App\Domain\Calcolo\AliquotaIrpef;

class CalcolatoreIrpef
{
    /**
     * @param AliquotaIrpef[] $scaglioniOrdinari scaglioni vigenti nell'anno
     * @param DeduzioneFiscale[] $deduzioni deduzioni applicabili al dipendente
     */
    public function calcola(
        Euro $imponibileLordo,
        array $scaglioniOrdinari,
        array $deduzioni,
        int $giornateLavorate,
    ): Euro {
        $imponibileNetto = $this->applicaDeduzioni($imponibileLordo, $deduzioni);
        $irpefLorda = $this->applicaScaglioni($imponibileNetto, $scaglioniOrdinari);
        $irpefNetta = $this->applicaDetrazioniLavoroDipendente(
            $irpefLorda,
            $imponibileNetto,
            $giornateLavorate,
        );
        return $irpefNetta;
    }

    private function applicaScaglioni(Euro $imponibile, array $scaglioni): Euro
    {
        $irpef = Euro::zero();
        foreach ($scaglioni as $scaglione) {
            $importoTassabile = $imponibile->trattenuta(
                $scaglione->limiteInferiore(),
                $scaglione->limiteSuperiore(),
            );
            if ($importoTassabile->isZero()) continue;
            $irpef = $irpef->piu(
                $importoTassabile->percentuale($scaglione->aliquota())
            );
        }
        return $irpef;
    }

    // ... altri metodi privati puri
}

Questa classe non ha Eloquent, non ha DB, non ha Mail. È PHP puro. Si può testare così:

<?php
// tests/Unit/Domain/Calcolo/CalcolatoreIrpefTest.php
it('calcola IRPEF 2026 su scaglione base', function () {
    $scaglioni = [
        new AliquotaIrpef(Euro::zero(), Euro::from(28000), 0.23),
        new AliquotaIrpef(Euro::from(28001), Euro::from(50000), 0.35),
        new AliquotaIrpef(Euro::from(50001), null, 0.43),
    ];
    $imponibile = Euro::from(30000);

    $calcolatore = new CalcolatoreIrpef();
    $irpef = $calcolatore->calcola($imponibile, $scaglioni, [], 220);

    expect($irpef->valore())->toBe(7140.0);
});

Il test non ha bisogno di database, è istantaneo da eseguire, è facile da leggere, copre un comportamento specifico. Sul cliente HR abbiamo scritto 180 test di questo tipo in due settimane di lavoro dedicato, coprendo praticamente tutte le regole di calcolo fiscale del sistema. Quando è arrivato il requisito di aggiungere il supporto per contratti di apprendistato, il pattern è stato: scrivi prima i test che descrivono il comportamento desiderato (regole fiscali specifiche dell'apprendistato), poi modifica/estendi il CalcolatoreIrpef per passarli. Due giorni di lavoro, zero regressioni.

Il repository pattern: come sostituire Eloquent come "dominio"

Il passaggio concettualmente più difficile per developer Laravel è abbandonare l'idea che i model Eloquent siano "le entità di dominio". In Laravel standard, Dipendente::find($id)->stipendioBase è un pattern idiomatico: accedi all'entità Dipendente e leggi il suo stipendioBase. Nell'architettura esagonale, Dipendente è un'entità di dominio PHP pura, e il caricamento dal database avviene via Repository:

<?php
// app/Domain/Dipendente/DipendenteRepository.php (INTERFACCIA - PORT)
namespace App\Domain\Dipendente;

interface DipendenteRepository
{
    public function findById(DipendenteId $id): ?Dipendente;
    public function save(Dipendente $dipendente): void;
    public function elencoAttiviAllaData(\DateTimeImmutable $data): array;
}
<?php
// app/Infrastructure/Persistence/Eloquent/EloquentDipendenteRepository.php (ADAPTER)
namespace App\Infrastructure\Persistence\Eloquent;

use App\Domain\Dipendente\Dipendente;
use App\Domain\Dipendente\DipendenteId;
use App\Domain\Dipendente\DipendenteRepository;

class EloquentDipendenteRepository implements DipendenteRepository
{
    public function findById(DipendenteId $id): ?Dipendente
    {
        $model = DipendenteEloquent::find($id->value());
        if (!$model) return null;
        return $this->toDomain($model);
    }

    public function save(Dipendente $dipendente): void
    {
        $model = DipendenteEloquent::find($dipendente->id()->value())
            ?? new DipendenteEloquent();
        $model->id = $dipendente->id()->value();
        $model->codice_fiscale = $dipendente->codiceFiscale()->value();
        $model->cognome = $dipendente->cognome();
        $model->nome = $dipendente->nome();
        // ... altri campi
        $model->save();
    }

    private function toDomain(DipendenteEloquent $model): Dipendente
    {
        return new Dipendente(
            id: new DipendenteId($model->id),
            codiceFiscale: new CodiceFiscale($model->codice_fiscale),
            cognome: $model->cognome,
            nome: $model->nome,
            // ... altri campi
        );
    }
}

Due osservazioni. Prima: il modello Eloquent DipendenteEloquent esiste ancora, ma vive in Infrastructure/ e non viene mai esposto fuori da lì. Il dominio non sa che esista. Seconda: c'è un mapping esplicito fra oggetto di dominio e model Eloquent. Questo è più codice rispetto a "lavora direttamente col model", ma è anche dove si verifica il vero isolamento. Il mapping può applicare conversioni di tipo (stringvalue object), validazioni, trasformazioni - cose che nel model Eloquent sono impossibili da centralizzare. Il mio articolo sul refactoring della Clean Architecture in Laravel con controller, service e repository su Laravel 12 descrive un approccio più leggero, intermedio fra il pattern Laravel puro e l'esagonale completo - scelgo uno dei due in base alla dimensione del progetto e al livello di testabilità richiesto.

Gli use case come orchestratori: il pattern Command Handler

Lo strato Application/ contiene gli use case: orchestratori che eseguono un singolo scenario di business coordinando servizi di dominio e repository. Il pattern che uso è Command/Handler: un use case è identificato da un Command (data class con input) e un Handler (class con metodo handle(Command): Result).

<?php
// app/Application/GeneraCedolinoMensile/GeneraCedolinoMensileCommand.php
namespace App\Application\GeneraCedolinoMensile;

readonly class GeneraCedolinoMensileCommand
{
    public function __construct(
        public int $dipendenteId,
        public int $anno,
        public int $mese,
    ) {}
}

// app/Application/GeneraCedolinoMensile/GeneraCedolinoMensileHandler.php
namespace App\Application\GeneraCedolinoMensile;

use App\Domain\Dipendente\DipendenteRepository;
use App\Domain\Cedolino\GeneratoreCedolino;
use App\Domain\Cedolino\CedolinoRepository;
use App\Domain\Notifica\NotificatorePagamento;

class GeneraCedolinoMensileHandler
{
    public function __construct(
        private DipendenteRepository $dipendenti,
        private GeneratoreCedolino $generatore,
        private CedolinoRepository $cedolini,
        private NotificatorePagamento $notificatore,
    ) {}

    public function handle(GeneraCedolinoMensileCommand $command): GeneraCedolinoMensileResult
    {
        $dipendente = $this->dipendenti->findById(new DipendenteId($command->dipendenteId));
        if (!$dipendente) {
            throw new DipendenteNonTrovato($command->dipendenteId);
        }
        $cedolino = $this->generatore->generaPerMese(
            $dipendente,
            new PeriodoMensile($command->anno, $command->mese),
        );
        $this->cedolini->save($cedolino);
        $this->notificatore->notifica($dipendente, $cedolino);
        return GeneraCedolinoMensileResult::successo($cedolino->id());
    }
}

L'handler è testabile con mock dei repository e del notificatore, senza database. Il controller Laravel diventa sottile: riceve la request, costruisce il Command, invoca l'handler, trasforma il Result in JSON response.

Migrazione incrementale: il Ship of Theseus applicato al codice

Il refactoring verso l'esagonale non si fa "tutto in una volta". Il pattern che applico è Strangler Fig: in ogni sprint si estrae in Domain un pezzo di business logic, si scrive lo use case corrispondente, si cambia il controller per usare l'use case al posto del codice monolitico precedente, e si scrivono i test unitari sul dominio estratto. Nel corso di 4-6 mesi, la maggioranza della logica si sposta dal monolite al dominio puro, senza mai fermare lo sviluppo delle feature.

Sul cliente HR abbiamo seguito questo ritmo: primi 2 mesi sul modulo cedolino (il più complesso e critico), mese 3-4 sul modulo turni e presenze, mese 5 sul modulo gestione contratti. A fine mese 6, circa il 78% della business logic era in Domain/, il restante 22% era codice leggero nei controller e helper di presentazione. Questa percentuale mi ha soddisfatto - il resto del 22% era effettivamente presentation logic, non logica di business travestita da controller, e non meritava di essere estratta nel dominio. Il pattern di introdurre test minimi PHP legacy con smoke test, harness e snapshot testing che ho descritto in dettaglio in un altro articolo è stato la base di partenza per avere una safety net durante il refactoring - senza test di alto livello che coprissero i flussi critici, la migrazione avrebbe introdotto regressioni incontrollate.

I benefici secondari: test velocità, test flakyness, morale del team

Il beneficio primario dichiarato ("il dominio è testabile") porta con sé una cascata di benefici secondari che scoprirai solo dopo alcuni mesi. La suite di test su 180 test di dominio puro gira in 2 minuti e 40 secondi, contro i 18 minuti pre-refactoring - un fattore 7x. Questa velocità cambia la dinamica di sviluppo: i developer eseguono i test molte più volte nella giornata (15-20 volte al giorno invece di 2-3), catturando bug molto prima, riducendo il tempo di correzione medio. I test unitari sono anche notevolmente meno flakey: non ci sono race condition con il database di test, non ci sono dati sporchi da sprint precedenti, non ci sono deadlock MySQL. La flakyness è scesa praticamente a zero - un test che passa oggi passa domani e il mese prossimo, a meno che qualcuno non abbia cambiato il codice.

Il beneficio meno tecnico ma forse più importante è il morale del team. I tre developer interni del cliente, dopo cinque mesi di refactoring guidato, hanno una relazione completamente diversa con la codebase. Quando ho chiesto loro cosa fosse cambiato, uno mi ha risposto "prima avevo paura di toccare il calcolatore IRPEF. Ora lo modifico senza esitazione, vedo i test che passano, mergeo. È diverso". Questo non è un effetto piccolo - è la differenza fra un team che accumula debito tecnico per paura e un team che rimodella continuamente il codice per mantenerlo sano.

Se gestisci un'applicazione Laravel con molta business logic mescolata in controller e Eloquent model, e ti trovi bloccato ogni volta che devi modificare le regole di dominio perché nessuno riesce a testare le modifiche in isolamento, oppure stai pianificando una nuova applicazione con dominio complesso e vuoi partire con le fondamenta architettura giuste, contattami per una valutazione: in due giornate di lavoro analizzo la struttura attuale della tua codebase, identifico i tre moduli più critici dove l'estrazione del dominio darebbe il massimo beneficio, e ti consegno un piano di refactoring incrementale con milestone verificabili e effort stimato per ognuno - senza l'illusione che l'architettura esagonale sia sempre la scelta giusta, ma con l'onestà di dirti dove è la leva adeguata per il tuo contesto specifico e dove invece un Laravel più standard è perfettamente adeguato.

Ultima modifica: