Apri un progetto Spring Boot e trovi le classi User e Article piene di getter e setter, e la logica in tre Service diversi: lo stesso controllo sull'email è scritto in quattro punti, e quando cambia una regola non sai quanti ne hai dimenticati. Il domain layer è la risposta della Clean Architecture a questo problema: un insieme di classi che contengono le regole di business, non dipendono da Spring né da JPA e si provano con una chiamata di metodo. In questo articolo costruiamo quel layer con i record di Java 21, tutti i listati sono compilati ed eseguiti su OpenJDK 21.0.13, e confrontiamo i risultati con il backend di questo sito, misurato nel codice. Sul perché sia diverso dall'«entity anemica» c'è un passo di Martin Fowler che vale la pena leggere: Anemic Domain Model sostiene che un modello senza comportamento paga tutti i costi di un dominio (soprattutto il mapping verso il database) senza averne i benefici.
Che cosa c'è nel domain layer
Il dominio contiene i concetti del business e le regole che li legano. La tabella dice che cosa va dove, e soprattutto che cosa non vi entra: niente annotazioni di framework, niente I/O, niente orologio letto da dentro (l'istante si passa come parametro, per rendere il codice provabile).
| Elemento | Che cos'è | Esempio |
|---|---|---|
| Entity | Ha un'identità che resta uguale nel tempo, anche se gli attributi cambiano | User, Article |
| Value object | Non ha identità: è definito dal suo valore, è immutabile e si valida alla costruzione | Email, Money, Slug |
| Port | Interfaccia verso l'esterno, dichiarata nel dominio e implementata in infrastructure | UserRepository, EmailSender |
| Domain event | Un fatto accaduto nel dominio, a cui altri possono reagire | ArticlePublished |
La distinzione che sbaglia più spesso chi comincia è quella fra entity e value object, e porta conseguenze pratiche: due email con lo stesso testo sono la stessa email (value object, uguaglianza per valore), due utenti con lo stesso nome non sono lo stesso utente (entity, uguaglianza per identità). Come si implementa lo vedremo con il codice, perché il record di Java, per come è definito, favorisce il primo caso e va corretto per il secondo. I port li approfondiamo in JPA e Flyway in Clean Architecture.
Value object: validare alla nascita
Il vantaggio di un value object è che un oggetto non valido non esiste. Se Email controlla il formato nel costruttore compatto, ogni metodo che riceve un Email può darne per scontata la validità e non ripete il controllo. Il listato ha tre value object, ciascuno con una scelta da spiegare. Email normalizza in minuscolo dopo la validazione, perché Test@Example.COM e test@example.com devono essere lo stesso valore. Money imposta la scala in base alla valuta (setScale con HALF_UP): senza, 10 e 10.00 risultano diversi. Slug ha una fabbrica fromTitle che toglie gli accenti con Normalizer prima di scartare i caratteri non ammessi.
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.Normalizer;
import java.util.Currency;
import java.util.Objects;
import java.util.regex.Pattern;
record Email(String value) {
private static final Pattern FORMAT = Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$");
Email {
if (value == null || value.isBlank()) throw new IllegalArgumentException("Email vuota");
if (!FORMAT.matcher(value).matches()) throw new IllegalArgumentException("Email non valida: " + value);
value = value.toLowerCase(); // normalizzazione: due scritture, un solo valore
}
}
record Money(BigDecimal amount, Currency currency) {
Money {
Objects.requireNonNull(amount);
Objects.requireNonNull(currency);
if (amount.signum() < 0) throw new IllegalArgumentException("Importo negativo");
amount = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.HALF_UP);
}
Money add(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("Valute diverse: " + currency + " + " + other.currency);
}
return new Money(amount.add(other.amount), currency);
}
}
record Slug(String value) {
private static final Pattern FORMAT = Pattern.compile("^[a-z0-9]+(-[a-z0-9]+)*$");
Slug {
if (value == null || value.length() > 100 || !FORMAT.matcher(value).matches()) {
throw new IllegalArgumentException("Slug non valido: " + value);
}
}
static Slug fromTitle(String title) {
String senzaAccenti = Normalizer.normalize(title, Normalizer.Form.NFD).replaceAll("\\p{M}+", "");
String pulito = senzaAccenti.toLowerCase()
.replaceAll("[^a-z0-9\\s-]", "")
.trim()
.replaceAll("[\\s-]+", "-");
String troncato = pulito.length() > 100 ? pulito.substring(0, 100) : pulito;
return new Slug(troncato.replaceAll("^-+|-+$", ""));
}
}
Tre dettagli di questo codice nascono da errori che gli esempi tipici contengono. Il primo riguarda Money: la documentazione di BigDecimal dice che equals richiede lo stesso valore e la stessa rappresentazione, cioè la stessa scala, quindi un record con un BigDecimal non normalizzato eredita questa sorpresa (il confronto è nel programma qui sotto). Il secondo riguarda il formato di Slug: l'espressione ammette solo gruppi di lettere o cifre separati da un trattino singolo, che esclude per costruzione trattini ai bordi e doppi trattini, senza un secondo controllo. Il terzo è la validazione di Email con un'espressione regolare semplificata: va bene per escludere il testo palesemente sbagliato, ma l'unica verifica che un indirizzo esiste è inviargli un messaggio.
Entity: l'identità non è l'insieme dei campi
Un record genera equals, hashCode e toString a partire da tutti i componenti: lo dice la documentazione di java.lang.Record. Per un value object è il comportamento giusto. Per un'entity no, per due motivi pratici. Primo: due istanze dello stesso utente con un lastLoginAt diverso risulterebbero diverse, e in un HashSet o come chiave di mappa comparirebbero due volte. Secondo: il toString generato stampa anche la password hashata, che finisce nei log alla prima log.info("utente {}", user). Il listato mette a confronto UserNaive (il record con i default) e User (con equals sull'id e toString selettivo), e definisce Article con l'operazione di pubblicazione.
import java.time.Instant;
import java.util.List;
import java.util.UUID;
record UserId(UUID value) {}
// Record "ingenuo": equals/hashCode/toString generati su TUTTI i componenti.
record UserNaive(UserId id, Email email, String hashedPassword, Instant lastLoginAt) {}
// Entita': l'identita' e' l'id, non l'insieme dei campi.
record User(UserId id, Email email, String hashedPassword, Instant lastLoginAt) {
@Override public boolean equals(Object o) { return o instanceof User other && id.equals(other.id); }
@Override public int hashCode() { return id.hashCode(); }
@Override public String toString() { return "User[id=" + id.value() + ", email=" + email.value() + "]"; }
User withLastLogin(Instant when) { return new User(id, email, hashedPassword, when); }
}
interface DomainEvent {}
record ArticlePublished(String slug, Instant occurredAt) implements DomainEvent {}
enum PublishStatus { DRAFT, PUBLISHED }
record PublishResult(Article article, List<DomainEvent> events) {}
record Article(UUID id, Slug slug, String title, UserId authorId, PublishStatus status, Instant publishedAt) {
static Article draft(String title, UserId author) {
return new Article(UUID.randomUUID(), Slug.fromTitle(title), title, author, PublishStatus.DRAFT, null);
}
// Un record non puo' accumulare eventi in un campo: il risultato dell'operazione li porta con se'.
PublishResult publish(Instant now) {
if (status == PublishStatus.PUBLISHED) throw new IllegalStateException("Articolo gia' pubblicato");
Article published = new Article(id, slug, title, authorId, PublishStatus.PUBLISHED, now);
return new PublishResult(published, List.of(new ArticlePublished(slug.value(), now)));
}
}
Nota come Article.publish non può «cambiare lo stato»: i record sono immutabili, e il metodo restituisce un PublishResult che contiene il nuovo articolo e gli eventi prodotti. Un record non può avere campi d'istanza fuori dai componenti, quindi non può nemmeno accumulare gli eventi in una lista interna, come si farebbe in una classe; far restituire gli eventi dall'operazione è la soluzione più semplice. La regola «non si pubblica due volte» sta nell'entity, non in un service, e per questo si prova senza avviare nulla.
Che cosa si osserva eseguendolo
Il programma esercita tutto in un solo main: normalizzazione dell'email, i due tipi di utente in un Set, il toString, la scala di Money, due modi di costruire uno slug e la pubblicazione. L'uscita è identica in due esecuzioni consecutive, perché l'UUID e gli istanti sono fissi e nulla dipende dall'ora.
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Currency;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
public class DomainDemo {
// Versione ingenua: toglie ogni carattere fuori da [a-z0-9], accenti compresi.
static String slugIngenuo(String title) {
return title.toLowerCase().replaceAll("[^a-z0-9\\s-]", "").replaceAll("\\s+", "-").replaceAll("-+", "-");
}
public static void main(String[] args) {
UserId id = new UserId(UUID.fromString("00000000-0000-0000-0000-000000000001"));
Email email = new Email("Test@Example.COM");
System.out.println("email normalizzata: " + email.value());
// 1. equals: stesso id, ultimo accesso diverso
Instant t1 = Instant.parse("2026-11-01T10:00:00Z"), t2 = Instant.parse("2026-11-02T10:00:00Z");
Set<UserNaive> naive = new HashSet<>(Set.of(new UserNaive(id, email, "$2a$12$HASHFINTO", t1)));
naive.add(new UserNaive(id, email, "$2a$12$HASHFINTO", t2));
Set<User> entities = new HashSet<>(Set.of(new User(id, email, "$2a$12$HASHFINTO", t1)));
entities.add(new User(id, email, "$2a$12$HASHFINTO", t2));
System.out.println("record ingenuo nel Set: " + naive.size() + " | entity con equals su id: " + entities.size());
// 2. toString e dati sensibili
System.out.println(new UserNaive(id, email, "$2a$12$HASHFINTO", t1));
System.out.println(new User(id, email, "$2a$12$HASHFINTO", t1));
// 3. Money: la scala di BigDecimal
Currency eur = Currency.getInstance("EUR");
System.out.println("BigDecimal 10 vs 10.00 uguali? " + new BigDecimal("10").equals(new BigDecimal("10.00")));
System.out.println("Money 10 vs 10.00 uguali? " + new Money(new BigDecimal("10"), eur).equals(new Money(new BigDecimal("10.00"), eur)));
System.out.println("somma: " + new Money(new BigDecimal("0.1"), eur).add(new Money(new BigDecimal("0.2"), eur)));
try { new Money(BigDecimal.ONE, eur).add(new Money(BigDecimal.ONE, Currency.getInstance("USD"))); }
catch (IllegalArgumentException e) { System.out.println(e.getMessage()); }
// 4. Slug da un titolo italiano
System.out.println(slugIngenuo("Perché la Clean Architecture è utile? Davvero!"));
System.out.println(Slug.fromTitle("Perché la Clean Architecture è utile? Davvero!").value());
try { Slug.fromTitle("???"); } catch (IllegalArgumentException e) { System.out.println(e.getMessage()); }
// 5. Pubblicazione: nuova istanza + eventi
Article bozza = Article.draft("Domain layer puro", id);
PublishResult r = bozza.publish(t2);
System.out.println(bozza.status() + " -> " + r.article().status() + ", eventi: " + r.events());
try { r.article().publish(t2); } catch (IllegalStateException e) { System.out.println(e.getMessage()); }
}
}
// email normalizzata: test@example.com
// record ingenuo nel Set: 2 | entity con equals su id: 1
// UserNaive[id=UserId[value=00000000-0000-0000-0000-000000000001], email=Email[value=test@example.com], hashedPassword=$2a$12$HASHFINTO, lastLoginAt=2026-11-01T10:00:00Z]
// User[id=00000000-0000-0000-0000-000000000001, email=test@example.com]
// BigDecimal 10 vs 10.00 uguali? false
// Money 10 vs 10.00 uguali? true
// somma: Money[amount=0.30, currency=EUR]
// Valute diverse: EUR + USD
// perch-la-clean-architecture-utile-davvero
// perche-la-clean-architecture-e-utile-davvero
// Slug non valido:
// DRAFT -> PUBLISHED, eventi: [ArticlePublished[slug=domain-layer-puro, occurredAt=2026-11-02T10:00:00Z]]
// Articolo gia' pubblicato
Le righe da leggere sono quattro. Il record ingenuo conta due utenti nel Set e l'entity con equals sull'id ne conta uno: è l'effetto della scelta di identità. Il toString del record ingenuo contiene la riga hashedPassword=...; quello dell'entity no. Il secondo confronto mostra che BigDecimal("10") e BigDecimal("10.00") sono diversi, mentre i due Money corrispondenti sono uguali, grazie alla scala imposta dal costruttore. E lo slug costruito con la versione ingenua da «Perché la Clean Architecture è utile? Davvero!» perde le lettere accentate (perch-la-clean-architecture-utile-davvero), mentre quello con Normalizer mantiene perche e e. Per un sito in italiano non è un dettaglio: lo slug è un indirizzo pubblico.
Dove vive la logica: nel dominio, non nel service
La regola che motiva tutto il layer è che il controllo sta dove sta il dato. Se Article.publish verifica lo stato, ogni percorso che pubblica (un endpoint REST, un job, un'importazione) passa dallo stesso controllo; se lo verifica un service, ogni nuovo percorso deve ricordarsi di farlo. Il service di applicazione (il caso d'uso) resta il coordinatore: carica, chiama il dominio, salva, pubblica gli eventi. Lo vedi in Use Case Pattern in Spring Boot.
Un limite che conviene dire: il dominio non sostituisce i vincoli del database. Se qualcuno scrive direttamente in SQL, le regole di Email o Article non vengono eseguite. Per gli invarianti che contano davvero servono anche UNIQUE, CHECK e chiavi esterne nello schema: sono due livelli di difesa, non uno al posto dell'altro.
Aggregati e eventi: tenerli piccoli
Un aggregato è un gruppo di oggetti che devono restare coerenti nella stessa transazione, con un'entity radice come unico punto di accesso. L'errore frequente è includere nell'aggregato tutto ciò che è collegato: un utente che contiene la lista di tutti i suoi articoli obbliga a caricarli per pubblicarne uno. Qui Article è un aggregato a sé e punta all'autore per identificativo (UserId), non per riferimento: pubblicare un articolo tocca un solo articolo. Nel dominio basta il criterio: tieni insieme solo ciò che deve rispettare un invariante nella stessa transazione.
Gli eventi servono per far reagire altre parti senza accoppiarle. Quando un articolo viene pubblicato, l'invio di una notifica è una conseguenza, non parte della regola. In Spring si ascolta l'evento con @TransactionalEventListener: la documentazione di Spring Framework precisa che la fase predefinita è AFTER_COMMIT e che, se non c'è una transazione in corso, l'ascoltatore non viene invocato affatto (a meno di fallbackExecution). Con un semplice @EventListener, invece, la reazione parte durante la transazione, prima del commit: un annullamento successivo lascia un effetto già avvenuto.
// Application layer (schema da adattare, non eseguito: richiede Spring)
@Component
class SendWelcomeEmailOnPublished {
private final EmailSender emailSender; // port del dominio
SendWelcomeEmailOnPublished(EmailSender emailSender) { this.emailSender = emailSender; }
// AFTER_COMMIT e' la fase predefinita: la mail parte solo se la transazione e' andata a buon fine.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void on(ArticlePublished event) {
emailSender.notifyPublished(event.slug());
}
}
Il listener è uno schema da adattare e non è stato eseguito: richiede Spring. Il costo da conoscere è che con AFTER_COMMIT l'invio è best effort: se il processo cade subito dopo il commit, l'evento è perso. Per garanzie forti si usa una tabella outbox scritta nella stessa transazione. Il backend di questo sito usa questa fase in un caso reale, il listener che invia le email di assegnazione delle issue, che aggiunge @Async e cattura le eccezioni SMTP senza propagarle (IssueAssignedEmailListener, letto nel sorgente).
Quanto è puro il dominio del backend di questo sito
Il package domain del backend contiene 146 file Java, su 3741 in src/main (misurati il 2026-11-12 con find ... -name '*.java' | wc -l). Una regola ArchUnit, CleanArchitectureTest.domainDoesNotDependOnFrameworks_frozen, vieta al dominio le dipendenze da Spring e JPA. È congelata: le violazioni storiche sono registrate in un archivio (archunit-store) e non fanno fallire la build, mentre ogni violazione nuova sì. L'archivio elenca oggi sei righe, riferite a tre classi: TermSheetAnalyzer, annotata con lo stereotipo Spring @Component, e due port di repository, ProposalRepository e QuoteRepository, che usano Page e Pageable di Spring Data nelle firme (il nome della regola, scritto nel luglio 2026, parla di quattro violazioni: l'archivio conta righe, non classi). Per la spiegazione del meccanismo, vedi il gate ArchUnit.
Cosa dice questo del principio: «dominio puro» è un obiettivo con un debito dichiarato e misurabile, non un'etichetta. Definire un proprio tipo di paginazione costa codice e mapping, e usare quello di Spring Data costa un'importazione nel dominio; il progetto ha scelto la seconda strada nei casi elencati e ha bloccato il peggioramento. Altri due dettagli confermano le scelte di questo articolo: la classe User del dominio (domain/model/User.java) è una classe, non un record, confronta per email ed esclude l'hash dal toString; il record ImportoVoce del modulo di economia dei progetti dichiara nel suo Javadoc l'arrotondamento HALF_UP a due decimali.
Il costo della doppia modellazione
Dominio puro e modello di persistenza separati significano due classi per concetto e un mapper da mantenere allineato a ogni nuova colonna: un campo dimenticato nel mapper è un bug silenzioso. Il beneficio è che il dominio non eredita i vincoli di JPA (costruttore senza argomenti, campi mutabili, proxy lazy). Con un dominio piccolo e senza regole complesse, usare l'entity JPA come modello è una scelta difendibile; la separazione paga quando le regole sono molte e il modello persistente tende a divergere da quello di business. Il livello infrastructure della serie, con JPA e Redis, è in Infrastructure layer: JPA e Redis. Per provare il dominio, vedi Testing in Clean Architecture.
Domande frequenti
Devo usare record per tutte le entity?
Per i value object sì, quasi sempre. Per le entity il record funziona se sovrascrivi equals, hashCode e toString come visto sopra; se l'entity ha molto comportamento o stato che evolve, una classe con campi finali e metodi di modifica che restituiscono una copia è un'alternativa legittima, ed è quella scelta dal User del backend.
Posso saltare i value object in un dominio semplice?
Sì, quando un'Email è usata in un solo punto e non ha regole, una String basta. Appena le regole compaiono in due punti, il value object costa poche righe e toglie il rischio di una regola dimenticata. Cominciare con la stringa e promuovere a value object al secondo uso è una strategia ragionevole.
Chi chiama Instant.now()?
Il caso d'uso o un'interfaccia Clock iniettata, non l'entity. Se il dominio legge l'orologio, ogni test dipende dall'ora; passando l'istante come parametro, come in publish(Instant now), l'uscita è sempre la stessa.
Che cosa sai fare adesso
Sai scrivere un value object che non nasce invalido e riconoscere le tre sorprese di record (uguaglianza su tutti i componenti, toString che espone i dati, scala di BigDecimal). Sai distinguere entity e value object dal modo in cui si confrontano, tenere piccoli gli aggregati e far restituire gli eventi dall'operazione. E sai dire quanto è puro un dominio misurandolo, come fa la regola ArchUnit del backend, invece di affermarlo. Il passo successivo è collegare il dominio alla persistenza in JPA e Flyway, e il quadro d'insieme è in Clean Architecture in Spring Boot 3.4.
Fonti
- Martin Fowler - Anemic Domain Model - il costo di un modello senza comportamento; consultata il 2026-11-12
- Java SE 21 API - java.lang.Record -
equals,hashCode,toStringderivati da tutti i componenti; consultata il 2026-11-12 - Java SE 21 API - java.math.BigDecimal -
equalsrichiede stesso valore e stessa scala; consultata il 2026-11-12 - Spring Framework Reference - Transaction-bound Events - fase
AFTER_COMMITpredefinita e assenza di invocazione senza transazione; consultata il 2026-11-12 - Robert C. Martin - The Clean Architecture - la regola di dipendenza verso il dominio; consultata il 2026-11-12
- Sorgenti del backend:
CleanArchitectureTest.java,archunit-store,domain/model/User.java,ImportoVoce.java,IssueAssignedEmailListener.java- letti il 2026-11-12

Commenti
Caricamento commenti...
Accedi per commentare