Salta al contenuto principale

Clean Architecture in Java Spring Boot 3.4: 10 Pattern Production-Ready (e 3 Anti-Pattern da Evitare)

I quattro layer di Clean Architecture in Spring Boot 3.4, tre trappole del modello eseguite e il bilancio onesto di come il backend applica la regola.

Cosa ne pensi di questo articolo?

Clean Architecture in Java Spring Boot 3.4: 10 Pattern Production-Ready (e 3 Anti-Pattern da Evitare)
Clean ArchitectureFoto:RDNE Stock projectPexels

In Breve

I quattro layer di Clean Architecture in Spring Boot 3.4, tre trappole del modello eseguite e il bilancio onesto di come il backend applica la regola.

Un monolite Spring Boot senza confini interni funziona finché è piccolo. Poi un cambio al database tocca il controller, un test richiede l'intero contesto e nessuno sa più dove stia la regola che limita le registrazioni. Clean Architecture non è una libreria: è una regola sulla direzione delle dipendenze, applicata a quattro layer. Questo articolo è il pilastro della serie. Ti dà il quadro, le prove che ho eseguito (compresi tre errori che si fanno spesso con record, importi e metriche) e il bilancio onesto di come tiene la regola nel backend di questo sito, che la applica con eccezioni documentate.

I quattro layer e la regola che li tiene insieme

Robert C. Martin descrive la Dependency Rule così: le dipendenze del codice sorgente possono puntare solo verso l'interno, e niente in un cerchio interno può conoscere qualcosa di un cerchio esterno. Nel backend di questo sito i cerchi sono quattro cartelle. Il domain contiene entità, value object e port, senza framework. L'application contiene i use case e dipende dal domain. L'infrastructure implementa i port con JPA, Redis, HTTP e dipende da domain e application. La presentation espone i controller REST e parla con i use case.

Lo schema è questo, e il progetto ne aggiunge un secondo livello: accanto alle quattro cartelle ci sono moduli verticali (per esempio projecthub, partitaiva, lms) con una struttura propria. Il comando seguente e le sue misure sono stati eseguiti in backend/ il 2026-11-12; la quota di file che importano Spring per layer è calcolata con grep -rlE '^import org.springframework'.

cd backend
ls src/main/java/dev/federicocalo/portfolio
find src/main -name "*.java" | wc -l
find src/test -name "*.java" | wc -l
ls src/main/resources/db/migration | wc -l
grep -rhE "^\s+@(Get|Post|Put|Delete|Patch)Mapping" src/main/java | wc -l
for d in domain application infrastructure presentation; do
  base=src/main/java/dev/federicocalo/portfolio/$d
  echo "$d: $(grep -rlE '^import org.springframework' --include=*.java $base | wc -l) su $(find $base -name '*.java' | wc -l) file importano Spring"
done

# Output:
# PortfolioApplication.java
# application
# crm
# domain
# ecosystemportal
# infrastructure
# knowledgehub
# lms
# partitaiva
# presentation
# projecteconomics
# projecthub
# revenue
# tutoringreview
# urbex
# 3741
# 783
# 228
# 1008
# domain: 3 su 146 file importano Spring
# application: 486 su 1666 file importano Spring
# infrastructure: 238 su 324 file importano Spring
# presentation: 147 su 152 file importano Spring

I numeri raccontano il progetto meglio di uno schema. Il domain ha 3 file su 146 che importano Spring: quasi puro, e non del tutto. Il layer application ne ha 486 su 1666 e non è un difetto: i use case usano @Service e @Transactional, scelta consapevole che sposta la purezza al solo domain. L'infrastructure e la presentation importano Spring quasi sempre, com'è naturale. Le quattro cartelle contengono 2288 file, mentre i file Java sotto src/main sono 3741: il resto sta nei moduli verticali. Quindi quando dico «il backend applica la regola» parlo di un perimetro, non di tutto.

Port e adapter: cambiare tecnologia senza toccare il use case

Il beneficio concreto della Dependency Rule è la sostituibilità. Un use case dipende da un'interfaccia (il port), e chi lo costruisce decide quale implementazione (l'adapter) passargli: è lo schema Ports and Adapters, detto anche architettura esagonale, descritto da Alistair Cockburn. Nell'esempio, il use case di benvenuto non sa se il messaggio finisce su console o in una coda.

