Il gate architetturale che gira davvero: 4.445 classi, sette violazioni, due falsi verdi
Hai scritto le regole dell'architettura in un documento, le hai spiegate in riunione, e sei mesi dopo un controller inietta un repository JPA. Nessuno lo ha fatto per cattiveria: era venerdì, funzionava, e nessuno strumento ha detto niente. Una regola che non fallisce non è una regola, è un'opinione con un file di accompagnamento.
La forma eseguibile di quelle regole esiste, si scrive in Java e si chiama ArchUnit: legge i bytecode compilati, valuta asserzioni sui pacchetti e sulle dipendenze, e fallisce come un test qualsiasi. Questo progetto ne usa la versione 1.3.0, dichiarata nel pom.xml con ambito di test.
Questo articolo esegue la regola principale su tutto il codice e mostra l'uscita vera. Poi mostra due modi in cui lo stesso strumento può sembrare verde senza controllare niente, perché li ho provocati di proposito e misurati: è la parte che vale di più, perché un gate finto è peggio di nessun gate.
La regola minima, riga per riga
Il blocco qui sotto è quanto serve per avere un gate funzionante. Non è uno pseudocodice: è la forma che gira, ridotta all'essenziale.
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);
ClassFileImporter arriva da com.tngtech.archunit.core.importer e legge i file .class, non i sorgenti. La conseguenza pratica è che devi aver compilato prima, e che le classi non compilate non vengono controllate: se lanci il gate su un modulo compilato a metà, il verde non significa niente. Il metodo importPackages accetta una o più stringhe di pacchetto e scandaglia il classpath.
DO_NOT_INCLUDE_TESTS è una delle opzioni predefinite di ImportOption, e riconosce i bytecode di test dalla posizione sul disco, cioè dalle cartelle che Maven e Gradle usano per convenzione. Senza di essa il gate include i test, e un test di integrazione che importa Spring per mestiere diventa una violazione del dominio: il risultato è una lista di falsi positivi che convince il team che lo strumento non funziona. Le altre opzioni della stessa famiglia escludono i file jar, i percorsi dei pacchetti archiviati o quelli che corrispondono a una espressione regolare.
noClasses().that(...).should(...) è la forma negativa, e si legge letteralmente: nessuna classe che sta nel pacchetto indicato deve dipendere da classi che stanno negli altri. Esiste anche la forma positiva, classes().that(...).should(...), e la scelta fra le due non è stilistica: la forma negativa definisce ciò che è vietato e lascia passare tutto il resto, mentre la positiva definisce ciò che è obbligatorio e va scritta in modo esaustivo o produce falsi allarmi.
| Elemento | Valori ammessi | Se lo ometti | Perché così qui |
|---|---|---|---|
importPackages | Una o più stringhe di pacchetto | Non compila: serve almeno una radice | Una radice sola: controllare tutto il codice |
withImportOption | Opzioni predefinite o un predicato tuo | Importa anche i test | I test importano il framework per mestiere |
.. finale nel pacchetto | Con o senza | Controlla solo il pacchetto diretto | Le regole di dominio valgono sui sottopacchetti |
as(...) | Testo libero | Messaggio generato dalla sintassi | Dice al lettore del fallimento che cosa riparare |
L'esecuzione, e i sette che ci sono
Il metodo check non restituisce un valore: se trova violazioni lancia un AssertionError con l'elenco. L'ho eseguito il 31 ottobre 2026, compilando contro i bytecode del progetto con OpenJDK 21.0.12.1, e catturando il messaggio per contarne le righe.
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
cd backend
mvn -o -q dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt -DincludeScope=test
$JAVA_HOME/bin/javac -proc:none -cp "target/classes:$(cat /tmp/cp.txt)" -d /tmp/out DemoArchUnit.java
$JAVA_HOME/bin/java -cp "/tmp/out:target/classes:$(cat /tmp/cp.txt)" DemoArchUnit
INFO com.tngtech.archunit.core.PluginLoader -- Detected Java version 21.0.12.1
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 <...Pageable>
Method <...domain.port.ProposalRepository.findAll(...)> has return type <...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 <...Page>
Sette righe, quattro classi. La prima cosa utile di questa uscita è che nomina il tipo di dipendenza, non solo la classe: «è annotata con», «ha un parametro di tipo», «ha un tipo di ritorno». Sono tre difetti di natura diversa e si riparano in modo diverso, e uno strumento che dicesse soltanto «questa classe viola la regola» ti lascerebbe a cercare dove.
Vale la pena notare che nessuna delle sette violazioni riguarda la logica di business. Due classi del dominio sono annotate come componenti di Spring, e tre firme di porte usano i tipi di paginazione di Spring Data: sono tutte dipendenze contratte per comodità di collegamento, non regole di prodotto scritte nel posto sbagliato. È un esito frequente, e dice qualcosa sul tipo di erosione che un'architettura subisce davvero: non arriva da decisioni sbagliate sul dominio, arriva dal bisogno di far funzionare l'assemblaggio in fretta.
La seconda cosa utile è il numero di classi importate: 4.445. Serve come controllo di sanità, e va guardato ogni volta. Se un giorno quel numero crolla a trecento, la regola sta controllando un decimo del codice e il verde che produce non vale niente; il motivo più comune è una compilazione parziale o un pacchetto radice scritto male.
Primo falso verde: la regola che non seleziona niente
Il modo più facile di avere un gate che non protegge è scrivere una regola il cui that() non corrisponde a nessuna classe. Succede quando rinomini un pacchetto, quando sbagli a scrivere un nome, o quando copi una regola da un altro progetto. Ho provato a scrivere presentazione invece di presentation, e questo è il risultato:
pacchetto inesistente, senza allowEmptyShould -> FALLISCE: Rule 'regola su un pacchetto che non esiste'
failed to check any classes. This means either that no classes have been passed to the rule at all, or
that no classes passed to the rule matched the `that()` clause. To allow rules being evaluated without
checking any classes you can either use `ArchRule.allowEmptyShould(true)` on a single rule or set the
configuration property `archRule.failOnEmptyShould = false` to change the behavior globally.
pacchetto inesistente, con allowEmptyShould(true) -> PASSA
La buona notizia è che ArchUnit 1.3.0, per impostazione predefinita, fallisce: una regola che non ha controllato nulla è considerata un errore, e il messaggio spiega esattamente che cosa sta succedendo. La cattiva notizia è la seconda riga. Con allowEmptyShould(true) la stessa regola sbagliata passa in silenzio, e quella opzione viene aggiunta per una ragione legittima: durante uno sviluppo incrementale un pacchetto può essere ancora vuoto e non vuoi che la suite sia rossa per questo.
La regola di prudenza che ne ricavo è che allowEmptyShould(true) va trattata come una deroga temporanea, con un commento che dice perché c'è e quando va via. In questo progetto compare su due regole su cinque, in entrambi i casi su pacchetti di una funzionalità in costruzione, e il commento nel sorgente lo dichiara. Se la trovi su una regola che riguarda un pacchetto pieno da due anni, quella regola non sta controllando il pacchetto che credi.
Secondo falso verde: un punto invece di due
Questo è più insidioso, perché non c'è nessun nome sbagliato e nessuna opzione sospetta. La notazione dei pacchetti di ArchUnit distingue domain da domain..: la prima indica quel pacchetto e nient'altro, la seconda include tutti i discendenti. Ho eseguito la regola del dominio puro con un punto solo:
noClasses()
.that().resideInAPackage("dev.federicocalo.portfolio.domain")
.should().dependOnClassesThat()
.resideInAnyPackage("org.springframework..", "jakarta.persistence..")
.allowEmptyShould(true)
un solo punto invece di due in domain. -> PASSA
Passa. Non perché il dominio sia pulito, ma perché nel pacchetto domain non ci sono classi dirette: tutte le 139 stanno nei venti sottopacchetti, da domain.model a domain.valueobject. La regola ha controllato un insieme vuoto e, con la deroga attiva, ha dichiarato vittoria. Lo stesso errore senza la deroga sarebbe fallito con il messaggio visto sopra, che è un ottimo argomento per non metterla di default.
Le due trappole si combinano in modo pessimo: chi aggiunge allowEmptyShould(true) per silenziare un pacchetto vuoto e poi copia quella regola come modello ha prodotto una fabbrica di gate decorativi. Il controllo che le trova entrambe è lo stesso: stampare il numero di classi che la regola ha effettivamente valutato, e guardarlo.
Quanto costa, misurato
La domanda che arriva subito dopo è quanto rallenta la suite. L'importazione dei bytecode è la parte lenta, e il controllo di una singola regola su un insieme già importato è trascurabile. Ho cronometrato tre esecuzioni complete, dall'avvio della macchina virtuale alla stampa del risultato:
cd backend
CP="/tmp/out:target/classes:$(cat /tmp/cp.txt)"
for i in 1 2 3; do
S=$(date +%s.%N)
java -cp "$CP" DemoArchUnit >/dev/null 2>&1
E=$(date +%s.%N)
echo "corsa: $(echo "$E - $S" | bc) s"
done
corsa: 5.840097608 s
corsa: 5.907706628 s
corsa: 5.978796117 s
Sei secondi, con una dispersione di un decimo fra le tre corse. Praticamente tutto quel tempo è l'importazione delle 4.445 classi, ed è il motivo per cui nel test del progetto le classi si importano una volta sola in un metodo annotato @BeforeAll e le cinque regole le riusano: pagare sei secondi una volta è accettabile, pagarli cinque volte no.
Il numero va letto come un ordine di grandezza, non come una costante: dipende dal numero di classi, dal disco e dal fatto che il risultato sia già nella cache del sistema operativo. Sulla tua macchina sarà diverso, e la misura che conta è quella che fai tu con lo stesso comando. Quello che non cambia è il rapporto: l'importazione domina, e quindi il modo di tenere basso il costo è importare una volta.
Le cinque regole di questo progetto, e perché non sono una
Il test architetturale di questo backend non contiene una regola ma cinque, e la differenza fra loro non è il contenuto: è l'ampiezza della selezione. Tre hanno uno scopo ristretto e due sono globali.
- Una classe sola, selezionata con
haveFullyQualifiedName: vieta a un singolo controller di importare l'infrastruttura, perché quella era la correzione in corso. - Un pacchetto di funzionalità, con
resideInAPackagesu un ramo preciso: vale per la presentazione di un modulo nuovo, dove il debito storico non esiste. - Lo stesso pacchetto sul lato applicativo, con una esclusione per nome: i registri sono fuori, e il commento dichiara che la dipendenza è voluta.
- Due regole globali, una sulla presentazione e una sul dominio, che coprono tutto il codice e portano il debito storico congelato.
Lo schema che ne emerge è utile da copiare. Le regole ristrette servono quando stai correggendo un punto e vuoi che resti corretto: sono severe, non hanno deroghe, e hanno il nome della classe dentro. Le regole globali servono a impedire che il problema si allarghi altrove, e per esistere su un progetto reale devono poter convivere con le violazioni che c'erano prima.
Il commento nel sorgente spiega anche perché le regole ristrette non sono state allargate subito: una selezione ampia su codice non ancora bonificato produce decine di fallimenti che non riguardano il lavoro in corso, e il risultato pratico è che qualcuno disattiva il test. La scelta è stata avere prima la rete sottile dove serviva, poi la rete larga con il debito dichiarato.
Il falso verde più costoso, documentato nel sorgente
C'è un terzo modo di avere un gate che non gira, e non l'ho riprodotto io: è registrato nel commento del test di questo progetto, con la data dell'audit che lo ha scoperto. Lo riporto perché è il più difficile da notare di tutti.
ArchUnit offre un modo idiomatico di dichiarare le regole, come campi statici annotati @ArchTest, raccolti da un motore dedicato chiamato archunit-junit5. Il commento nel sorgente dichiara che quel motore, nella versione 1.3.0, non viene individuato da Surefire 3.5.3 contro la piattaforma JUnit 1.11.4 che Spring Boot 3.4 porta con sé, e che il risultato è un conteggio di test eseguiti pari a zero. La stessa classe, con le stesse regole, non controllava niente e la suite era verde.
La correzione adottata è smettere di usare il motore dedicato: le classi si importano una volta in un metodo annotato @BeforeAll, e ogni regola diventa un normale metodo di test che chiama check. Il motore che esegue tutti gli altri test della suite esegue anche questi, e non c'è nessun componente aggiuntivo da far riconoscere.
- Guarda il numero di test eseguiti, non il colore della suite: zero test verdi sono verdi.
- Guarda il numero di classi importate dal gate, e confrontalo con quante ne hai.
- Introduci di proposito una violazione e verifica che il gate la trovi, almeno una volta, il giorno in cui lo scrivi.
- Usa il motore di test che già gira, invece di aggiungerne uno che va fatto riconoscere.
- Tratta ogni deroga come temporanea: una opzione che ammette l'insieme vuoto porta accanto un commento con il motivo e la data.
Il terzo punto è l'unico che chiude davvero la questione, e costa cinque minuti. Scrivi una classe fittizia nel dominio, mettici un'annotazione di Spring, lancia il gate, verifica che diventi rosso, cancella la classe. Un gate che non è mai stato visto fallire non ha nessuna prova di funzionare, e tutte e tre le trappole di questo articolo sarebbero state scoperte da quell'unico esperimento.
Un ultimo accorgimento sul testo di as(...): è il posto in cui scrivere la riparazione, non il problema. «Il dominio dipende da Spring» descrive il fallimento, che il lettore vede già; «inietta la porta invece dell'adattatore» gli dice che cosa fare. Chi leggerà quel messaggio sarà qualcuno che ha la build rossa e dieci minuti di pazienza, e la differenza fra le due formulazioni decide se la regola verrà rispettata o disattivata.
Chiusura
Adesso sai scrivere una regola architetturale eseguibile in dieci righe, e sai che cosa decide ciascuna: la radice dei pacchetti da importare, l'esclusione dei test, la differenza fra un punto e due, e il testo che spiega il fallimento a chi lo leggerà.
Sai anche riconoscere un gate che non protegge, perché ne hai visti tre casi misurati: la regola che non seleziona nessuna classe e passa grazie a una deroga, il pacchetto scritto senza i due punti che valuta un insieme vuoto, e il motore di test che non viene individuato e riporta zero test eseguiti. In tutti e tre i casi la suite è verde.
Resta un problema pratico: il gate del dominio, su questo progetto, trova sette violazioni, e se lo attivi oggi la tua suite diventa rossa per un debito che non hai creato tu. Il prossimo articolo della serie mostra come si rende visibile quel debito senza aprire un cantiere, con 225 violazioni congelate in un file e la trappola che può azzerarle in silenzio.
Commenti
Caricamento commenti...
Accedi per commentare