Il dominio che non importa niente: nove record, 368 righe, zero dipendenze
Hai una classe User con una ventina di campi, le annotazioni di Hibernate in cima, i setter generati da Lombok e un metodo che calcola se l'utente può accedere a una funzione a pagamento. Quella classe è contemporaneamente il tuo modello di dominio, la forma della tua tabella e il corpo del tuo JSON. Funziona, e ogni volta che devi scrivere un test su quel metodo devi accendere un contesto Spring, perché la classe da sola non si istanzia in modo sensato.
Il dominio di questo progetto è lo strato più piccolo dei quattro: 139 file su 3.677, cioè il 3,8% del codice. Dentro non ci sono annotazioni, non c'è iniezione delle dipendenze e non ci sono setter. Ci sono record immutabili che validano i propri invarianti nel costruttore, e il risultato è che si eseguono con una riga di comando e un solo percorso nel classpath.
Questo articolo guarda la parte più piccola e più copiabile di quello strato: i nove value object da 368 righe totali. Sono il posto in cui conviene cominciare, perché il beneficio si vede nella prima mezz'ora e non richiede di spostare niente.
Che cos'è un value object, in termini operativi
La definizione da manuale è «un oggetto senza identità, uguale a un altro quando i suoi valori coincidono». È vera e non ti dice che cosa farne. La definizione operativa è più utile: un value object è il posto in cui metti un controllo che oggi è sparso in sei punti del tuo codice, in modo che diventi impossibile costruire il valore sbagliato.
Prendi un indirizzo di posta elettronica. Nel codice tipico passa come String, e la validazione del formato vive nel controller, forse anche nel servizio che manda le email, e certamente non vive nel punto in cui quel valore viene scritto a database da un lavoro schedulato. Il value object sposta il controllo nel tipo: se hai in mano un Email, quell'indirizzo è già stato validato, e non c'è nessun percorso nel programma che possa fartene avere uno che non lo è.
La differenza misurabile è nel numero di punti in cui può entrare un dato sporco. Con la stringa nuda sono tutti i punti di ingresso che hai; con il tipo è uno, il costruttore.
Il costruttore compatto, che è la parte che conta
Questo è il codice reale di Email, che vive in backend/src/main/java/dev/federicocalo/portfolio/domain/valueobject/Email.java. L'ho ridotto alle righe che decidono qualcosa.
public record Email(String value) {
private static final Pattern EMAIL_PATTERN =
Pattern.compile("^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$");
public Email {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Email must not be null or blank");
}
value = value.strip().toLowerCase();
if (!EMAIL_PATTERN.matcher(value).matches()) {
throw new IllegalArgumentException(
"Invalid email format: '" + value + "'");
}
}
public static Email of(String value) {
return new Email(value);
}
public String domain() {
return value.substring(value.indexOf('@') + 1);
}
}
Il blocco public Email { ... } senza parentesi dopo il nome è il costruttore compatto dei record, disponibile da Java 16 e qui compilato con <release>21</release> dichiarato nel pom.xml. Dentro quel blocco i parametri del record sono variabili locali assegnabili: la riga value = value.strip().toLowerCase() non modifica il campo, modifica il parametro prima che il compilatore lo assegni al campo. È il motivo per cui la normalizzazione funziona pur restando il record immutabile, e se provi a fare la stessa cosa in un costruttore canonico scritto per esteso non compila, perché il campo è final.
L'ordine delle tre operazioni non è casuale, ed è la cosa che cambierai più spesso quando adatterai questo schema al tuo caso. Il controllo di nullità viene prima perché strip() su null solleverebbe una NullPointerException, cioè un errore che non dice niente a chi lo legge.
La normalizzazione viene prima della convalida perché altrimenti un indirizzo con uno spazio in coda verrebbe rifiutato invece di essere accolto: se metti la convalida prima, l'utente che ha incollato l'indirizzo da un'email vede un messaggio di errore e non capisce perché.
| Elemento | Da dove viene | Che cosa accetta | Se lo ometti |
|---|---|---|---|
Pattern | java.util.regex, libreria standard | Una espressione regolare come stringa | Devi convalidare a mano, con più righe e più errori |
static final sul pattern | Parola chiave del linguaggio | Nessun valore: è una scelta | Compili l'espressione a ogni costruzione, non una volta per classe |
isBlank() | java.lang.String, da Java 11 | Nessun argomento | Con isEmpty() una stringa di soli spazi passa |
of(...) | Convenzione, non obbligo | Lo stesso argomento del costruttore | Niente: resta new Email(...), più rumoroso in catena |
Una parola sull'espressione regolare, perché è la riga che viene copiata più spesso e capita meno. Non è conforme alla specifica degli indirizzi di posta: è volutamente permissiva, e il commento nel sorgente lo dichiara. La ragione è che una espressione regolare severa rifiuta indirizzi legittimi, e l'unico modo serio di verificare che una casella esista è mandarci un messaggio di conferma. Qui il controllo serve a intercettare l'errore di battitura, non a certificare l'esistenza: se lo irrigidisci, perdi utenti veri per guadagnare una convalida che non vale niente.
Che cosa cambia davvero: l'esperimento
La domanda legittima è se tutto questo produca un effetto osservabile o sia estetica. Ho scritto due versioni della stessa idea, una con il costruttore compatto e una senza, e le ho messe nello stesso programma.
record EmailIngenua(String value) {}
EmailIngenua a = new EmailIngenua("Federico.Calo@Example.COM");
EmailIngenua b = new EmailIngenua("federico.calo@example.com");
System.out.println("ingenua: a.equals(b) = " + a.equals(b));
Email c = Email.of("Federico.Calo@Example.COM");
Email d = Email.of("federico.calo@example.com");
System.out.println("con invariante: c.equals(d) = " + c.equals(d));
Compilato con OpenJDK 21.0.12.1 contro i bytecode del progetto ed eseguito il 31 ottobre 2026, il programma ha stampato questo:
ingenua: a.equals(b) = false
ingenua: chiavi distinte in una mappa = true
con invariante: c.equals(d) = true
ingenua accetta il bianco = " "
con invariante: Email must not be null or blank
con invariante: Invalid email format: 'federico.calo@example'
La seconda riga è il difetto vero, e non è un difetto di eleganza. Due record ingenui costruiti con la stessa casella scritta in due modi hanno codici hash diversi, quindi sono due chiavi distinte in una HashMap e due righe distinte in un Set. In un sistema reale questo diventa un utente duplicato, una newsletter inviata due volte, un controllo di unicità che non scatta. Il record con l'invariante non può produrre quel caso: le due costruzioni convergono sullo stesso valore prima che il campo venga assegnato.
La quinta e la sesta riga mostrano l'altra metà del guadagno. I messaggi di errore sono specifici, nominano il valore rifiutato e arrivano dal punto in cui il valore è nato, non da tre livelli più in là dove nessuno sa più da dove veniva.
La prova che il dominio è davvero isolato
C'è un modo di verificare l'isolamento che non richiede di fidarsi di nessuna dichiarazione: eseguire il codice di dominio con un classpath che non contiene nient'altro. Il progetto ha 3.854 archivi nel repository Maven locale e il classpath di test è lungo 41.528 caratteri; questo comando non ne usa nessuno.
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
cd backend
$JAVA_HOME/bin/javac -proc:none -cp target/classes -d /tmp/out DemoSenzaNormalizzazione.java
/usr/bin/time -f "reale %e s" $JAVA_HOME/bin/java -cp /tmp/out:target/classes DemoSenzaNormalizzazione
reale 0.08 s
reale 0.07 s
reale 0.07 s
Settanta millisecondi, tre volte di fila, per avviare la macchina virtuale, caricare le classi ed eseguire il programma. Non c'è Spring nel classpath, non c'è Hibernate, non c'è un contesto da inizializzare: non perché li abbia esclusi a mano, ma perché il codice non li nomina. Il giorno in cui una classe di dominio acquisisce un'annotazione del framework, questo comando smette di funzionare, e il fallimento è immediato e inequivocabile.
L'opzione -proc:none nel comando di compilazione non è decorativa. Il classpath di questo progetto contiene processori di annotazioni, fra cui quello di Lombok, e il compilatore li attiva da sé quando li trova: senza quella opzione javac stampa un avviso e fa lavoro che qui non serve. Toglierla non rompe niente, ma rende l'uscita del comando meno leggibile, e un comando che il lettore deve rifare va tenuto pulito.
La differenza con il contesto Spring non è una questione di secondi risparmiati in un singolo test: è il fatto che un test di dominio puro non ha nessuna configurazione da sbagliare. Niente profili, niente database di prova, niente bean sostituiti. Quando un test così fallisce, l'unico sospettato è la regola che stai verificando.
Il tipo che impedisce di scambiare due numeri
Il value object più noioso del progetto è anche quello che previene il difetto più difficile da trovare. EntityId avvolge un Long e rifiuta il nullo e lo zero, e in 31 righe risolve una categoria di errori che nessun test unitario intercetta.
public record EntityId(Long value) {
public EntityId {
if (value == null || value <= 0) {
throw new IllegalArgumentException(
"EntityId value must be a positive number, but was: " + value);
}
}
public static EntityId of(Long value) { return new EntityId(value); }
}
Il difetto che previene è lo scambio di posizione. Una firma come trasferisci(Long daUtente, Long adArticolo) accetta i due argomenti invertiti senza una parola: il compilatore vede due Long e tace, il test passa se usa gli stessi identificatori per entrambi, e il problema si manifesta in produzione come un dato associato alla riga sbagliata. Con due tipi distinti lo stesso errore non compila.
La condizione value <= 0 merita attenzione perché è la riga che cambierai. Rifiuta lo zero oltre ai negativi, e lo fa perché nelle sequenze di PostgreSQL le chiavi generate partono da uno: uno zero in quel campo è sempre il risultato di un valore non inizializzato, mai una chiave vera. Se il tuo schema usa identificatori che possono valere zero, questa riga va cambiata, e se la copi senza guardare il tuo schema ottieni un rifiuto su dati legittimi.
Eseguito insieme agli altri due value object il 31 ottobre 2026, il programma di prova ha stampato questo:
email = federico.calo+blog@example.com
dominio = example.com
uguaglianza= true
slug = clean-architecture-spring-boot
rifiutato = Slug must match [a-z0-9][a-z0-9-]*[a-z0-9], but was: 'Clean Architecture'
rifiutato = EntityId value must be a positive number, but was: 0
La prima riga mostra la normalizzazione all'opera su un ingresso sporco, con spazi in testa e in coda e maiuscole: Federico.Calo+Blog@Example.COM diventa la forma canonica. Il segno più nella parte locale sopravvive, perché è legittimo e molti lo usano per etichettare le iscrizioni. Le ultime due righe sono i messaggi che vedrai davvero quando sbagli, copiati dall'esecuzione: nominano il valore rifiutato e la regola violata, che è l'unico modo perché chi legge il log capisca che cosa correggere.
I quattro modi in cui questo schema si rompe
Non è gratis, e vale la pena sapere dove si paga prima di adottarlo su tutto. Questi sono i punti in cui ho visto l'approccio produrre più lavoro di quanto ne risparmi.
- Il serializzatore non sa aprire l'involucro. Un record con un campo solo viene serializzato come oggetto JSON annidato, non come valore nudo, se non gli dici il contrario. La forma del tuo JSON pubblico cambia, e i consumatori se ne accorgono.
- Il framework di persistenza vuole un costruttore vuoto. Un record non ce l'ha e non può averlo. È il motivo per cui l'entità JPA resta una classe separata, e per cui serve un mapper.
- La convalida arriva troppo tardi. Se costruisci il value object dopo aver già accettato la richiesta, l'eccezione nasce nel mezzo del caso d'uso e diventa un errore 500 invece di un 400.
- L'invariante diventa un ostacolo nei dati storici. Un vincolo introdotto oggi vale anche per le righe scritte ieri: se il database contiene valori che il nuovo costruttore rifiuta, la lettura di quei dati solleva un'eccezione.
Il quarto è quello che fa più danni e si scopre tardi, perché il codice nuovo è corretto e il problema sta nei dati vecchi. La regola di prudenza che ne ricavo è questa: un invariante che rifiuta va introdotto insieme a una lettura dei dati esistenti che verifichi quante righe lo violerebbero. Se la risposta è diversa da zero, prima si sanano i dati, poi si stringe il tipo. Nell'ordine inverso l'invariante non protegge il futuro, rompe il passato.
I nove che ci sono, e perché non sono venti
Il dominio di questo progetto ha nove value object, per 368 righe totali. L'elenco dice qualcosa sul criterio con cui sono stati creati.
- Identificatori:
EntityIdrifiuta i valori nulli e non positivi, e serve a non passare un identificatore di articolo dove ne serve uno di utente. - Testo con una forma:
Email,Slug,LanguageCode,IpAddress, dove esiste un formato che si può scrivere ed è utile rifiutare subito ciò che non lo rispetta. - Insiemi chiusi di scelte:
ConversionFormat,PageSize,MarginPreset,DocumentStyle, dove il valore ammesso è uno di pochi e il resto è un errore.
Quello che non c'è è più interessante. Non esiste un value object per il titolo di un articolo, per il corpo di un commento, per il nome di una persona: campi di testo libero senza una forma verificabile, dove un tipo dedicato aggiungerebbe una classe e non impedirebbe nessun errore. Il criterio che ne ricavo è che un value object si crea quando esiste una regola che può rifiutare qualcosa. Se non puoi scrivere la condizione di rifiuto, la stringa va bene com'è, e il tipo in più è solo lavoro.
Il caso di Slug merita una riga, perché la sua espressione regolare è ^[a-z0-9][a-z0-9-]*[a-z0-9]$ e non accetta i trattini all'inizio o alla fine. Sembra un dettaglio ornamentale; è il motivo per cui nessun URL del sito può finire con un trattino, cosa che produrrebbe due indirizzi diversi per la stessa pagina e un contenuto duplicato agli occhi dei motori di ricerca. Una conseguenza esterna al programma, resa impossibile da tre righe in un costruttore.
Chiusura
Adesso sai scrivere un value object che vale la pena di esistere: un record con un costruttore compatto che normalizza prima di convalidare, un messaggio di errore che nomina il valore rifiutato, e nessuna annotazione. Sai anche riconoscere i casi in cui non serve, che sono la maggioranza dei campi di un progetto reale.
Sai dimostrare che il tuo dominio è isolato invece di affermarlo, eseguendolo con un classpath che contiene solo i tuoi bytecode: se parte, non dipende da niente, e il giorno in cui qualcuno aggiunge una dipendenza il comando te lo dice in settanta millisecondi.
Resta aperta la domanda che il dominio da solo non può risolvere: come fa un dominio che non conosce il database a leggere dal database. Il prossimo articolo della serie apre la porta e l'adattatore, con il codice reale che collega un record di dominio a una tabella PostgreSQL senza che il record sappia che PostgreSQL esiste.
Commenti
Caricamento commenti...
Accedi per commentare