public interface EmailSender {
    void sendWelcome(String to);
}

public class WelcomeUseCase {

    private final EmailSender emailSender;

    public WelcomeUseCase(EmailSender emailSender) {
        this.emailSender = emailSender;
    }

    public String execute(String to) {
        emailSender.sendWelcome(to);
        return "benvenuto inviato a " + to;
    }
}

public class ConsoleEmailSender implements EmailSender {
    @Override public void sendWelcome(String to) {
        System.out.println("[console] messaggio di benvenuto per " + to);
    }
}

import java.util.ArrayList;
import java.util.List;

public class OutboxEmailSender implements EmailSender {
    private final List<String> queue = new ArrayList<>();

    @Override public void sendWelcome(String to) {
        queue.add(to);
    }

    public List<String> queued() { return List.copyOf(queue); }
}

public class DemoSwap {

    public static void main(String[] args) {
        var console = new WelcomeUseCase(new ConsoleEmailSender());
        System.out.println(console.execute("giulia@example.com"));

        var outbox = new OutboxEmailSender();
        var queued = new WelcomeUseCase(outbox);
        System.out.println(queued.execute("marco@example.com"));
        System.out.println("in coda: " + outbox.queued());
    }
}

// Output:
// [console] messaggio di benvenuto per giulia@example.com
// benvenuto inviato a giulia@example.com
// benvenuto inviato a marco@example.com
// in coda: [marco@example.com]

Il WelcomeUseCase è lo stesso nelle due esecuzioni: cambia solo l'argomento del costruttore. Nel primo caso l'adapter stampa, nel secondo accoda il destinatario, e la riga «in coda» mostra che il messaggio è finito lì. In un'applicazione Spring il «chi costruisce» è il contenitore di dipendenze, ma il principio è identico, ed è anche ciò che rende banale provare il use case con un adapter finto. Come si scrive e si prova un use case completo è in Use Case Pattern in Spring Boot Clean Architecture, gli adapter di persistenza e di cache sono in Infrastructure layer: JPA e Redis.

Entity e value object: tre trappole che il codice mostra

Un value object è identificato dal suo valore (una somma di denaro, un'email); un'entità è identificata da un id che resta lo stesso mentre i suoi dati cambiano. I record di Java sono comodi per i value object, e inducono a usarli anche per le entità, ed è qui che si inciampa. Il programma seguente mostra tre trappole con uscita reale.

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Currency;

public record Money(BigDecimal amount, Currency currency) {

    public Money {
        amount = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.UNNECESSARY);
    }

    public Money add(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("valute diverse: " + currency + " e " + other.currency);
        }
        return new Money(amount.add(other.amount), currency);
    }
}

import java.util.Set;

public record Email(String value) {

    private static final Set<String> DISPOSABLE_DOMAINS = Set.of("temp-mail.com");

    public Email {
        int at = value.indexOf('@');
        if (at <= 0 || at == value.length() - 1) {
            throw new IllegalArgumentException("email non valida: " + value);
        }
        if (DISPOSABLE_DOMAINS.contains(value.substring(at + 1).toLowerCase())) {
            throw new IllegalArgumentException("dominio non ammesso: " + value.substring(at + 1));
        }
    }
}

public record UserRecord(String id, String name, String role) { }

import java.util.Objects;

public final class UserEntity {
    private final String id;
    private String role;

    public UserEntity(String id, String role) {
        this.id = Objects.requireNonNull(id);
        this.role = role;
    }

    public String id() { return id; }
    public String role() { return role; }
    public void promoteTo(String newRole) { this.role = newRole; }

    @Override public boolean equals(Object o) {
        return o instanceof UserEntity other && id.equals(other.id);
    }

    @Override public int hashCode() { return id.hashCode(); }
}

import java.math.BigDecimal;
import java.util.Currency;

public class DemoModel {

