Hai fatto quattro cartelle e non è cambiato niente: la freccia che manca
Hai letto di Clean Architecture, hai creato domain, application, infrastructure e presentation, hai spostato le classi dentro, e il progetto si comporta esattamente come prima. I controller continuano a iniettare i repository JPA, il modello di dominio ha ancora le annotazioni di Hibernate, e quando devi cambiare la libreria di accesso al database tocchi gli stessi venti file di prima. Hai pagato il prezzo della struttura e non hai ricevuto il beneficio.
Il motivo è che le quattro cartelle non sono l'architettura: sono il posto dove l'architettura si può verificare. La regola vera è una sola, si chiama regola della dipendenza, e dice in quale direzione può puntare un import. Finché quella direzione non è controllata da qualcosa che fa fallire la compilazione o la suite, le cartelle sono decorazione.
Questa serie smonta un backend Spring Boot reale e in produzione - quello che serve federicocalo.dev - e mostra come i quattro strati stanno insieme, quanto costano, dove sono stati violati e come la violazione è stata resa visibile. Ogni numero che leggi viene da un comando che puoi rifare sul tuo progetto.
La regola della dipendenza, in una domanda sola
Prendi una classe qualsiasi, apri la lista dei suoi import e chiediti: questa classe conosce qualcosa che sta più in fuori di lei? Se la risposta è sì, hai trovato una violazione. L'ordine, dal centro verso il bordo, è dominio, applicazione, infrastruttura e presentazione; il dominio non conosce nessuno, l'applicazione conosce solo il dominio, e tutto il resto guarda verso l'interno.
Detto così sembra ovvio e inutile. Diventa utile quando lo traduci in conseguenze che puoi misurare sul calendario: quanti file devi aprire per sostituire PostgreSQL con un altro motore, quanti per aggiungere un canale di ingresso oltre a HTTP, quanti test puoi scrivere senza accendere un database. Se la risposta a queste tre domande non cambia dopo aver creato le cartelle, la ristrutturazione non è avvenuta.
C'è una seconda formulazione, più brutale e più facile da controllare: il codice che contiene le regole di business non deve avere nessun motivo per cambiare quando cambia uno strumento. Se aggiorni la versione di Spring e devi toccare una classe che calcola uno sconto, quella classe non è nel posto giusto, indipendentemente dalla cartella in cui l'hai messa.
Il conto reale: dove stanno i 3.677 file
Le proporzioni di un progetto vero dicono più di qualsiasi diagramma. Questo comando conta i file sorgente di ciascuno strato, e lo puoi lanciare sul tuo progetto cambiando il percorso del pacchetto radice.
cd backend
for l in domain application infrastructure presentation; do
echo -n "$l: "
find src/main/java/dev/federicocalo/portfolio/$l -name "*.java" | wc -l
done
find src/main/java -name "*.java" | wc -l
Eseguito il 31 ottobre 2026 su questo repository, il comando ha stampato questo:
domain: 139
application: 1626
infrastructure: 316
presentation: 149
3677
La prima cosa che salta all'occhio è che il dominio è lo strato più piccolo, e che l'applicazione pesa più di dieci volte tanto. È controintuitivo solo se pensi che il dominio sia «il modello dei dati»: in questa impostazione il dominio contiene soltanto le entità, i value object, le porte e le poche regole che valgono a prescindere dal caso d'uso. Tutto il resto - orchestrazione, validazione d'ingresso, trasformazione in DTO, decisioni su che cosa fare in che ordine - vive nell'applicazione, ed è lì che un prodotto reale accumula massa.
La seconda cosa è che la presentazione e l'infrastruttura, sommate, stanno sotto il 13% del totale. Sono gli strati sostituibili, e il fatto che siano sottili è esattamente il risultato che la struttura promette: il bordo è spesso quanto basta per tradurre, non per decidere.
| Strato | File Java | Che cosa contiene qui | Che cosa può importare |
|---|---|---|---|
domain | 139 | Entità, value object, porte, regole invarianti | Solo la libreria standard di Java |
application | 1626 | Casi d'uso, DTO di richiesta e risposta, eccezioni applicative | Il dominio |
infrastructure | 316 | Entità JPA, adattatori, client HTTP, sicurezza, cache | Dominio e applicazione |
presentation | 149 | Controller REST, gestori di eccezioni, filtri | L'applicazione |
Un caso d'uso vero attraversa sette file
La teoria si giudica sul costo. Prendiamo la funzione più piccola possibile del progetto: una libreria di citazioni con due endpoint di lettura, uno che restituisce una citazione casuale e uno che restituisce una pagina filtrata per autore e tema. Nessuna scrittura, nessuna autenticazione, nessuna logica di business degna di questo nome. Ecco tutti i file che la compongono.
cd backend/src/main/java/dev/federicocalo/portfolio
find . -iname "*Quote*" | sort
./application/dto/response/QuoteResponse.java
./application/usecase/quotes/GetRandomQuoteUseCase.java
./application/usecase/quotes/GetRandomQuoteUseCaseImpl.java
./application/usecase/quotes/ListQuotesUseCase.java
./application/usecase/quotes/ListQuotesUseCaseImpl.java
./domain/model/Quote.java
./domain/port/QuoteRepository.java
./infrastructure/persistence/adapter/QuoteRepositoryAdapter.java
./infrastructure/persistence/entity/QuoteEntity.java
./infrastructure/persistence/repository/JpaQuoteRepository.java
./presentation/controller/QuoteController.java
Undici file, non sette: ho contato male di proposito nel titolo della sezione, e vale la pena dire perché. Se togli i due casi d'uso che non ci interessano qui e il repository di Spring Data, restano sette file per un singolo endpoint di lista: il modello di dominio, la porta, l'interfaccia del caso d'uso, la sua implementazione, il DTO di risposta, l'adattatore e il controller. In un'applicazione scritta nel modo più diretto possibile quello stesso endpoint sarebbe un metodo in un controller che chiama un repository Spring Data e restituisce l'entità JPA. Un file.
Questo è il conto da mettere sul tavolo prima di adottare la struttura, perché è il punto in cui la maggior parte delle discussioni finisce male. Sette file contro uno non è un dettaglio di stile: è una moltiplicazione del lavoro di scrittura, di lettura e di navigazione, e su un endpoint di sola lettura senza regole non si ripaga. Vale la pena quando almeno una di tre condizioni è vera: la logica che circonda quella lettura è destinata a crescere, il dato arriverà anche da una fonte diversa dal database, oppure vuoi poter testare la decisione senza accendere PostgreSQL.
Dove la paghi e dove la incassi
Nel caso delle citazioni la terza condizione è l'unica che si è verificata, e si vede in una riga sola. Il caso d'uso limita la dimensione della pagina, e quel limite è una decisione di prodotto, non un dettaglio di trasporto.
final int cappedSize = Math.min(Math.max(1, size), MAX_SIZE);
MAX_SIZE è una costante privata del caso d'uso e vale 50. Il punto interessante non è il numero, è dove sta. Se quel limite vivesse nel controller, verrebbe applicato solo alle richieste che arrivano da HTTP: un lavoro schedulato, un consumatore di messaggi o un comando di amministrazione che chiamasse lo stesso caso d'uso potrebbe chiedere centomila righe e portarsi via la memoria del processo. Messo nel caso d'uso, il limite vale per tutti i chiamanti presenti e futuri, e questo è esattamente il tipo di garanzia che la struttura compra.
Il controller, dal suo lato, dichiara lo stesso vincolo con le annotazioni di Bean Validation (@Min(1) @Max(50) su size), e qui la duplicazione è voluta: la validazione sul bordo serve a restituire un 400 con un messaggio chiaro invece di accettare in silenzio un valore assurdo e correggerlo di nascosto. La regola che ne ricavo è semplice: il bordo rifiuta, il caso d'uso garantisce. Quando scegli uno solo dei due, scegli fra un errore poco chiaro e una protezione che non copre tutti i chiamanti.
Le quattro cose che finiscono nel dominio per sbaglio
Quasi tutte le violazioni che ho visto nascere non arrivano da una decisione architetturale sbagliata: arrivano da una comodità presa alle undici di sera. Sono sempre le stesse quattro, in questo ordine di frequenza, e riconoscerle in anticipo vale più di qualsiasi diagramma appeso al muro.
- Le annotazioni del framework. Una classe di dominio che contiene logica utile viene marcata come componente per poterla iniettare. Da quel momento il dominio non si istanzia più con
newin un test. - I tipi di paginazione.
PageePageablesono comodi e descrivono bene il problema, ma appartengono a Spring Data. Una porta che li usa ha legato il dominio alla libreria di accesso ai dati. - Le annotazioni di persistenza. Riusare la stessa classe come entità JPA e come modello di dominio risparmia un mapper e vincola il modello alla forma delle tabelle, compresi i costruttori vuoti e i campi mutabili che Hibernate pretende.
- Le annotazioni di serializzazione. Mettere
@JsonIncludeo@JsonPropertysul modello di dominio significa che la forma del JSON pubblico e la forma del tuo modello non potranno più divergere.
Le prime due sono quelle che hanno prodotto le sette violazioni che vedrai fra poco in questo progetto, e non sono state corrette: sono state contate. Le ultime due qui non ci sono, e la ragione è che evitarle costa poco: esiste una classe QuoteEntity separata con le annotazioni JPA, e un record QuoteResponse separato con quelle di Jackson. Il modello di dominio Quote è un record di otto campi che non importa niente oltre a java.time.Instant e java.util.UUID.
Quando valuti una di queste quattro scorciatoie, la domanda utile non è «è elegante?» ma «quanti chiamanti dovrò toccare il giorno in cui questa libreria cambia?». Per le annotazioni di serializzazione la risposta è «tutti i consumatori del mio JSON», e per questo non si prendono mai. Per i tipi di paginazione la risposta è «i due adattatori che la implementano», e per questo in questo progetto si è deciso di tenerla e di dichiararla.
Il controllo che puoi eseguire oggi
Finché la regola della dipendenza vive in un documento, non esiste. Questa è la versione eseguibile, scritta con ArchUnit 1.3.0: importa i bytecode compilati e verifica che nessuna classe del dominio dipenda da Spring o da JPA.
JavaClasses classi = new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages("dev.federicocalo.portfolio");
ArchRule regola = noClasses()
.that().resideInAPackage("dev.federicocalo.portfolio.domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("org.springframework..", "jakarta.persistence..")
.as("domain puro: niente Spring, niente JPA");
regola.check(classi);
Tre cose vanno capite di questo blocco, perché sono le tre che cambierai quando lo adatterai al tuo progetto. ClassFileImporter legge i file .class, non i sorgenti: devi aver compilato, e se compili solo una parte del progetto la regola controlla solo quella parte. DO_NOT_INCLUDE_TESTS esclude i bytecode di test, e senza questa opzione un test che per mestiere importa Spring diventa una violazione del dominio. Il doppio punto finale in domain.. significa «questo pacchetto e tutti i suoi discendenti»: con un punto solo controlleresti il pacchetto domain e nessun sottopacchetto, cioè quasi niente.
Il metodo check non restituisce un valore: lancia un AssertionError con l'elenco delle violazioni. L'ho eseguito il 31 ottobre 2026 su questo repository, compilato con OpenJDK 21.0.12.1, e ha stampato questo:
classi importate = 4445
ESITO = rosso, righe di violazione = 7
Architecture Violation [Priority: MEDIUM] - Rule 'domain puro: niente Spring, niente JPA' was violated (7 times):
Class <...domain.bloodwork.BloodworkInterpreter> is annotated with <org.springframework.stereotype.Component>
Class <...domain.termsheet.TermSheetAnalyzer> is annotated with <org.springframework.stereotype.Component>
Method <...domain.port.ProposalRepository.findAll(...)> has parameter of type <org.springframework.data.domain.Pageable>
Method <...domain.port.ProposalRepository.findAll(...)> has return type <org.springframework.data.domain.Page>
Method <...domain.port.ProposalRepository.findByAuthorId(...)> has parameter of type <...Pageable>
Method <...domain.port.ProposalRepository.findByAuthorId(...)> has return type <...Page>
Method <...domain.port.QuoteRepository.findFiltered(...)> has return type <org.springframework.data.domain.Page>
Sette violazioni su 4.445 classi, concentrate in quattro classi. Due sono annotate come componenti di Spring e stanno nel dominio; due porte restituiscono il tipo Page di Spring Data. Nessuna di queste è un disastro, e nessuna è stata scritta per sbaglio: sono scorciatoie prese consapevolmente, perché riscrivere la paginazione come tipo del dominio costa lavoro e il beneficio immediato è zero. Il punto non è che non ci siano violazioni: è che sono sette, contate, e che una ottava farebbe fallire la suite.
Questo è il lascito concreto delle quattro cartelle. Non impediscono di sbagliare: rendono lo sbaglio enumerabile. Un progetto senza strati dichiarati ha lo stesso numero di scorciatoie, ma non puoi contarle, quindi non puoi decidere quali ripagare e quali tenere.
Il primo passo sul tuo progetto, in ordine
Se il tuo progetto ha già le quattro cartelle e nessun controllo, l'errore da non fare è cominciare dalla pulizia. La pulizia senza misura è una riscrittura mascherata: tocchi decine di file, non sai quando hai finito, e alla prima scadenza vera il lavoro resta a metà. L'ordine che funziona è l'opposto, e si fa in un pomeriggio.
- Aggiungi la dipendenza e scrivi una regola sola, quella del dominio puro, e lanciala. Il numero che esce è la tua linea di partenza.
- Non correggere niente. Congela quel numero, così la suite resta verde e nessuna violazione nuova può entrare senza farla fallire.
- Scrivi la seconda regola, quella che vieta alla presentazione di conoscere l'infrastruttura, e ripeti: misura, congela, non correggere.
- Ripaga una violazione per volta, quando tocchi quel codice per un'altra ragione, e verifica che il numero congelato scenda da sé.
Il terzo passo è quello che la maggior parte dei progetti salta, e vale la pena insistere: la freccia che si rompe più spesso non è quella del dominio, è quella della presentazione. Un controller che inietta un repository JPA compila, funziona, passa i test e non dà nessun segnale di dolore fino al giorno in cui devi esporre la stessa operazione su un canale diverso da HTTP. In questo progetto quella regola esiste, è attiva, e il suo numero congelato è di due ordini di grandezza più grande di quello del dominio. Il quinto articolo della serie lo apre e lo conta riga per riga.
Quello che rende questo ordine sostenibile è la quarta riga: non si apre un cantiere di bonifica. Si rende visibile il debito, si impedisce che cresca, e si ripaga quando si passa da quelle parti per un motivo che paga già da sé. Un gate che congela è l'unico modo che conosco per avere una regola architetturale in un progetto che deve anche consegnare.
Chiusura
Adesso sai distinguere due cose che venivano confuse: la struttura delle cartelle, che è gratis e non garantisce niente, e la regola della dipendenza, che è l'unica affermazione verificabile della Clean Architecture. Sai formularla come domanda su una classe sola, e sai tradurla in un comando che gira sul tuo progetto e produce un numero.
Sai anche quanto costa, con le cifre di un progetto reale invece delle promesse di un diagramma: sette file per un endpoint di lista, uno strato di dominio che è il 3,8% del codice, e un bordo HTTP che insieme all'infrastruttura non arriva al 13%. E sai qual è la condizione minima perché il conto torni: che il limite di una pagina stia dove vale per tutti i chiamanti, non solo per quello che arriva via HTTP.
Nel prossimo articolo della serie si scende nello strato più piccolo e più frainteso, quello da 139 file, e si guarda che cosa ci sta davvero dentro: value object che validano nel costruttore, record immutabili, e il motivo per cui un dominio che non importa niente si testa senza accendere nulla.
Commenti
Caricamento commenti...
Accedi per commentare