Hai un modello di dominio con le sue regole, per esempio «un articolo si pubblica una volta sola», e poi arrivano JPA e Hibernate con le loro annotazioni, i costruttori vuoti e i setter. La via più breve è mettere @Entity sulla classe del dominio e proseguire. Funziona, finché la regola di business e la forma della tabella cambiano per ragioni diverse e si ritrovano nello stesso file. La Clean Architecture propone di tenere due modelli, uno puro nel dominio e uno di persistenza nell'infrastructure, collegati da un mapper e da un adapter che implementa un'interfaccia (il port) dichiarata nel dominio. In questo articolo costruiamo quel meccanismo in un programma che compila e gira senza Spring, e poi guardiamo come lo fa il backend di questo sito e come Flyway versiona lo schema, con una prova eseguita su un database in memoria. Prerequisito: sapere che cos'è un layer; il domain layer è l'articolo precedente della serie.
Perché due modelli e non uno
Un'entità JPA deve avere un costruttore senza argomenti e campi mutabili, perché Hibernate la istanzia e la popola da sé. Un'entità di dominio che vuole garantire le proprie invarianti ha il vincolo opposto: si costruisce solo in stati validi e non cambia sotto i piedi di chi la usa. Le due pretese si contraddicono, e l'unico modo di soddisfarle entrambe in una stessa classe è indebolire quella del dominio. Il costo concreto si vede nei test: una regola che vive in una classe annotata JPA si prova più facilmente con un test d'integrazione, mentre una classe pura si prova con una chiamata di metodo.
Il prezzo della separazione è altrettanto concreto: per ogni entità ci sono due classi e un mapper da scrivere e mantenere, e un campo nuovo va aggiunto in tre punti. Per una tabella di configurazione a una sola riga non vale la pena, e infatti nel backend di questo sito l'adapter delle impostazioni pubblicitarie non ha nessuna entità JPA (lo vediamo più sotto). La separazione paga quando il dominio ha regole vere; dove c'è solo un contenitore di dati, un record e una query bastano.
Il dominio puro
Il listato definisce i value object (ArticleId, Title), lo stato (PublicationStatus), l'entità Article e il port ArticleRepository. Nessuna riga importa Spring o JPA. Due scelte meritano una spiegazione. La prima: Title valida nel costruttore compatto, quindi un titolo vuoto non esiste come oggetto e nessun livello superiore deve ricordarsi di controllarlo. La seconda riguarda publish: un record è immutabile, per cui il metodo non può «cambiare lo stato» di this; restituisce una nuova istanza con stato PUBLISHED. È il difetto più comune quando si scrive un metodo di questo tipo su un record: un void publish() che calcola il nuovo stato e poi lo butta via compila senza avvisi e non fa niente.
import java.time.Instant;
import java.util.*;
// Layer domain: nessuna annotazione, nessun import da framework.
record ArticleId(UUID value) {
ArticleId { Objects.requireNonNull(value); }
static ArticleId generate() { return new ArticleId(UUID.randomUUID()); }
}
record Title(String value) {
Title {
if (value == null || value.isBlank()) throw new IllegalArgumentException("Title cannot be empty");
if (value.length() > 255) throw new IllegalArgumentException("Title too long");
}
}
enum PublicationStatus { DRAFT, PUBLISHED, ARCHIVED }
class ArticleAlreadyPublishedException extends RuntimeException {
ArticleAlreadyPublishedException(String m) { super(m); }
}
record Article(ArticleId id, Title title, PublicationStatus status, Instant publishedAt) {
static Article draft(Title title) {
return new Article(ArticleId.generate(), title, PublicationStatus.DRAFT, null);
}
// Un record e' immutabile: publish() non puo' cambiare 'this', restituisce un'altra istanza.
Article publish(Instant now) {
if (status != PublicationStatus.DRAFT) {
throw new ArticleAlreadyPublishedException("Articolo gia' pubblicato: " + id.value());
}
return new Article(id, title, PublicationStatus.PUBLISHED, now);
}
}
// Il port: interfaccia del dominio, implementata fuori.
interface ArticleRepository {
Optional<Article> findById(ArticleId id);
Article save(Article article);
}
Il port ArticleRepository ha le sole operazioni che il dominio vuole, nei suoi tipi: prende e restituisce Article e ArticleId, mai righe o entità JPA. Questo è il punto che inverte la dipendenza: il dominio dichiara ciò di cui ha bisogno, e l'infrastructure si adatta. Nota anche che publish riceve l'istante come parametro invece di chiamare Instant.now(): così il test non dipende dall'orologio e la sua uscita è sempre la stessa.
Lato infrastructure: riga, mapper, adapter
ArticleRow è il sostituto, in questo programma, dell'entità JPA: una classe mutabile con tipi «da tabella» (String per l'identificativo, Instant, nome dello stato come testo) e nessuna regola. Con JPA la stessa classe porterebbe @Entity, @Id e @Column; non lo mostro eseguito perché richiederebbe Hibernate e un database, e il resto del meccanismo non cambierebbe. Il mapper converte nei due sensi e, leggendo, ricostruisce i value object: se la riga contiene dati che il dominio rifiuta, l'errore emerge in quel punto invece di propagarsi.
import java.time.Instant;
import java.util.*;
// Layer infrastructure. ArticleRow fa le veci della @Entity JPA: classe mutabile,
// solo tipi "da tabella" (String, Instant), nessuna regola di business.
class ArticleRow {
String id;
String title;
String status;
Instant publishedAt;
}
class ArticleMapper {
Article toDomain(ArticleRow r) {
return new Article(new ArticleId(UUID.fromString(r.id)), new Title(r.title),
PublicationStatus.valueOf(r.status), r.publishedAt);
}
ArticleRow toRow(Article a) {
ArticleRow r = new ArticleRow();
r.id = a.id().value().toString();
r.title = a.title().value();
r.status = a.status().name();
r.publishedAt = a.publishedAt();
return r;
}
}
// L'adapter implementa il port. Qui la "tabella" e' una Map; con JPA al suo posto
// ci sarebbe un JpaRepository<ArticleEntity, ...>, e il resto resterebbe uguale.
class InMemoryArticleAdapter implements ArticleRepository {
final Map<String, ArticleRow> table = new HashMap<>();
private final ArticleMapper mapper = new ArticleMapper();
public Optional<Article> findById(ArticleId id) {
return Optional.ofNullable(table.get(id.value().toString())).map(mapper::toDomain);
}
public Article save(Article article) {
ArticleRow row = mapper.toRow(article);
table.put(row.id, row);
return mapper.toDomain(row);
}
}
L'adapter implementa il port e tiene il mapper privato. Qui la tabella è una Map; sostituirla con un JpaRepository cambia solo il corpo dei due metodi. Dettaglio da non trascurare: save restituisce l'oggetto riletto dalla riga tramite il mapper, non quello ricevuto, così il chiamante vede ciò che è stato davvero persistito (con un database, anche i valori generati dalla base).
Il caso d'uso, e che cosa si osserva
Il caso d'uso PublishArticleUseCase è nel layer application: dipende dal port, non dall'adapter, e orchestra tre passi (carica, applica la regola, salva). Nel main lo uso come lo userebbe un controller: salvo una bozza, la pubblico, provo a pubblicarla di nuovo, e infine inserisco a mano nella «tabella» una riga con titolo vuoto, per vedere cosa accade quando i dati persistiti violano il dominio.
import java.time.Instant;
// Layer application: conosce il port, non l'adapter.
class PublishArticleUseCase {
private final ArticleRepository repository;
PublishArticleUseCase(ArticleRepository repository) { this.repository = repository; }
Article execute(ArticleId id, Instant now) {
Article article = repository.findById(id)
.orElseThrow(() -> new IllegalStateException("Articolo non trovato: " + id.value()));
return repository.save(article.publish(now));
}
}
public class Demo {
public static void main(String[] args) {
InMemoryArticleAdapter adapter = new InMemoryArticleAdapter();
PublishArticleUseCase useCase = new PublishArticleUseCase(adapter);
Instant now = Instant.parse("2026-11-12T10:00:00Z");
Article bozza = adapter.save(Article.draft(new Title("Domain puro")));
System.out.println("salvata: " + bozza.status());
Article pubblicata = useCase.execute(bozza.id(), now);
System.out.println("pubblicata: " + pubblicata.status() + " il " + pubblicata.publishedAt());
System.out.println("la bozza originale e' cambiata? " + bozza.status());
try {
useCase.execute(bozza.id(), now);
} catch (ArticleAlreadyPublishedException e) {
System.out.println("secondo publish: " + e.getMessage().replaceAll("[0-9a-f-]{36}", "<uuid>"));
}
// Una riga corrotta in tabella non entra nel dominio: il value object la rifiuta.
ArticleRow corrotta = new ArticleRow();
corrotta.id = java.util.UUID.randomUUID().toString();
corrotta.title = " ";
corrotta.status = "DRAFT";
adapter.table.put(corrotta.id, corrotta);
try {
adapter.findById(new ArticleId(java.util.UUID.fromString(corrotta.id)));
} catch (IllegalArgumentException e) {
System.out.println("lettura riga corrotta: " + e.getMessage());
}
}
}
// salvata: DRAFT
// pubblicata: PUBLISHED il 2026-11-12T10:00:00Z
// la bozza originale e' cambiata? DRAFT
// secondo publish: Articolo gia' pubblicato: <uuid>
// lettura riga corrotta: Title cannot be empty
L'uscita ha quattro letture. La bozza originale resta DRAFT dopo la pubblicazione: ne è nata una seconda istanza, e chi teneva un riferimento alla prima non vede cambiamenti di nascosto. Il secondo publish fallisce con l'eccezione di dominio, e la regola è provata senza avviare niente: è una chiamata di metodo. Infine la riga corrotta viene respinta dal value object al momento della lettura. Il programma è deterministico (identico in due esecuzioni consecutive; l'UUID è sostituito con un segnaposto nel messaggio proprio per questo).
Come lo fa il backend di questo sito
Il backend ha un port ArticleRepository nel package domain.port e un ArticleRepositoryAdapter in infrastructure.persistence.adapter, che usa un repository Spring Data e un mapper. Il mapper non è scritto a mano: è un'interfaccia MapStruct, uno strumento che genera il codice di conversione a compilazione (documentazione). Dove la conversione non è ovvia, per esempio lo stato che in tabella è un testo minuscolo e nel dominio un enum, si dichiara un convertitore con nome (qualifiedByName). Il listato è un estratto che ho letto dal sorgente, non un programma eseguibile.
// domain/port/ArticleRepository.java (estratto): nessun import da JPA
public interface ArticleRepository {
Optional<Article> findById(Long id);
Optional<Article> findBySlug(String slug);
// ...
}
// infrastructure/persistence/adapter/ArticleRepositoryAdapter.java (estratto)
@Repository
@Transactional(readOnly = true)
public class ArticleRepositoryAdapter implements ArticleRepository {
private final JpaArticleRepository jpaArticle; // Spring Data
private final ArticleMapper mapper; // MapStruct
@Override
public Optional<Article> findById(Long id) {
return jpaArticle.findById(id.intValue()).map(mapper::toArticle);
}
// ...
}
// infrastructure/persistence/mapper/ArticleMapper.java (estratto)
@Mapper
public interface ArticleMapper {
@Mapping(target = "id", source = "id", qualifiedByName = "articleIdToLong")
@Mapping(target = "status", source = "status", qualifiedByName = "stringToArticleStatus")
Article toArticle(ArticleEntity entity);
// ...
}
Due osservazioni su ciò che il sorgente mostra davvero. L'adapter è annotato @Transactional(readOnly = true): la transazione è dichiarata sull'adapter di persistenza, non nel dominio, e le scritture la ridefiniscono. E non tutti gli adapter del backend usano JPA: AdsSettingsRepositoryAdapter, per una tabella a riga unica, usa JdbcTemplate con una query e un RowMapper, senza entità. È la stessa architettura con meno cerimonia, e il port nel dominio è identico. La regola non è «sempre un'entità JPA separata», è «il dominio non conosce la tecnologia».
Sul mapper manuale contro MapStruct: quello a mano, come nel programma sopra, è ovvio da leggere e fallisce a runtime se dimentichi un campo; MapStruct fallisce (o avvisa) a compilazione sui campi non mappati, e si paga con un annotation processor nel build. Con record dal costruttore compatto e value object annidati la generazione richiede più convertitori di quanto sembri, quindi non è un risparmio automatico.
Flyway: lo schema ha una storia
Flyway applica i file SQL di una cartella in ordine di versione e ricorda in una tabella (flyway_schema_history) quali ha già eseguito. Il nome del file è il contratto: V, il numero di versione, due underscore, la descrizione. Ogni migration versionata si esegue una volta sola per database, e la documentazione di Flyway precisa che le repeatable sono invece rieseguite quando cambiano (Migrations). Il listato mostra due migration minime.
-- V1__create_articles.sql
CREATE TABLE articles (
id BIGINT PRIMARY KEY,
slug VARCHAR(300) NOT NULL UNIQUE,
status VARCHAR(20) NOT NULL DEFAULT 'draft'
);
-- V2__add_published_at.sql
ALTER TABLE articles ADD COLUMN published_at TIMESTAMP WITH TIME ZONE;
La configurazione del backend è nello application.yml. Ogni proprietà qui sotto ha un valore predefinito diverso o meno ovvio, quindi vale la pena sapere cosa si è scelto. ddl-auto: validate fa controllare a Hibernate che le entità corrispondano alle tabelle, ma non le crea: il valore predefinito di Spring Boot è none (o create-drop con un database incorporato e nessuno strumento di migrazione), come riporta l'appendice delle proprietà. baseline-on-migrate vale false per default, e lasciarlo così significa che un database non vuoto senza storico fa fallire l'avvio invece di essere dichiarato «versione 1». out-of-order: true è una scelta del progetto: ammette una migration con versione più bassa di quelle già applicate, perché in questo repo le versioni sono assegnate in intervalli riservati a lavori paralleli (il commento nel file lo spiega).
spring:
jpa:
hibernate:
ddl-auto: validate # Hibernate controlla lo schema, non lo crea
open-in-view: false
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: false # un database non vuoto senza storico e' un errore, non un caso da "sistemare"
validate-on-migrate: true
out-of-order: true # applica anche migration con versione piu' bassa arrivate in ritardo
L'out-of-order ha un costo che il commento non dice: due ambienti possono applicare le stesse migration in ordine diverso, e se due di esse toccano lo stesso oggetto il risultato dipende dall'ordine. Va bene quando le migration sono indipendenti tra loro. Nel repository le migration vanno da V001 a V240 e sono 228 file (misurato il 2026-11-12 con ls backend/src/main/resources/db/migration | wc -l): la numerazione non è contigua, ed è la conseguenza diretta di quegli intervalli.
Una migration applicata non si modifica
Flyway registra un checksum del contenuto di ogni migration applicata e lo confronta a ogni avvio. Il programma applica le due migration a un database H2 in memoria (modalità PostgreSQL) con Flyway 10.20.1, lo rilancia senza novità, poi aggiunge un commento al file della versione 2 e riprova. Il risultato mostra le tre cose che servono sapere.
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.output.MigrateResult;
import java.nio.file.*;
public class FlywayDemo {
static Flyway flyway() {
return Flyway.configure()
.dataSource("jdbc:h2:mem:demo;MODE=PostgreSQL;DB_CLOSE_DELAY=-1", "sa", "")
.locations("filesystem:mig")
.load();
}
public static void main(String[] args) throws Exception {
MigrateResult r1 = flyway().migrate();
System.out.println("1a esecuzione: applicate " + r1.migrationsExecuted);
MigrateResult r2 = flyway().migrate();
System.out.println("2a esecuzione: applicate " + r2.migrationsExecuted);
// Modifico una migration gia' applicata
Path v2 = Path.of("mig/V2__add_published_at.sql");
String originale = Files.readString(v2);
Files.writeString(v2, originale + "-- ritocco\n");
try {
flyway().migrate();
} catch (Exception e) {
System.out.println(e.getClass().getSimpleName() + ": " + e.getMessage().lines().findFirst().orElse(""));
e.getMessage().lines().skip(1).filter(l -> l.contains("Resolved") || l.contains("Applied") || l.contains("Migration")).forEach(System.out::println);
} finally {
Files.writeString(v2, originale);
}
}
}
// 1a esecuzione: applicate 2
// 2a esecuzione: applicate 0
// FlywayValidateException: Validate failed: Migrations have failed validation
// Migration checksum mismatch for migration version 2
// -> Applied to database : 1955181868
// -> Resolved locally : 846718191
La seconda esecuzione non applica nulla: è questa l'idempotenza che conta, ed è fornita dalla tabella storico, non dalle clausole IF NOT EXISTS messe nei file. Quelle clausole non servono a rendere rieseguibile una migration, e anzi possono nascondere un errore, per esempio una tabella già presente con una forma diversa da quella attesa. La terza esecuzione mostra il rifiuto: anche un solo commento aggiunto a una migration già applicata cambia il checksum, e Flyway si ferma. I due numeri sono i checksum calcolati sul file (stabili: identici in due esecuzioni). La regola pratica è che una migration pubblicata non si tocca; si scrive la successiva. Per l'annullamento, la stessa pagina di documentazione descrive le migration di undo come una categoria separata, che non si improvvisa riscrivendo un file.
Aggiungere un vincolo NOT NULL a una tabella con dati
Aggiungere in un colpo solo una colonna NOT NULL a una tabella che contiene righe fallisce, perché le righe esistenti non hanno un valore. La via sicura si fa in tre migration: aggiungere la colonna ammettendo il null, riempire le righe esistenti, e solo dopo imporre il vincolo. Tenerle separate fa sì che se il riempimento fallisce il vincolo non sia stato ancora tentato, e che ogni passo sia leggibile da solo.
-- V3__add_view_count_nullable.sql
ALTER TABLE articles ADD COLUMN view_count INTEGER;
-- V4__backfill_view_count.sql
UPDATE articles SET view_count = 0 WHERE view_count IS NULL;
-- V5__view_count_not_null.sql
ALTER TABLE articles ALTER COLUMN view_count SET NOT NULL;
ALTER TABLE articles ALTER COLUMN view_count SET DEFAULT 0;
La prova: il programma porta lo schema alla versione 2 (target("2")), inserisce una riga come se fosse un dato di produzione, poi lascia che Flyway applichi le versioni 3, 4 e 5. Legge la riga e prova a inserirne una con view_count nullo.
import org.flywaydb.core.Flyway;
import java.sql.*;
public class Expand {
static final String URL = "jdbc:h2:mem:expand;MODE=PostgreSQL;DB_CLOSE_DELAY=-1";
public static void main(String[] args) throws Exception {
Flyway.configure().dataSource(URL, "sa", "").locations("filesystem:mig3")
.target("2").load().migrate(); // schema alla v2
try (Connection c = DriverManager.getConnection(URL, "sa", "");
Statement s = c.createStatement()) {
s.execute("INSERT INTO articles (id, slug) VALUES (1, 'vecchio-articolo')"); // dato esistente
}
Flyway.configure().dataSource(URL, "sa", "").locations("filesystem:mig3")
.load().migrate(); // v3 (colonna), v4 (riempie), v5 (vincolo)
try (Connection c = DriverManager.getConnection(URL, "sa", "");
Statement s = c.createStatement();
ResultSet rs = s.executeQuery("SELECT slug, view_count FROM articles")) {
while (rs.next()) System.out.println(rs.getString(1) + " -> " + rs.getInt(2));
try { s.execute("INSERT INTO articles (id, slug, view_count) VALUES (2, 'x', NULL)"); }
catch (SQLException e) { System.out.println("NULL rifiutato: " + e.getErrorCode()); }
}
}
}
// vecchio-articolo -> 0
// NULL rifiutato: 23502
La riga vecchia ha ricevuto 0 dal passo di riempimento e il vincolo rifiuta il nullo (il codice 23502 è la violazione di NOT NULL secondo H2). Va detto con chiarezza cosa è stato provato: questa prova gira su H2 in modalità PostgreSQL, non su PostgreSQL. La sintassi usata è standard e accettata da entrambi, ma i messaggi e alcuni comportamenti (i lock presi da ALTER TABLE su una tabella grande, per esempio) sono propri del database e non sono verificati qui. Un errore classico da evitare in SQL per PostgreSQL: nella clausola HAVING non si può usare il nome di una colonna di output, ma va riscritta l'espressione; lo dice la pagina di SELECT.
Domande frequenti
Devo sempre avere un'entità JPA separata dal dominio?
No. Serve quando il dominio ha regole e invarianti da proteggere e non vuoi che il framework di persistenza ne condizioni la forma. Per tabelle di configurazione o dati senza comportamento, un record e una query bastano: il backend di questo sito usa JdbcTemplate senza entità per la tabella delle impostazioni pubblicitarie.
Meglio un mapper manuale o MapStruct?
Il mapper manuale è il più semplice da capire ed è adatto a pochi campi; MapStruct toglie il codice ripetitivo e segnala a compilazione i campi non mappati, ma aggiunge un annotation processor e convertitori espliciti per i tipi non banali. Il backend di questo sito usa MapStruct, con convertitori con nome per gli enum.
Perché Flyway rifiuta una migration che ho solo ritoccato?
Perché confronta il checksum del file con quello registrato quando è stata applicata. Lo hai visto sopra: un commento in più cambia il checksum. Una migration già eseguita si considera immutabile; la correzione si scrive come migration successiva.
Che cosa sai fare adesso
Sai scrivere un'entità di dominio immutabile con la sua regola, un port nel dominio e un adapter che lo implementa senza far trapelare la persistenza verso l'alto. Sai riconoscere quando la separazione vale il suo costo e quando basta un adapter con JdbcTemplate. Sai versionare lo schema con migration che non si riscrivono, e introdurre un vincolo su una tabella con dati in tre passi. Il passo successivo è il use case che orchestra questi pezzi, e il quadro d'insieme è in Clean Architecture in Spring Boot 3.4; per provare gli adapter con un database vero vedi Testcontainers.
Fonti
- Redgate Flyway - Migrations - versioned, repeatable e undo migration; consultata il 2026-11-12
- Spring Boot - Common Application Properties - valori predefiniti di
spring.jpa.hibernate.ddl-auto,spring.flyway.baseline-on-migrate,validate-on-migrate; consultata il 2026-11-12 - MapStruct Reference Guide - MapStruct come annotation processor che genera il codice di mappatura; consultata il 2026-11-12
- PostgreSQL 16 - SELECT - il nome di una colonna di output non si usa in WHERE e HAVING; consultata il 2026-11-12
- Sorgenti del backend:
ArticleRepositoryAdapter.java,ArticleMapper.java,AdsSettingsRepositoryAdapter.java,application.ymlsottobackend/src/main/- letti il 2026-11-12 - Prova eseguita: Flyway 10.20.1 e H2 2.2.224 da Maven Central, OpenJDK 21.0.13, in una cartella temporanea fuori dal repository

Commenti
Caricamento commenti...
Accedi per commentare