    public static void main(String[] args) {
        Currency eur = Currency.getInstance("EUR");
        Currency usd = Currency.getInstance("USD");

        System.out.println("BigDecimal 10 e 10.00 uguali: " + new BigDecimal("10").equals(new BigDecimal("10.00")));
        Money ten = new Money(new BigDecimal("10"), eur);
        Money tenAgain = new Money(new BigDecimal("10.00"), eur);
        System.out.println("10 e 10.00 EUR uguali: " + ten.equals(tenAgain));
        System.out.println("somma: " + ten.add(new Money(new BigDecimal("5.5"), eur)));
        try {
            ten.add(new Money(BigDecimal.ONE, usd));
        } catch (IllegalArgumentException e) {
            System.out.println("rifiutata: " + e.getMessage());
        }
        try {
            new Money(new BigDecimal("1.005"), eur);
        } catch (ArithmeticException e) {
            System.out.println("1.005 EUR rifiutato: " + e.getMessage());
        }

        try {
            new Email("qualcuno@temp-mail.com");
        } catch (IllegalArgumentException e) {
            System.out.println("email: " + e.getMessage());
        }

        var before = new UserRecord("u-1", "Giulia", "USER");
        var after = new UserRecord("u-1", "Giulia", "ADMIN");
        System.out.println("record, stesso id e ruolo diverso, uguali: " + before.equals(after));

        var a = new UserEntity("u-1", "USER");
        var b = new UserEntity("u-1", "USER");
        b.promoteTo("ADMIN");
        System.out.println("entity, stesso id e ruolo diverso, uguali: " + a.equals(b));
    }
}

// Output:
// BigDecimal 10 e 10.00 uguali: false
// 10 e 10.00 EUR uguali: true
// somma: Money[amount=15.50, currency=EUR]
// rifiutata: valute diverse: EUR e USD
// 1.005 EUR rifiutato: Rounding necessary
// email: dominio non ammesso: temp-mail.com
// record, stesso id e ruolo diverso, uguali: false
// entity, stesso id e ruolo diverso, uguali: true

La prima riga è la trappola dei decimali: due BigDecimal con scala diversa (10 e 10.00) non sono equals, perché il metodo confronta anche la scala. Il compact constructor di Money normalizza la scala sulle cifre della valuta con RoundingMode.UNNECESSARY, e così le due somme risultano uguali, mentre un importo come 1.005 EUR viene rifiutato invece di essere arrotondato in silenzio. La somma di valute diverse lancia un'eccezione: la regola sta nel tipo, non nel codice che lo usa.

La seconda trappola è nella regola dei domini usa e getta, che sta dentro Email. Se la regola fosse in un servizio, ogni punto che crea un'email dovrebbe ricordarsi di chiamarlo: dentro il value object non si può dimenticare, perché non esiste un'Email non valida. La terza è la più sottile: UserRecord è un record, quindi equals confronta tutti i campi, e due oggetti con lo stesso id ma ruolo diverso risultano diversi. Per un'entità non è ciò che vuoi, e la classe UserEntity con equals basato sull'id risolve il problema al prezzo di mutabilità controllata. Il modello del domain è approfondito in Domain layer in Clean Architecture.

Transazioni ed eccezioni: dove stanno e come escono

La transazione appartiene al use case, perché è lui a sapere quali scritture formano un'unità di lavoro, e non al domain né al controller. Nel backend ho misurato dove compare @Transactional (2026-11-12): 76 file in application, 32 in infrastructure, 0 in domain e 0 in presentation. Le eccezioni di dominio estendono RuntimeException perché, per impostazione predefinita, Spring annulla la transazione solo per eccezioni non controllate; un handler globale nella presentation le traduce in stati HTTP, così il use case non sa cosa sia un 404. Il dettaglio, con il codice e la documentazione di Spring, è in Use Case Pattern in Spring Boot Clean Architecture, e non lo ripeto qui.

Persistenza: Spring Data dietro al port, non al posto del port

Il repository Spring Data è un dettaglio dell'infrastructure. Il domain vede un port con i metodi che gli servono; l'adapter converte tra l'entità JPA e il modello del domain. Quando i filtri diventano molti, la tentazione è un'interfaccia con decine di metodi findByXAndYAndZ. La documentazione di Spring Data JPA propone le Specification: predicati composti con l'API Criteria, usabili estendendo l'interfaccia con JpaSpecificationExecutor. Lo schema seguente non è eseguito (richiede Spring Data e un database).

public final class ArticleSpecifications {

    public static Specification<ArticleJpaEntity> byAuthor(String authorId) {
        return (root, query, cb) -> cb.equal(root.get("authorId"), authorId);
    }

    public static Specification<ArticleJpaEntity> published() {
        return (root, query, cb) -> cb.isTrue(root.get("published"));
    }

