Nello sviluppo enterprise contemporaneo, costruire applicazioni resilienti ed evolvibili richiede l'abbandono di modelli anemici e architetture centrate sul database. L'adozione congiunta di Domain-Driven Design (DDD) e Clean Code (applicati tramite l'Architettura Esagonale o Ports & Adapters) permette di isolare il core di business dai dettagli infrastrutturali.
Con il rilascio di Java 25 (LTS), il linguaggio offre strumenti formidabili per esprimere i costrutti del DDD in modo nativo: Records, Sealed Interfaces, Pattern Matching avanzato e costruttori flessibili permettono di modellare Value Objects, Entità ed Eventi di Dominio con un livello di rigore e pulizia mai visto prima.
1. Fondamenti: Cosa Significa DDD e Clean Code?
Cos'è il Clean Code?
Formalizzato da Robert C. Martin ("Uncle Bob"), il Clean Code è codice progettato per essere compreso immediatamente da altri sviluppatori, prima ancora che dalle macchine:
- Single Responsibility Principle (SRP): Una classe o un metodo deve avere una sola ragione per cambiare.
- Indipendenza dai Framework: Il business core non deve dipendere da Spring, Hibernate, Jackson o protocolli di rete.
- Espressività e Nomi Intenzionali: Il codice deve essere auto-documentante e privo di effetti collaterali oscuri.
- Testabilità Isolata: Il core di business deve essere verificabile istantaneamente con Unit Test puri su POJO.
Cos'è il Domain-Driven Design (DDD)?
Introdotto da Eric Evans, il DDD pone la complessità del business ("il Dominio") al centro della progettazione software:
- Ubiquitous Language (Linguaggio Ubiquo): Vocabolario condiviso tra esperti di business e sviluppatori, riflesso in modo trasparente nelle classi e nei metodi.
- Bounded Context (Contesti Delimitati): Confini espliciti che delimitano la consistenza semantica del modello.
- Pattern Tattici:
- Value Objects: Oggetti immutabili definiti solo dal proprio stato/valore, senza ID (perfetti come
recordin Java 25). - Entities: Oggetti definiti dalla loro identità persistente (es.
OrderId). - Aggregate Root: Unità atomica di consistenza che incapsula entità interne e garantisce le regole e gli invarianti di business.
- Domain Events: Fatti immutabili accaduti nel dominio, ideali da modellare con
sealed interfaceerecord. - Repository Port: Astrazione pura Java che simula una collezione in memoria per salvare/caricare l'aggregato.
- Value Objects: Oggetti immutabili definiti solo dal proprio stato/valore, senza ID (perfetti come
2. Il Problema: Anemic Domain Model vs Rich Domain Model
Nelle applicazioni Spring tradizionali, è comune incontrare l'anti-pattern dell'Anemic Domain Model, dove le entità sono meri contenitori di dati (getter/setter) e la logica è sparpagliata in classi @Service gigantesche. Ecco come cambia il paradigma con il Rich Domain Model:
| Caratteristica | Anemic Model (Anti-Pattern) | Rich Domain Model (DDD + Java 25) |
|---|---|---|
| Invarianti di Business | Violati facilmente da setter indiscriminati. | Protetti dentro l'Aggregate Root e Value Objects. |
| Posizione della Logica | Dispersa in grossi @Service procedurali. |
Incapsulata in Entità, Value Objects e Metodi di Business. |
| Accoppiamento Framework | @Entity, @Table, annotazioni Jackson nel core. |
POJO e Record puri, zero annotazioni esterne. |
| Velocità dei Test | Test lenti con contesti Spring o mock complessi. | Test unitari ultra-rapidi eseguiti in pochi millisecondi. |
3. Struttura del Bounded Context (Architettura Esagonale)
L'Architettura Esagonale (Ports & Adapters) permette di mantenere il Dominio al centro, completamente isolato.
com.example.ecommerce.order
├── domain <-- CORE POJO/RECORD (Zero Spring/JPA)
│ ├── model <-- Order (Aggregate), OrderId, Money, OrderLine
│ ├── event <-- OrderDomainEvent (Sealed Interface)
│ ├── exception <-- OrderDomainException
│ └── repository <-- OrderRepository (Output Port)
├── application <-- CASI D'USO
│ ├── dto <-- CreateOrderCommand, OrderResponseDto
│ └── service <-- CreateOrderUseCaseService
└── infrastructure <-- DETTAGLI TECNICI E ADAPTERS
├── persistence <-- OrderJpaEntity, SpringDataOrderRepository
│ ├── adapter <-- OrderRepositoryJpaAdapter
│ └── mapper <-- OrderPersistenceMapper (MapStruct)
└── rest <-- OrderRestController (Web Adapter)
4. Configurazione Maven: pom.xml per Java 25
Per supportare le ultime feature, configuriamo Spring Boot 3.5+, MapStruct e Testcontainers per Java 25:
<!-- dependencies essenziali nel pom.xml -->
<properties>
<java.version>25</java.version>
<org.mapstruct.version>1.6.3</org.mapstruct.version>
</properties>
<dependencies>
<!-- Spring Boot Web, Data JPA, Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MapStruct per il mapping pulito -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
</dependencies>
5. Implementazione del Dominio (Java 25)
5.1 Value Objects Immutabili (Records)
I record di Java sono la traduzione naturale dei Value Objects del DDD: garantiscono immutabilità, comparazione per valore e permettono validazioni compatte.
package com.example.ecommerce.order.domain.model;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Currency;
public record Money(BigDecimal amount, Currency currency) {
public static final Currency EUR = Currency.getInstance("EUR");
// Compact constructor per le invarianti
public Money {
if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("L'importo non può essere negativo");
}
if (currency == null) {
throw new IllegalArgumentException("La valuta è obbligatoria");
}
amount = amount.setScale(2, RoundingMode.HALF_UP);
}
public static Money ofEuros(double value) {
return new Money(BigDecimal.valueOf(value), EUR);
}
public static Money zero(Currency currency) {
return new Money(BigDecimal.ZERO, currency);
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Valute incompatibili");
}
return new Money(this.amount.add(other.amount), this.currency);
}
public Money multiply(int quantity) {
return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), this.currency);
}
}
Gli identificatori fortemente tipizzati prevengono errori comuni (es. scambiare un OrderId con un CustomerId):
public record OrderId(java.util.UUID value) {
public OrderId {
if (value == null) throw new IllegalArgumentException("OrderId non può essere nullo");
}
public static OrderId generate() { return new OrderId(java.util.UUID.randomUUID()); }
}
5.2 Domain Events con Sealed Interfaces
Le Sealed Interfaces permettono di creare gerarchie chiuse, perfette per garantire un pattern matching esaustivo in fase di compilazione.
package com.example.ecommerce.order.domain.event;
import com.example.ecommerce.order.domain.model.OrderId;
import com.example.ecommerce.order.domain.model.Money;
import java.time.Instant;
import java.util.UUID;
public sealed interface OrderDomainEvent {
OrderId orderId();
Instant occurredOn();
record OrderCreatedEvent(OrderId orderId, UUID customerId, Instant occurredOn) implements OrderDomainEvent {}
record OrderPaidEvent(OrderId orderId, Money totalPaid, Instant occurredOn) implements OrderDomainEvent {}
record OrderCancelledEvent(OrderId orderId, String reason, Instant occurredOn) implements OrderDomainEvent {}
}
5.3 L'Aggregate Root: Il Cuore del Dominio
L'Order non ha annotazioni JPA. Espone solo metodi di business espliciti (ubiquitous language) e non espone setter per alterare illegalmente il suo stato interno.
package com.example.ecommerce.order.domain.model;
import com.example.ecommerce.order.domain.event.OrderDomainEvent;
import com.example.ecommerce.order.domain.event.OrderDomainEvent.*;
import java.time.Instant;
import java.util.*;
public class Order {
private final OrderId id;
private final UUID customerId;
private final List<OrderLine> lines;
private OrderStatus status;
private final List<OrderDomainEvent> domainEvents = new ArrayList<>();
public Order(OrderId id, UUID customerId) {
if (customerId == null) throw new IllegalArgumentException("CustomerId obbligatorio");
this.id = Objects.requireNonNull(id, "OrderId obbligatorio");
this.customerId = customerId;
this.lines = new ArrayList<>();
this.status = OrderStatus.CREATED;
recordEvent(new OrderCreatedEvent(this.id, this.customerId, Instant.now()));
}
public void addProduct(UUID productId, Money unitPrice, int quantity) {
if (this.status != OrderStatus.CREATED) {
throw new IllegalStateException("Impossibile modificare un ordine nello stato " + this.status);
}
this.lines.add(new OrderLine(productId, unitPrice, quantity));
}
public Money calculateTotal() {
if (lines.isEmpty()) return Money.zero(Money.EUR);
return lines.stream()
.map(OrderLine::calculateSubtotal)
.reduce(Money.zero(Money.EUR), Money::add);
}
public void markAsPaid() {
if (this.status != OrderStatus.CREATED) {
throw new IllegalStateException("Solo gli ordini CREATED possono essere pagati");
}
if (this.lines.isEmpty()) {
throw new IllegalStateException("Impossibile saldare un ordine privo di articoli");
}
this.status = OrderStatus.PAID;
recordEvent(new OrderPaidEvent(this.id, calculateTotal(), Instant.now()));
}
private void recordEvent(OrderDomainEvent event) {
this.domainEvents.add(event);
}
public List<OrderDomainEvent> pullDomainEvents() {
var events = List.copyOf(domainEvents);
domainEvents.clear();
return events;
}
// Solo Getter (stato in sola lettura, collezioni non modificabili)
public OrderId getId() { return id; }
public OrderStatus getStatus() { return status; }
public List<OrderLine> getLines() { return Collections.unmodifiableList(lines); }
}
6. L'Application Layer: Use Cases e Pattern Matching
Il servizio applicativo orchestra il recupero, la mutazione e il salvataggio. Usiamo l'operatore switch potenziato di Java 25 per dispacciare gli eventi in modo type-safe ed esaustivo.
package com.example.ecommerce.order.application.service;
import com.example.ecommerce.order.domain.event.OrderDomainEvent;
import com.example.ecommerce.order.domain.event.OrderDomainEvent.*;
import com.example.ecommerce.order.domain.model.*;
import com.example.ecommerce.order.domain.repository.OrderRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
public class CreateOrderUseCaseService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher;
public CreateOrderUseCaseService(OrderRepository orderRepository, ApplicationEventPublisher eventPublisher) {
this.orderRepository = orderRepository;
this.eventPublisher = eventPublisher;
}
public OrderResponseDto handle(CreateOrderCommand command) {
Order order = new Order(OrderId.generate(), command.customerId());
command.items().forEach(item ->
order.addProduct(item.productId(), Money.ofEuros(item.price()), item.quantity())
);
Order savedOrder = orderRepository.save(order);
// Dispatching eventi con Pattern Matching
savedOrder.pullDomainEvents().forEach(this::dispatchDomainEvent);
return new OrderResponseDto(savedOrder.getId().value(), savedOrder.getStatus().name());
}
private void dispatchDomainEvent(OrderDomainEvent event) {
// Switch esaustivo su sealed interface (Java 25)
switch (event) {
case OrderCreatedEvent created -> eventPublisher.publishEvent(created);
case OrderPaidEvent paid -> eventPublisher.publishEvent(paid);
case OrderCancelledEvent canc -> eventPublisher.publishEvent(canc);
}
}
}
7. L'Infrastructure Layer: L'Adapter JPA
L'infrastruttura implementa il repository (Port) del Dominio, mappando il POJO Order in un'entità JPA (OrderJpaEntity) tramite MapStruct.
@Component
public class OrderRepositoryJpaAdapter implements OrderRepository {
private final SpringDataOrderRepository springDataRepository;
private final OrderPersistenceMapper mapper; // Interfaccia MapStruct
public OrderRepositoryJpaAdapter(SpringDataOrderRepository springRepo, OrderPersistenceMapper mapper) {
this.springDataRepository = springRepo;
this.mapper = mapper;
}
@Override
public Order save(Order order) {
// Dominio -> JPA
var entity = mapper.toJpaEntity(order);
var saved = springDataRepository.save(entity);
// JPA -> Dominio
return mapper.toDomainEntity(saved);
}
}
8. Il Superpotere: Testabilità Estrema
Grazie all'isolamento architetturale, i test di business vengono eseguiti senza mock complessi e senza avviare il contesto Spring. Sono test rapidissimi, eseguiti in millisecondi.
class OrderTest {
@Test
void shouldCalculateTotalAndMarkAsPaid() {
// Arrange
Order order = new Order(OrderId.generate(), UUID.randomUUID());
order.addProduct(UUID.randomUUID(), Money.ofEuros(49.90), 2);
// Act
order.markAsPaid();
// Assert
assertEquals(Money.ofEuros(99.80), order.calculateTotal());
assertEquals(OrderStatus.PAID, order.getStatus());
var events = order.pullDomainEvents();
assertTrue(events.stream().anyMatch(e -> e instanceof OrderDomainEvent.OrderPaidEvent));
}
}
Conclusione
L'integrazione di Java 25, Domain-Driven Design e Clean Code in un'Architettura Esagonale risolve i limiti storici dello sviluppo enterprise monolitico:
- Isolamento Totale: Il dominio non conosce i database o il web. Potresti passare da REST a gRPC, o da PostgreSQL a MongoDB, e le classi core non cambierebbero di una virgola.
- Sintassi Moderna: Records e Sealed Interfaces rimuovono tonnellate di boilerplate, rendendo il codice elegante e strettamente tipizzato.
- Qualità Assicurata: Le regole di business sono centralizzate nell'Aggregate Root e facilmente testabili, azzerando le probabilità di corruzione dello stato interno.
Iniziare un progetto con questo grado di disaccoppiamento richiede un piccolo investimento iniziale in mapping (tramite MapStruct), ma il ritorno in termini di leggibilità, flessibilità e stabilità è incalcolabile.


