Introduzione: DDD come Fondamento della Modularita
Il Domain Driven Design (DDD) non e solo un approccio alla modellazione del software: e lo strumento più potente per identificare i confini corretti dei moduli in un modular monolith. Senza DDD, le boundaries tra moduli diventano arbitrarie, basate su criteri tecnici piuttosto che su confini di dominio reali. Il risultato e un'architettura fragile che non rispecchia il business.
In questo articolo esploreremo i concetti fondamentali di DDD applicati alla modularizzazione: Bounded Contexts, Ubiquitous Language, Context Mapping, e Aggregate Design. Vedremo come tradurre questi concetti in confini di modulo concreti e come implementarli in codice Java/Spring Boot.
Cosa Imparerai in Questo Articolo
- I fondamenti del DDD: entità, value objects, aggregates e bounded contexts
- Come identificare i bounded contexts della tua applicazione
- Ubiquitous Language: perchè ogni modulo ha il proprio vocabolario
- Context Mapping: Anti-Corruption Layer, Shared Kernel, Customer/Supplier
- Aggregate Design: la chiave per transazioni consistenti
- Come tradurre i bounded contexts in struttura di package Java
- La legge di Conway: allineare team e architettura
Fondamenti DDD per il Modular Monolith
Il Domain Driven Design introduce un vocabolario preciso per modellare il software attorno al dominio di business. Vediamo i building block fondamentali:
Entità
Un'entità e un oggetto di dominio con un'identità unica che persiste nel tempo.
Due entità sono uguali se hanno lo stesso ID, indipendentemente dai valori dei loro attributi.
Esempi: Order, User, Product.
Value Objects
Un value object e un oggetto immutabile definito dai suoi attributi, senza identità
propria. Due value object con gli stessi attributi sono identici. Esempi: Money,
Address, EmailAddress.
Aggregates
Un aggregate e un cluster di entità e value objects trattati come un'unita per le operazioni di modifica. L'aggregate ha una root entity che funge da punto di accesso e garantisce l'integrita delle invarianti interne. Le transazioni non devono mai attraversare i confini di un aggregate.
// Esempio di Aggregate: Order
// Order e la root entity dell'aggregate
// OrderItem e Money sono parte dell'aggregate
public class Order {
private final UUID id; // identità dell'entità
private UUID userId;
private List<OrderItem> items; // entità figlie
private Money total; // value object
private OrderStatus status;
private Instant createdAt;
// Factory method - unico modo per creare un Order
public static Order create(UUID userId, List<ProductDto> products) {
Order order = new Order();
order.id = UUID.randomUUID();
order.userId = userId;
order.items = products.stream()
.map(p -> OrderItem.fromProduct(p))
.toList();
order.total = Money.sum(
order.items.stream().map(OrderItem::getPrice).toList()
);
order.status = OrderStatus.CREATED;
order.createdAt = Instant.now();
return order;
}
// Le invarianti sono protette all'interno dell'aggregate
public void cancel() {
if (this.status == OrderStatus.SHIPPED) {
throw new IllegalStateException(
"Cannot cancel a shipped order"
);
}
this.status = OrderStatus.CANCELLED;
}
}
// Value Object immutabile
public record Money(BigDecimal amount, Currency currency) {
public static Money sum(List<Money> values) {
return new Money(
values.stream()
.map(Money::amount)
.reduce(BigDecimal.ZERO, BigDecimal::add),
values.get(0).currency()
);
}
}
Bounded Contexts: I Confini del Dominio
Un Bounded Context e un confine linguistico e funzionale all'interno del quale un modello di dominio e valido e consistente. All'interno di un bounded context, ogni termine ha un significato preciso e univoco. Attraverso i confini del context, lo stesso concetto può avere significati diversi.
Esempio concreto: il concetto di "Prodotto" ha significati diversi in contesti diversi:
- Nel Catalogo: un prodotto ha nome, descrizione, immagini, categorie
- Nel Magazzino: un prodotto ha quantità disponibile, locazione, soglia di riordino
- Nell'Ordine: un prodotto e una riga con prezzo unitario, quantità ordinata, sconto
- Nella Spedizione: un prodotto ha peso, dimensioni, fragilita
Forzare un unico modello Product che soddisfi tutti questi contesti crea una
God Class con decine di attributi, la maggior parte dei quali irrilevanti per
ciascun contesto specifico.
// SBAGLIATO: Un unico modello Product per tutti i contesti
// "God Class" che viola il principio di bounded context
public class Product {
// Dati catalogo
private String name;
private String description;
private List<String> images;
private Category category;
// Dati magazzino
private int stockQuantity;
private String warehouseLocation;
private int reorderThreshold;
// Dati ordine
private BigDecimal unitPrice;
private BigDecimal discount;
// Dati spedizione
private double weight;
private Dimensions dimensions;
private boolean fragile;
// ... 30+ attributi
}
// CORRETTO: Un modello per ogni bounded context
// Catalogo
public class CatalogProduct {
private UUID id;
private String name;
private String description;
private List<String> images;
private Category category;
}
// Magazzino
public class InventoryItem {
private UUID productId; // riferimento, non l'oggetto intero
private int stockQuantity;
private String location;
private int reorderThreshold;
}
// Ordine
public class OrderLineItem {
private UUID productId; // riferimento
private Money unitPrice;
private int quantity;
private Money discount;
}
Regola Fondamentale
Ogni bounded context deve avere il proprio modello per i concetti condivisi. I contesti comunicano attraverso ID di riferimento, non attraverso oggetti condivisi. Questo e il principio chiave che rende i moduli indipendenti e estraibili.
Ubiquitous Language: Un Vocabolario per Context
L'Ubiquitous Language e il vocabolario condiviso tra sviluppatori e domain expert all'interno di un bounded context. Ogni context ha il proprio linguaggio, e lo stesso termine può avere significati diversi in contesti diversi.
Questo concetto ha implicazioni pratiche sul naming nel codice:
- Nel modulo Order:
Customere chi effettua l'ordine, con indirizzo di fatturazione e metodo di pagamento - Nel modulo Shipping:
Customere il destinatario, con indirizzo di consegna e preferenze di spedizione - Nel modulo Support:
Customere chi apre un ticket, con storico delle interazioni e livello di priorità
Forzare un unico modello Customer per tutti i contesti crea accoppiamento e impedisce
ai team di evolvere i propri modelli in modo indipendente.
Context Mapping: Come i Contesti Comunicano
Il Context Mapping definisce come i bounded contexts interagiscono tra loro. Esistono diversi pattern di relazione, ciascuno con trade-off specifici:
Anti-Corruption Layer (ACL)
L'Anti-Corruption Layer e uno strato di traduzione che isola un bounded context dal modello di un altro context. Quando il modulo Order ha bisogno di dati dal modulo Catalog, non usa direttamente i DTO del Catalog: li traduce nel proprio modello interno attraverso un ACL.
// Anti-Corruption Layer nel modulo Order
// Traduce i concetti del Catalog nel linguaggio dell'Order
package com.ecommerce.order.internal.acl;
@Component
class CatalogAntiCorruptionLayer {
private final CatalogModuleApi catalogModule;
// Traduce ProductDto (catalogo) in OrderProduct (ordine)
public OrderProduct resolveProduct(UUID productId) {
ProductDto catalogProduct = catalogModule.findById(productId)
.orElseThrow(() -> new ProductNotAvailableException(productId));
// Traduzione: prende solo ciò che serve al contesto Order
return new OrderProduct(
catalogProduct.id(),
catalogProduct.name(),
Money.of(catalogProduct.price(), Currency.EUR),
catalogProduct.isAvailable()
);
}
}
// OrderProduct: modello interno del contesto Order
// Non e il ProductDto del Catalog
record OrderProduct(
UUID productId,
String displayName,
Money price,
boolean available
) {}
Shared Kernel
Lo Shared Kernel e un piccolo insieme di codice condiviso tra due o più bounded
contexts. Include tipicamente value objects comuni (come Money, Address),
eventi di dominio condivisi, e costanti. Lo shared kernel deve essere minimale
e cambiato solo con il consenso di tutti i team coinvolti.
// Shared Kernel: codice condiviso tra moduli
package com.ecommerce.shared;
// Value objects condivisi
public record Money(BigDecimal amount, Currency currency) {
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new CurrencyMismatchException();
}
return new Money(this.amount.add(other.amount), this.currency);
}
}
public record Address(
String street, String city,
String zipCode, String country
) {}
// Eventi di dominio condivisi
public record OrderCreatedEvent(
UUID orderId, UUID userId, Money total, Instant timestamp
) {}
Customer/Supplier
Nel pattern Customer/Supplier, un context (supplier) fornisce dati o servizi a un altro context (customer). Il supplier definisce l'interfaccia, ma tiene conto delle esigenze del customer. Questo pattern si applica quando un modulo dipende chiaramente da un altro in modo unidirezionale.
Aggregate Design: Transazioni e Consistenza
Il design degli aggregate e cruciale per un modular monolith perchè definisce i confini transazionali. Una transazione ACID deve operare all'interno di un singolo aggregate. Le interazioni tra aggregate diversi (anche nello stesso modulo) devono essere gestite con eventual consistency attraverso eventi di dominio.
Regole per il Design degli Aggregates
- Piccoli: un aggregate deve essere il più piccolo possibile. Aggrega solo ciò che deve essere consistente in una singola transazione
- Riferimenti per ID: gli aggregate non contengono riferimenti diretti ad altri aggregate, ma solo i loro ID
- Una transazione per aggregate: non modificare mai più aggregate nella stessa transazione
- Eventual consistency tra aggregate: usa eventi di dominio per coordinare cambiamenti tra aggregate diversi
// Design corretto degli Aggregates
// Order Aggregate - confine transazionale
public class Order {
private UUID id;
private UUID userId; // riferimento per ID, non User object
private UUID paymentId; // riferimento per ID, non Payment object
private List<OrderItem> items; // parte dell'aggregate
private OrderStatus status;
// Operazione transazionale: avviene tutta nello stesso aggregate
@Transactional
public void addItem(UUID productId, int quantity, Money price) {
// Verifica invarianti interne
if (this.status != OrderStatus.DRAFT) {
throw new IllegalStateException("Cannot modify confirmed order");
}
this.items.add(new OrderItem(productId, quantity, price));
this.recalculateTotal();
}
}
// SBAGLIATO: transazione che attraversa più aggregate
@Transactional
public void createOrderAndReserveStock(CreateOrderCmd cmd) {
Order order = orderRepo.save(Order.create(cmd));
// VIOLAZIONE: modifica un altro aggregate nella stessa transazione
inventoryService.reserveStock(order.getItems());
// Se reserveStock fallisce, tutto il rollback e complesso
}
// CORRETTO: eventual consistency tra aggregate
@Transactional
public void createOrder(CreateOrderCmd cmd) {
Order order = orderRepo.save(Order.create(cmd));
// Pubblica evento: il modulo Inventory reagira in modo asincrono
events.publish(new OrderCreatedEvent(order.getId(), order.getItems()));
}
Identificare i Bounded Contexts: Metodo Pratico
Identificare i bounded contexts corretti e tanto un'arte quanto una scienza. Ecco un metodo pratico in quattro passi:
- Event Storming: riunisci sviluppatori e domain expert. Identifica tutti gli eventi di dominio su post-it arancioni. Raggruppa gli eventi in cluster logici: ogni cluster e un potenziale bounded context
- Analisi del linguaggio: identifica dove lo stesso termine ha significati diversi. Ogni divergenza linguistica indica un confine di context
- Analisi delle dipendenze: mappa le dipendenze tra cluster. I cluster con poche dipendenze esterne sono buoni candidati per moduli indipendenti
- Validazione con il team: verifica che i confini identificati rispecchino la struttura organizzativa e le competenze dei team
La Legge di Conway
La legge di Conway afferma che le organizzazioni progettano sistemi che rispecchiano la struttura della propria comunicazione. In pratica: i confini dei moduli dovrebbero allinearsi con i confini dei team. Un team responsabile del modulo Order non dovrebbe avere bisogno di coordinarsi quotidianamente con il team del modulo Shipping per completare il proprio lavoro.
Se la struttura del codice non rispecchia la struttura dei team, si creano attriti: merge conflict frequenti, riunioni di coordinamento costanti, blocchi reciproci. Allineare architettura e organizzazione e un prerequisito per il successo del modular monolith.
Prossimo Articolo
Nel prossimo articolo affronteremo il tema del database design per modular monolith: schema condiviso vs schema per modulo, data ownership, gestione delle transazioni distribuite, e pattern come Saga e Event Sourcing per la consistenza eventuale. Tradurremo i bounded contexts identificati qui in strutture dati concrete.