    public static Specification<ArticleJpaEntity> titleContains(String text) {
        return (root, query, cb) ->
            cb.like(cb.lower(root.get("title")), "%" + text.toLowerCase() + "%");
    }
}

interface JpaArticleRepository
        extends JpaRepository<ArticleJpaEntity, String>, JpaSpecificationExecutor<ArticleJpaEntity> { }

// nell'adapter: i filtri si compongono solo se servono
Specification<ArticleJpaEntity> filter = Specification.where(published());
if (authorId != null) filter = filter.and(byAuthor(authorId));
if (text != null)     filter = filter.and(titleContains(text));
List<ArticleJpaEntity> rows = jpa.findAll(filter);

Ogni specification è un filtro piccolo e componibile, e l'adapter li combina solo se il valore è presente. Il compromesso è che i nomi degli attributi sono stringhe: un refuso ("autorId") fallisce a runtime, non a compilazione, a meno di usare il metamodello JPA. Non è un pattern che il backend di questo sito applichi: nel codice di src/main/java nessun file usa Specification< né JpaSpecificationExecutor (misurato con grep -rl). La mia posizione è che serva solo quando i filtri combinabili sono davvero tanti; con tre o quattro query fisse, i metodi derivati sono più leggibili. Le migration e l'adapter JPA sono in JPA e Flyway in Clean Architecture.

Cache e test: due temi con un articolo ciascuno

La cache, con il confronto fra decoratore e @Cacheable e il comportamento quando Redis non risponde, è in Infrastructure layer: JPA e Redis. Il backend usa Redis come cache con TTL individuali per ogni cache, e c'è anche Caffeine: il Javadoc di RedisCacheConfig cita una cache Caffeine da 30 minuti nel use case dei cambi valuta (FxEurUseCaseImpl). La piramide dei test e i casi che i mock non vedono sono in Testing completo in Clean Architecture, e l'uso di Testcontainers in Testing Spring Boot con Testcontainers. I principi SOLID che stanno sotto questi pattern sono in SOLID in pratica con Java e TypeScript.

Metriche: contare ciò che succede, senza sporcare il domain

Una metrica è un effetto collaterale e quindi va dalla parte dell'infrastructure, o in un decoratore del use case. Micrometer è la libreria che Spring Boot usa per questo; la documentazione di Spring Boot elenca prometheus tra gli endpoint di Actuator. Con il SimpleMeterRegistry si vede in poche righe che cosa registra un contatore, un timer e un gauge, senza Spring.

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import java.util.ArrayList;
import java.util.List;

public class DemoMetrics {

    public static void main(String[] args) {
        MeterRegistry registry = new SimpleMeterRegistry();

        Counter published = Counter.builder("articles.published").tag("category", "java").register(registry);
        Timer publishTime = Timer.builder("articles.publish.duration").register(registry);
        List<String> drafts = new ArrayList<>(List.of("bozza A", "bozza B"));
        registry.gaugeCollectionSize("articles.drafts", io.micrometer.core.instrument.Tags.empty(), drafts);

        for (int i = 0; i < 3; i++) {
            publishTime.record(() -> published.increment());
        }
        drafts.add("bozza C");

        System.out.println("pubblicati: " + published.count());
        System.out.println("misure del timer: " + publishTime.count());
        System.out.println("bozze: " + registry.get("articles.drafts").gauge().value());
        System.out.println("tag del contatore: " + published.getId().getTags());
    }
}

// Output:
// pubblicati: 3.0
// misure del timer: 3
// bozze: 3.0
// tag del contatore: [tag(category=java)]

Il contatore ha il tag category=java, e il suo valore è un double (3.0). Il timer registra tre misure. Il gauge segue la lista tramite gaugeCollectionSize e, dopo l'aggiunta di una terza bozza, vale 3: il gauge legge il valore al momento dell'osservazione. La trappola sta nel tipo di gauge che accetta un oggetto qualunque: la documentazione di Micrometer precisa che tutte le forme di gauge mantengono solo un riferimento debole all'oggetto osservato, quindi se passi un oggetto temporaneo (una stringa creata al volo) nulla impedisce al garbage collector di rimuoverlo, e il gauge perde l'oggetto che doveva osservare. Per un gauge su un numero che cambia usa un AtomicInteger che tieni in un campo.

