Domain-Driven Design con Laravel: implementare bounded contexts in un progetto reale
Perché Domain-Driven Design è la decisione architetturale più fraintesa nelle PMI tecnologiche italiane
Il 19 aprile 2024 sono stato ingaggiato come consulente senior da una piattaforma fintech milanese attiva nel settore della distribuzione di prodotti assicurativi B2B - ramo danni commerciale e polizze professionali - con un fatturato annuo intorno agli 11 milioni di euro e un team tecnico composto da sei sviluppatori più due architetti. L'azienda aveva avviato nove mesi prima un progetto di riscrittura completa del gestionale core (quello che gestisce il ciclo di vita di polizza, preventivazione, emissione, incasso premi, gestione sinistri e rinnovi) in Laravel 10 applicando Domain-Driven Design come metodologia architetturale principale. Quando mi hanno chiamato, il progetto era in ritardo di sette mesi sulla roadmap originale, aveva consumato circa 480.000 euro di budget interno più 120.000 euro di consulenza esterna, e la milestone MVP prevista per marzo 2024 era stata spostata a fine anno senza una data fissa. Il CTO mi ha convocato per un second opinion indipendente: la causa vera del ritardo era la complessità intrinseca del dominio assicurativo, come stavano raccontando gli architetti interni, o c'era un problema architetturale concreto che era sfuggito alla governance tecnica?
L'audit tecnico che ho fatto nelle prime due settimane - lettura integrale del codebase, interviste individuali con ciascuno sviluppatore, analisi delle pull request degli ultimi sei mesi, confronto con il bounded context map disegnato dagli architetti a inizio progetto - ha prodotto una diagnosi molto scomoda per il team interno ma necessaria per il cliente. Il progetto era stato over-engineered fin dal giorno zero: tutti gli aggregate root avevano ricevuto un repository interface con implementazione Eloquent separata nell'infrastruttura, tutte le operazioni di scrittura erano state modellate come command handler con domain event dispatchati su bus Rabbit MQ, tutte le primitive di business erano state promosse a value object immutabili anche quando erano semplici codici identificativi alfanumerici, il dominio era stato frammentato in undici bounded context separati quando a posteriori ne servivano realisticamente quattro. Il risultato era che aggiungere una nuova tipologia di polizza al sistema richiedeva mediamente 14 giornate di sviluppo invece delle 4 giornate stimate a inizio progetto, perché ogni nuova aggiunta doveva attraversare cinque strati architetturali distinti e toccare sette-otto file separati per funzionalità banali come "aggiungi un campo al form di preventivazione".
In cinque mesi di intervento affiancato al team interno - riducendo sette dei layer architetturali più problematici, ricompattando quattro bounded context in due, eliminando 320 classi di value object ridondanti che esistevano solo per mirror di primitive senza logica aggiunta, e ripristinando l'uso diretto di Eloquent per gli aggregate che non ne beneficiavano - abbiamo raggiunto un MVP funzionante entro fine dicembre 2024. La velocità di sviluppo misurata in story point per sprint del team è aumentata del 240% nel trimestre successivo alla ristrutturazione. L'azienda oggi è in produzione su tre delle quattro linee di business previste dal piano originale, con debito tecnico reale molto più basso rispetto al picco del 2024, e il team senior ha riacquistato fiducia nelle proprie decisioni architetturali invece di temerle.
Questo articolo è il distillato onesto di cosa ho imparato di DDD applicato a progetti Laravel in contesti PMI italiane, dopo quattordici progetti simili negli ultimi sette anni. Il messaggio centrale non è che DDD sia "sbagliato" o "da evitare" - è un framework concettuale potentissimo nei contesti giusti. Il messaggio è che DDD applicato con ortodossia libresca fuori dal suo vero contesto di applicabilità è la fonte di over-engineering più devastante che abbia incontrato in dieci anni di consulenza architetturale, e in contesti PMI italiani è quasi sempre applicato fuori contesto.
Quando Domain-Driven Design aiuta davvero un progetto Laravel e quando è solo teatro architetturale?
Il libro di Eric Evans del 2003 che ha fondato DDD - "Domain-Driven Design: Tackling Complexity in the Heart of Software" - è documentato nella sua pagina di riferimento ufficiale sul sito Domain Language ed è stato scritto pensando a un contesto molto specifico: grandi progetti software con domini di business intrinsecamente complessi - piattaforme di trading finanziario, sistemi medici ospedalieri con regolatorio complesso, gestionali logistici multimodali, software assicurativi attuariali - dove la complessità del dominio supera significativamente la complessità tecnica dell'implementazione. In quei contesti, il problema dominante del progetto non è "come faccio a farlo in PHP", è "riesco davvero a capire e modellare le regole di business del mio dominio". DDD è una metodologia per aggredire esattamente quel problema, e nei contesti giusti è straordinariamente efficace.
Il problema è che DDD è stato adottato negli ultimi dieci anni come default architetturale anche per progetti in cui il dominio non è quel livello di complessità. Nelle PMI italiane che ho auditato, il 70% dei progetti che si dichiarano "basati su DDD" ha in realtà una complessità di dominio modesta - gestionali verticali, marketplace B2B, piattaforme di lead generation, CRM custom - perfettamente gestibile con un'architettura Laravel idiomatica senza nessuno dei pattern DDD avanzati. Applicare DDD a questi progetti aggiunge strati di astrazione senza aggiungere valore funzionale, e il costo di questi strati cresce nel tempo perché ogni nuovo sviluppatore assunto deve attraversarli per ogni funzionalità.
La regola operativa che applico in fase di scelta architetturale è molto concreta. Valuto tre dimensioni del progetto indipendentemente: complessità del dominio (quanto regole di business specifiche, eccezioni, casi edge, regolatorio specializzato), complessità tecnica (quanto performance-critical, quanto scalabilità richiesta, quanto integrazione con sistemi esterni), e complessità organizzativa (quanti team che lavorano in parallelo, quanto turnover, quanto modularità richiesta per evoluzione indipendente). DDD con tutti i pattern avanzati (aggregate, value object sistematici, repository per ogni root, domain event bus, CQRS obbligatorio) si giustifica solo quando almeno due delle tre dimensioni sono ad alta complessità. Se solo una dimensione è complessa - tipicamente il dominio, come nel caso assicurativo del cliente milanese - si può applicare DDD selettivo, usando solo i pattern che servono al dominio specifico senza adottare l'intero arsenal metodologico. Se nessuna delle tre dimensioni è davvero complessa, DDD è sovraingegneria pura e Laravel idiomatico è la scelta più pragmatica.
Sul cliente assicurativo milanese, la valutazione onesta a posteriori era questa. Complessità di dominio: alta (assicurazioni B2B con tariffazione attuariale, regolatorio IVASS, gestione sinistri con logica di franchigia variabile). Complessità tecnica: media-bassa (traffico di qualche centinaia di utenti concorrenti, nessun requisito di scalabilità estrema, integrazioni limitate con compagnie assicuratrici esterne). Complessità organizzativa: bassa (team di sei sviluppatori, zero parallelismo fra team diversi, bassa rotazione). La giusta applicazione di DDD per questo profilo era solo sul nucleo del dominio tariffario e sinistri, con il resto del sistema costruito con Laravel idiomatico. L'applicazione uniforme di DDD anche sulle parti a bassa complessità (gestione utenti, anagrafica clienti, dashboard amministrativi, reporting) aveva triplicato il costo senza alcun beneficio funzionale. L'articolo che ho dedicato ai principi di architettura esagonale con ports and adapters applicata a Laravel per separare dominio e infrastruttura descrive il framework di decisioni che uso per applicare i pattern DDD solo dove davvero generano valore, e rappresenta la base concettuale dell'approccio DDD selettivo che consiglio alle PMI.
Se stai pianificando un progetto Laravel significativo o stai ereditando un progetto DDD che ti sembra over-engineered rispetto al tuo contesto, nel mio profilo professionale trovi il dettaglio degli interventi di semplificazione architetturale che ho condotto su progetti PHP PMI italiane, sempre con approccio di valutazione onesta della complessità reale del dominio prima di applicare metodologie specifiche.
Identificare bounded contexts reali (e non inventati) in un progetto di dominio complesso
Il concetto di bounded context è probabilmente l'idea più potente e meno compresa di tutto DDD. Un bounded context è una porzione di sistema in cui un termine del dominio ha un solo significato non ambiguo, governato da un linguaggio comune (l'ubiquitous language) fra sviluppatori e domain expert. Il concetto è descritto in modo limpido nella pagina di riferimento di Martin Fowler dedicata al Bounded Context nella sua collezione pubblica di pattern architetturali. Il caso canonico di esempio: nel bounded context "vendite", un cliente è un soggetto che ha firmato un contratto e paga una fattura; nel bounded context "marketing", un cliente è un contatto anagrafico che potrebbe diventare un lead; nel bounded context "customer care", un cliente è un utente autenticato che apre ticket di assistenza. Tre concetti con lo stesso nome, significati sostanzialmente diversi, ognuno con il suo modello di dati e le sue regole di business appropriate al contesto. Fondere i tre in un'unica entità Cliente globale del progetto è un errore architetturale che porta nel tempo a regole di business contraddittorie e a modelli di dati obesi che cercano di accomodare tutti i casi d'uso contemporaneamente.
Il problema che vedo nella maggioranza delle implementazioni DDD nelle PMI è l'invenzione di bounded context che non esistono nel dominio reale, guidata dalla lettura libresca dell'approccio senza confronto con i domain expert del cliente. Sul progetto assicurativo milanese, gli architetti iniziali avevano disegnato undici bounded context: Policy Issuance, Policy Renewal, Claims Management, Pricing, Underwriting, Billing, Commission Management, Document Generation, Customer Portal, Reporting, Audit Trail. Ognuno con un suo repository layer, i suoi aggregate root, le sue anti-corruption layer per comunicare con gli altri context, i suoi event handler. Sulla carta sembra pulito. Nella pratica, la separazione fra Policy Issuance e Policy Renewal era completamente artificiale: il 95% delle regole di business erano identiche fra i due, le differenze erano solo in alcuni percorsi utente della UI, e forzare due modelli di dominio separati creava duplicazione continua di codice e logica di sincronizzazione fra i due context che generava bug quasi ogni sprint.
Il metodo operativo corretto per identificare bounded context reali passa attraverso un'intervista strutturata con i domain expert del cliente - nel caso assicurativo, gli specialisti attuariali e i gestori sinistri dell'azienda. La tecnica specifica che uso si chiama event storming ed è stata formalizzata dal consulente italiano Alberto Brandolini: in una sessione di 4-6 ore con domain expert e sviluppatori in una stessa stanza con molti post-it, si mappano tutti gli eventi rilevanti del dominio su una timeline, si raggruppano gli eventi per affinità semantica e per unicità del vocabolario, e si disegnano i bounded context emergenti osservando dove il linguaggio dei domain expert cambia naturalmente. Nel caso milanese, ripetendo questo esercizio a sette mesi dall'inizio del progetto con il team al completo, abbiamo scoperto che il dominio reale aveva quattro bounded context distintivi: Policy Lifecycle (che ingloba issuance, renewal, modification - vocabolario identico, attori identici), Claims (sinistri - vocabolario profondamente diverso, attori diversi, regole attuariali specifiche), Billing & Commissions (incassi e commissioni intermediari - linguaggio commerciale-amministrativo), Customer Interaction (portale cliente e notifiche - linguaggio esperienziale, attori cliente finale).
La ricompattazione da undici a quattro bounded context ha eliminato in una sola decisione architetturale circa il 40% del codice di boilerplate inter-context (anti-corruption layer, event translator, state synchronization) e ha ridotto la superficie di complessità che ogni sviluppatore deve tenere in mente. Lezione operativa: i bounded context vengono scoperti dal dominio, non inventati dagli architetti. Se hai bisogno di un'anti-corruption layer fra due bounded context del tuo sistema, interroga onestamente se quel confine esiste davvero nel dominio o se stai creando separazioni artificiali.
Value object: quando l'astrazione si ripaga e quando è pura cerimonia
Il concetto di value object in DDD è uno dei pattern più abusati nelle implementazioni PMI. La teoria dice: promuovi a value object ogni primitive che rappresenta un concetto di business con proprie regole di validità, così incapsuli le regole con il valore, elimini il primitive obsession e rendi il codice più espressivo. Nella pratica delle PMI italiane, questo principio viene applicato in modo meccanico: ogni codice fiscale, ogni partita IVA, ogni email, ogni codice univoco aziendale diventa una classe dedicata di value object con costruttore validante, metodi equals(), metodo value(), serializzazione custom. Il risultato, su un progetto di medie dimensioni, sono 200-400 classi di value object, ciascuna con il suo costo cognitivo e di manutenzione.
La regola pragmatica che applico per decidere se promuovere una primitive a value object è una sola: il value object si giustifica solo se ha almeno due metodi comportamentali che valgono la promozione. Un PartitaIva con solo il costruttore validante e il metodo value() che restituisce la stringa è peggio di una semplice string validata al momento dell'input - aggiunge cerimonia senza aggiungere comportamento. Lo stesso PartitaIva diventa sensato come value object se ha un metodo nazione(): Nazione che estrae il codice paese dalla prefisso, un metodo isTrasparente(): bool che verifica se è una P.IVA di soggetto trasparente, un metodo verificaVies(): ViesResult che interroga il sistema VIES europeo. Con comportamento reale, il value object aggiunge valore. Senza comportamento, è cerimonia.
Sul progetto assicurativo milanese, avevo identificato 320 classi di value object che ricadevano nella categoria "solo cerimonia": tipicamente wrapper di stringhe o interi con solo costruttore, getter e equals(). Le abbiamo sistematicamente degradate a semplici type-hinted parameter con validazione al bordo (tipicamente via Laravel Form Request in input, via constraint Validator in Doctrine o Eloquent). Le 60 classi di value object che restavano - quelle con comportamento reale come Premio (che sapeva calcolare IVA, addizionali regionali, arrotondamenti), PeriodoAssicurazione (che sapeva calcolare durate prorata temporis, sovrapposizioni, rinnovi), Franchigia (che sapeva applicarsi a un danno e calcolare il residuo) - restavano pienamente giustificate e sono rimaste nel codice come cuore del dominio. La lezione operativa è: value object si ripaga solo quando incapsula comportamento, non quando incapsula solo tipizzazione.
Aggregate e consistenza: il pattern che funziona solo se rispetti il suo costo transazionale
Un aggregate root in DDD è il punto di ingresso unico per la modifica di un cluster di entità correlate, con la garanzia di consistenza transazionale fra tutti gli elementi del cluster. Il pattern è potentissimo in domini con invarianti complesse fra entità diverse (nel dominio assicurativo, per esempio, una polizza ha coerenza obbligatoria con i suoi massimali, franchigie, beneficiari, clausole aggiuntive - modificare uno di questi senza verificare la coerenza con gli altri può produrre polizze invalide normativamente). Il costo del pattern aggregate è che ogni modifica che attraversa il cluster passa attraverso il root, le scritture devono essere atomiche, e lo stato dell'aggregate deve essere caricato completamente in memoria prima di ogni modifica - con conseguenze di performance significative su aggregate grossi.
L'errore che vedo più spesso è l'over-sizing degli aggregate: il designer include nello stesso aggregate entità che semanticamente non hanno invarianti condivise, gonfia il cluster, e il caricamento di ogni modifica diventa pesante. Un buon aggregate nel dominio assicurativo era Polizza che contiene Massimale, Franchigia, Beneficiario, Clausola - questi hanno invarianti reali (la somma dei massimali per categoria non deve superare il capitale totale, la franchigia non può essere superiore al massimale, i beneficiari devono essere almeno uno). Un cattivo aggregate era Polizza esteso a includere anche Sinistro, PagamentoPremio, DocumentoEmesso, perché questi non hanno invarianti condivise con la polizza stessa (un sinistro può esistere prima di alcuni dettagli di polizza, i pagamenti hanno il proprio ciclo di vita indipendente, i documenti sono artefatti storici che non cambiano con la polizza).
Nel progetto milanese, la ridefinizione degli aggregate a dimensioni coerenti con le invarianti reali ha ridotto il tempo medio di caricamento di un aggregate Polizza da 2,1 secondi a 340 millisecondi, e ha eliminato circa il 70% delle optimistic concurrency failures che il sistema generava in produzione. Per le entità rimosse dall'aggregate originale (sinistri, pagamenti, documenti), abbiamo adottato il pattern di separate aggregate con comunicazione asincrona via domain event, allineandoci ai principi di separazione tra scritture e letture che ho descritto nel mio articolo su CQRS in PHP e Laravel per separare letture e scritture in applicazioni ad alto carico, che è un pattern architetturalmente complementare a DDD e spesso necessario per gestire aggregate grossi senza sacrificare le performance.
Cosa ho abbandonato come over-engineering nel progetto milanese (e che replicherei in casi simili)
Alla fine dell'intervento, la lista delle decisioni architetturali che abbiamo rinnegato esplicitamente rispetto al design iniziale è stata lunga e istruttiva. La elenco nei punti più rilevanti, perché è il tipo di lista che difficilmente si trova nella letteratura ufficiale di DDD e che invece è oro per chi sta valutando l'adozione della metodologia in un contesto PMI. Primo, abbiamo eliminato le interfacce repository astratte nelle zone a bassa complessità - per ottanta dei cento Eloquent model del sistema, l'interfaccia repository era solo un wrapper dell'Eloquent underneath, sostituita in produzione dall'unica implementazione possibile, senza un solo caso in cui avessimo bisogno di swappare implementazione in test o runtime. L'interfaccia era puro costo cognitivo senza beneficio, e l'abbiamo eliminata mantenendo Eloquent diretto per gli aggregate semplici. Secondo, abbiamo eliminato il domain event bus asincrono per i contesti a bassa complessità: era un meccanismo di RabbitMQ che trasportava eventi fra bounded context, ma in otto dei dieci use case reali gli event handler erano eseguiti in modo sincrono nella stessa transazione, e il bus asincrono aggiungeva solo latenza e complessità operativa. Abbiamo mantenuto il bus solo per gli eventi autenticamente cross-context che beneficiavano di asincronia (notifiche al cliente finale, sincronizzazione con sistemi compagnia esterni, audit trail su destinazione separata). Terzo, abbiamo eliminato i command handler come pattern obbligatorio per ogni operazione di scrittura: per le operazioni banali di CRUD su anagrafica, la catena Controller → CommandBus → Handler → AggregateRoot → Repository → Eloquent era sette strati di indirezione per salvare un campo testo. L'abbiamo sostituita con un semplice application service per le operazioni semplici, riservando il command handler pattern solo alle operazioni transazionali complesse del core assicurativo. Quarto, abbiamo reintrodotto Eloquent come primary storage per la maggioranza degli aggregate, utilizzando le feature di dependency injection avanzata PHP 8 e pattern di servizi testabili che ho descritto in un articolo dedicato per garantire la testabilità dove serve, senza forzare l'uso di Doctrine ORM che era stato scelto inizialmente più per ortodossia DDD che per reali necessità tecniche.
Il risultato finale del progetto al termine dei cinque mesi di intervento è stato il seguente. Velocità di sviluppo misurata in story point per sprint aumentata del 240% nel trimestre successivo alla ristrutturazione. Linee di codice totali ridotte del 38% rispetto al picco, nonostante l'aggiunta di tre nuovi moduli funzionali durante il periodo di intervento. Onboarding di nuovi sviluppatori assunti nel trimestre sceso da 12 settimane a 4 settimane per diventare produttivi in autonomia. Numero di bug in produzione per release sceso del 60% rispetto al trimestre precedente all'intervento. MVP consegnato al cliente finale entro fine dicembre 2024 come promesso in nuova roadmap. Costo consulenziale dell'intervento: 38.000 euro. ROI contrattuale stimato dal CTO in fase di retrospettiva: oltre 500.000 euro nei dodici mesi successivi fra velocità di sviluppo recuperata, riduzione dei bug di produzione, e risparmio di costi di riscrittura del codice che sarebbe stato necessario se il progetto fosse continuato sulla strada iniziale per altri dodici mesi.
Se guidi una PMI tecnologica che sta valutando l'adozione di Domain-Driven Design su un nuovo progetto Laravel, o se hai ereditato un progetto esistente che ti sembra impantanato in complessità architetturale che non produce valore funzionale, prima di procedere con la prossima decisione strutturale vale la pena fare una valutazione onesta e indipendente. Il messaggio più importante di questo articolo non è "non usare DDD" - è "usa DDD con consapevolezza del costo che introduce, e applicalo selettivamente solo dove il dominio lo giustifica". Se vuoi confrontarti sul tuo caso specifico con una valutazione indipendente della complessità reale del tuo dominio rispetto ai pattern architetturali che stai valutando o che hai già adottato, contattami per una consulenza preliminare: in una mezza giornata di analisi guidata produciamo insieme una mappatura realistica dei bounded context del tuo dominio, un giudizio onesto su quali pattern DDD avanzati si ripagano davvero nel tuo contesto, e una roadmap di semplificazione o consolidamento architetturale calibrata sulle dimensioni reali del tuo team e del tuo progetto.