Nel backend, Micrometer è presente con micrometer-registry-prometheus nel pom.xml e application.yml espone health, info, metrics e prometheus sotto management.endpoints.web.exposure.include. Il registro compare in 5 file di src/main/java (grep -rln MeterRegistry), quindi la strumentazione è mirata e non diffusa. L'etichetta di un contatore va tenuta a bassa cardinalità: un tag con valori illimitati (un id utente) genera una serie di dati per ciascun valore.

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus

Quanto è pulita davvero: il bilancio onesto

Un articolo su un'architettura che dichiara «zero violazioni» sta nascondendo qualcosa. Ecco che cosa ho misurato nel backend (2026-11-12), con i comandi riportati sopra o nei file citati.

  • Regole ArchUnit con baseline congelata. CleanArchitectureTest congela due regole globali: presentation che non dipende da infrastructure, domain senza Spring né JPA. Lo store contiene 224 righe di violazione per la prima e 6 per la seconda.
  • Copertura. Il controllo JaCoCo con soglia dell'80% sulle istruzioni è limitato ai package partitaiva; non c'è una copertura globale dichiarabile.
  • Mock. 55 file di test usano ancora @MockBean e 5 usano @MockitoBean (grep -rl su backend/src/test): la migrazione al nome nuovo è incompleta.
  • Transazioni. 32 file di infrastructure dichiarano @Transactional, 22 dei quali sono adapter di repository: non è la separazione ideale, ma con la propagazione predefinita partecipano alla transazione del use case invece di aprirne un'altra.

Il quadro è quello di un progetto che ha scelto di introdurre i controlli senza fermarsi a sistemare il passato: le violazioni esistenti sono visibili e fissate, quelle nuove fanno fallire la build. Costa un debito che si può dimenticare, e una trappola nel testo della regola, che cambia la chiave dell'archivio e azzera la baseline (la prova è in Testing completo in Clean Architecture). Il dato meno comodo è il primo: 224 righe di controller che importano classi di infrastructure, riferite a circa trenta classi, dicono che la regola «la presentation non tocca l'infrastructure» vale per il codice nuovo, non per tutto.

Quando non conviene

È una mia valutazione, non una misura: per un prototipo che userai tre mesi e butterai, quattro layer, port e mapper costano più di quanto restituiscono, e un'architettura a strati semplice con Spring ovunque è una scelta ragionevole. La Clean Architecture ripaga quando il codice vive anni, quando cambiano le tecnologie attorno al business (database, provider, canale) o quando più persone lavorano sullo stesso codice e hanno bisogno di un confine che nessuno rompa per distrazione. Il prezzo è un numero di file molto maggiore: il backend di questo sito ha 3741 file Java in src/main, e orientarsi richiede convenzioni di nomi e la ricerca per nome dell'IDE.

Domande che si fanno spesso

Come porto un monolite esistente verso questa struttura?

Per funzionalità, non tutto insieme: scegli la prossima da toccare e scrivila secondo i layer, lasciando il resto finché non richiede manutenzione. Non esiste un tempo valido in generale, perché dipende da quanto spesso il codice cambia. Congelare le violazioni con ArchUnit, come fa il backend, impedisce che il debito cresca mentre lo riduci.

Posso usare Spring Data JPA senza sporcare il domain?

Sì, confinando Spring Data nell'infrastructure: il port sta nel domain, l'adapter con il repository Spring Data e l'entità JPA sta in infrastructure, e un mapper converte tra i due modelli. Il costo è il mapper e il rischio di divergenza fra i due modelli, che si copre con un test di round trip come quello descritto nell'articolo sull'infrastructure.

Come verifico che il domain non importi Spring?

Con una regola ArchUnit che fallisce quando una classe del domain dipende da org.springframework o da jakarta.persistence, come fa il test congelato del backend. Una regola così va vista fallire almeno una volta: la prova eseguita è in Infrastructure layer: JPA e Redis. Per le novità del linguaggio che semplificano il domain (record, pattern matching) c'è Java 21 per Spring Boot developer.

Che cosa sai fare adesso

Sai spiegare i quattro layer con la direzione delle dipendenze e sai misurare quanto un codice vero la rispetta, con comandi che puoi rifare. Sai riconoscere le tre trappole del modello (decimali con scala diversa, record usati come entità, regole fuori dal value object), conosci il limite dei gauge e sai che cosa significa congelare una regola ArchUnit. E sai valutare con onestà quando questa architettura vale il suo costo.

Fonti

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti