Domain-Driven Design per Applicazioni Web
Il Domain-Driven Design (DDD), introdotto da Eric Evans nel 2003, è un approccio alla progettazione software che pone il dominio di business al centro di ogni decisione architetturale. In un ecosistema web dominato da framework e librerie, DDD offre una bussola per costruire applicazioni la cui struttura rispecchia fedelmente il linguaggio e i processi del business che servono.
In questo articolo esploreremo come applicare DDD in un contesto web con TypeScript, coprendo sia il livello strategico (Bounded Contexts, Context Maps) sia quello tattico (Entities, Value Objects, Aggregates, Domain Events). Useremo un dominio e-commerce come esempio concreto per illustrare ogni concetto.
Cosa Imparerai
- DDD Strategico: Bounded Contexts, Context Maps, Ubiquitous Language
- DDD Tattico: Entities, Value Objects, Aggregates, Repositories
- Domain Events e comunicazione tra contesti
- Implementazione pratica in TypeScript
- Struttura di un progetto Angular/Node.js con DDD
- Collaborazione con domain experts
DDD Strategico
Il DDD strategico si occupa del quadro d'insieme: come suddividere un sistema complesso in parti gestibili e come queste parti comunicano tra loro. È il livello che ha il maggiore impatto sull'architettura complessiva del sistema.
Ubiquitous Language
L'Ubiquitous Language è il vocabolario condiviso tra sviluppatori e domain experts. Ogni termine ha un significato preciso e univoco all'interno di un contesto. Il codice deve riflettere questo linguaggio: i nomi delle classi, dei metodi e delle variabili devono corrispondere ai termini usati dal business.
Esempio: E-commerce
Nel contesto Catalogo, un "Prodotto" ha nome, descrizione, categoria e immagini. Nel contesto Inventario, lo stesso concetto fisico diventa un "Articolo" con giacenza, soglia di riordino e magazzino di appartenenza. Usare lo stesso termine per concetti diversi genera confusione; il Ubiquitous Language risolve questa ambiguità definendo un vocabolario specifico per ogni contesto.
Bounded Contexts
Un Bounded Context è un confine esplicito all'interno del quale un modello di dominio ha un significato preciso. Ogni Bounded Context ha il proprio Ubiquitous Language, le proprie entità, e potenzialmente il proprio database e team di sviluppo.
+------------------+ +------------------+ +------------------+
| CATALOGO | | ORDINI | | SPEDIZIONI |
| | | | | |
| - Prodotto | | - Ordine | | - Spedizione |
| - Categoria | | - RigaOrdine | | - Pacco |
| - Prezzo | | - Cliente | | - Corriere |
| - Recensione | | - Pagamento | | - Tracking |
| | | | | |
+--------+---------+ +--------+---------+ +--------+---------+
| | |
+-------------------------+-------------------------+
|
Context Map (relazioni)
Context Map
La Context Map descrive le relazioni tra Bounded Contexts. Definisce come i contesti comunicano, chi dipende da chi e quali pattern di integrazione vengono utilizzati.
| Relazione | Descrizione | Esempio |
|---|---|---|
| Shared Kernel | Due contesti condividono un sottoinsieme del modello | Catalogo e Ordini condividono ProductId |
| Customer-Supplier | Un contesto fornisce dati, l'altro li consuma | Catalogo (supplier) fornisce info prodotto a Ordini (customer) |
| Anti-Corruption Layer | Un layer di traduzione protegge un contesto da modelli esterni | Integrazione con gateway di pagamento esterno |
| Published Language | Un formato standard per la comunicazione tra contesti | Domain Events serializzati in JSON con schema definito |
| Conformist | Un contesto adotta il modello di un altro senza traduzione | Contesto di reporting che usa direttamente il modello ordini |
DDD Tattico
Il DDD tattico fornisce i building blocks per implementare il modello di dominio all'interno di un Bounded Context. Questi pattern definiscono come strutturare entità, valori, aggregati e servizi.
Entities
Un'Entity è un oggetto definito dalla sua identità, non dai suoi attributi. Due entità con gli stessi dati ma identità diverse sono oggetti distinti. L'identità persiste nel tempo anche quando gli attributi cambiano.
// domain/entities/order.entity.ts
export class OrderId {
constructor(public readonly value: string) {
if (!value || value.trim().length === 0) {
throw new Error('OrderId non può essere vuoto');
}
}
equals(other: OrderId): boolean {
return this.value === other.value;
}
toString(): string {
return this.value;
}
}
export class Order {
private _items: OrderLine[] = [];
private _domainEvents: DomainEvent[] = [];
constructor(
public readonly id: OrderId,
public readonly customerId: CustomerId,
private _status: OrderStatus = OrderStatus.CREATED,
private _createdAt: Date = new Date()
) {}
get status(): OrderStatus {
return this._status;
}
get items(): ReadonlyArray<OrderLine> {
return [...this._items];
}
get totalAmount(): Money {
return this._items.reduce(
(total, line) => total.add(line.subtotal),
Money.zero('EUR')
);
}
addLine(productId: ProductId, quantity: Quantity, unitPrice: Money): void {
if (this._status !== OrderStatus.CREATED) {
throw new DomainError('Impossibile aggiungere righe a un ordine confermato');
}
const existingLine = this._items.find(
line => line.productId.equals(productId)
);
if (existingLine) {
throw new DomainError('Prodotto già presente nell ordine');
}
this._items.push(new OrderLine(productId, quantity, unitPrice));
}
confirm(): void {
if (this._items.length === 0) {
throw new DomainError('Impossibile confermare un ordine vuoto');
}
if (this._status !== OrderStatus.CREATED) {
throw new DomainError('L ordine è già stato confermato');
}
this._status = OrderStatus.CONFIRMED;
this._domainEvents.push(
new OrderConfirmedEvent(this.id, this.customerId, this.totalAmount)
);
}
pullDomainEvents(): DomainEvent[] {
const events = [...this._domainEvents];
this._domainEvents = [];
return events;
}
}
Value Objects
I Value Objects sono oggetti immutabili definiti interamente dal loro valore. Non hanno identità propria: due Value Objects con gli stessi attributi sono identici. Incapsulano regole di validazione e comportamento legato al valore che rappresentano.
// domain/value-objects/money.ts
export class Money {
private constructor(
public readonly amount: number,
public readonly currency: string
) {
if (amount < 0) {
throw new DomainError('L importo non può essere negativo');
}
}
static of(amount: number, currency: string): Money {
return new Money(Math.round(amount * 100) / 100, currency);
}
static zero(currency: string): Money {
return new Money(0, currency);
}
add(other: Money): Money {
this.ensureSameCurrency(other);
return Money.of(this.amount + other.amount, this.currency);
}
multiply(factor: number): Money {
return Money.of(this.amount * factor, this.currency);
}
equals(other: Money): boolean {
return this.amount === other.amount
&& this.currency === other.currency;
}
private ensureSameCurrency(other: Money): void {
if (this.currency !== other.currency) {
throw new DomainError(
`Valute diverse: ${this.currency} vs ${other.currency}`
);
}
}
toString(): string {
return `${this.amount.toFixed(2)} ${this.currency}`;
}
}
// domain/value-objects/quantity.ts
export class Quantity {
constructor(public readonly value: number) {
if (!Number.isInteger(value) || value <= 0) {
throw new DomainError('La quantità deve essere un intero positivo');
}
}
add(other: Quantity): Quantity {
return new Quantity(this.value + other.value);
}
equals(other: Quantity): boolean {
return this.value === other.value;
}
}
Aggregates
Un Aggregate è un cluster di entità e value objects trattato come una singola unita per le operazioni di modifica dei dati. Ogni aggregate ha una Aggregate Root: l'unica entità attraverso cui il mondo esterno può interagire con l'aggregate. Le regole di business che coinvolgono più oggetti all'interno dell'aggregate sono garantite dalla root.
Regole degli Aggregates
- L'accesso esterno avviene sempre e solo attraverso l'Aggregate Root
- Le invarianti di business sono mantenute all'interno del confine dell'aggregate
- Un'operazione di persistenza salva l'intero aggregate atomicamente
- I riferimenti tra aggregate avvengono tramite identità (ID), non per referenza diretta
- Gli aggregate devono essere il più piccoli possibile
// domain/aggregates/cart.aggregate.ts
export class Cart {
private _items: Map<string, CartItem> = new Map();
private _domainEvents: DomainEvent[] = [];
constructor(
public readonly id: CartId,
public readonly customerId: CustomerId
) {}
get items(): CartItem[] {
return Array.from(this._items.values());
}
get itemCount(): number {
return this._items.size;
}
get totalAmount(): Money {
return this.items.reduce(
(total, item) => total.add(item.subtotal),
Money.zero('EUR')
);
}
addProduct(productId: ProductId, quantity: Quantity, unitPrice: Money): void {
const key = productId.value;
if (this._items.has(key)) {
const existing = this._items.get(key)!;
this._items.set(key, existing.increaseQuantity(quantity));
} else {
this._items.set(key, new CartItem(productId, quantity, unitPrice));
}
this._domainEvents.push(
new ProductAddedToCartEvent(this.id, productId, quantity)
);
}
removeProduct(productId: ProductId): void {
const key = productId.value;
if (!this._items.has(key)) {
throw new DomainError('Prodotto non presente nel carrello');
}
this._items.delete(key);
this._domainEvents.push(
new ProductRemovedFromCartEvent(this.id, productId)
);
}
checkout(): Order {
if (this._items.size === 0) {
throw new DomainError('Il carrello è vuoto');
}
const orderId = new OrderId(crypto.randomUUID());
const order = new Order(orderId, this.customerId);
for (const item of this.items) {
order.addLine(item.productId, item.quantity, item.unitPrice);
}
this._items.clear();
this._domainEvents.push(new CartCheckedOutEvent(this.id, orderId));
return order;
}
pullDomainEvents(): DomainEvent[] {
const events = [...this._domainEvents];
this._domainEvents = [];
return events;
}
}
Domain Events
I Domain Events rappresentano fatti significativi che sono accaduti nel dominio. Sono immutabili, datati e trasportano le informazioni necessarie per comunicare il cambiamento ad altri contesti o componenti del sistema.
// domain/events/domain-event.ts
export interface DomainEvent {
readonly eventType: string;
readonly occurredOn: Date;
readonly aggregateId: string;
}
// domain/events/order-confirmed.event.ts
export class OrderConfirmedEvent implements DomainEvent {
readonly eventType = 'OrderConfirmed';
readonly occurredOn = new Date();
constructor(
public readonly aggregateId: string,
public readonly orderId: OrderId,
public readonly customerId: CustomerId,
public readonly totalAmount: Money
) {
this.aggregateId = orderId.value;
}
}
// domain/events/product-added-to-cart.event.ts
export class ProductAddedToCartEvent implements DomainEvent {
readonly eventType = 'ProductAddedToCart';
readonly occurredOn = new Date();
constructor(
public readonly cartId: CartId,
public readonly productId: ProductId,
public readonly quantity: Quantity
) {}
get aggregateId(): string {
return this.cartId.value;
}
}
Repository
Il Repository fornisce l'astrazione per la persistenza degli aggregate. Opera sempre sull'intero aggregate, non sulle singole entità interne.
// domain/repositories/order.repository.ts
export interface OrderRepository {
nextId(): OrderId;
findById(id: OrderId): Promise<Order | null>;
findByCustomer(customerId: CustomerId): Promise<Order[]>;
save(order: Order): Promise<void>;
}
// infrastructure/repositories/order-api.repository.ts
export class OrderApiRepository implements OrderRepository {
constructor(private readonly http: HttpClient) {}
nextId(): OrderId {
return new OrderId(crypto.randomUUID());
}
async findById(id: OrderId): Promise<Order | null> {
const response = await firstValueFrom(
this.http.get<OrderApiDto>(`/api/orders/${id.value}`)
);
return response ? OrderApiMapper.toDomain(response) : null;
}
async findByCustomer(customerId: CustomerId): Promise<Order[]> {
const response = await firstValueFrom(
this.http.get<OrderApiDto[]>(
`/api/orders?customer=${customerId.value}`
)
);
return response.map(dto => OrderApiMapper.toDomain(dto));
}
async save(order: Order): Promise<void> {
const dto = OrderApiMapper.toApi(order);
await firstValueFrom(
this.http.put(`/api/orders/${order.id.value}`, dto)
);
}
}
Domain Services
I Domain Services contengono logica di business che non appartiene naturalmente a nessuna entità o value object. Spesso coordinano operazioni che coinvolgono più aggregate.
// domain/services/pricing.service.ts
export class PricingService {
calculateDiscount(
order: Order,
customerTier: CustomerTier
): Money {
const total = order.totalAmount;
if (customerTier === CustomerTier.PREMIUM && total.amount > 100) {
return total.multiply(0.15); // 15% di sconto per clienti premium
}
if (total.amount > 200) {
return total.multiply(0.10); // 10% per ordini sopra 200 EUR
}
return Money.zero(total.currency);
}
applyTax(amount: Money, taxRate: number): Money {
return amount.multiply(1 + taxRate);
}
}
Struttura del Progetto con DDD
La struttura delle cartelle riflette i Bounded Contexts e i building blocks tattici. Ogni contesto è autonomo e contiene tutto il necessario per funzionare.
src/
bounded-contexts/
catalog/
domain/
entities/
product.entity.ts
value-objects/
product-name.ts
price.ts
repositories/
product.repository.ts
events/
product-created.event.ts
application/
commands/
create-product.command.ts
queries/
get-product.query.ts
infrastructure/
repositories/
product-api.repository.ts
mappers/
product-api.mapper.ts
orders/
domain/
entities/
order.entity.ts
order-line.entity.ts
value-objects/
money.ts
quantity.ts
order-id.ts
aggregates/
order.aggregate.ts
repositories/
order.repository.ts
services/
pricing.service.ts
events/
order-confirmed.event.ts
application/
commands/
create-order.command.ts
confirm-order.command.ts
queries/
get-order-by-id.query.ts
infrastructure/
repositories/
order-api.repository.ts
event-handlers/
send-confirmation-email.handler.ts
shipping/
domain/
...
application/
...
infrastructure/
...
shared-kernel/
domain/
value-objects/
email-address.ts
entity-id.ts
infrastructure/
event-bus/
event-bus.ts
Anti-Corruption Layer
Quando si integra con sistemi esterni (API di terze parti, sistemi legacy), l'Anti-Corruption Layer (ACL) traduce i modelli esterni nel linguaggio del nostro dominio, proteggendo il modello interno da contaminazioni.
// infrastructure/acl/payment-gateway.adapter.ts
// Modello esterno del gateway di pagamento
interface StripePaymentResponse {
id: string;
amount: number; // in centesimi
currency: string;
status: 'succeeded' | 'pending' | 'failed';
created: number; // timestamp Unix
}
// Il nostro modello di dominio
export class PaymentResult {
constructor(
public readonly transactionId: string,
public readonly amount: Money,
public readonly status: PaymentStatus,
public readonly processedAt: Date
) {}
}
// Anti-Corruption Layer: traduce tra i due modelli
export class PaymentGatewayAdapter {
constructor(private readonly stripeClient: StripeClient) {}
async processPayment(
orderId: OrderId,
amount: Money
): Promise<PaymentResult> {
// Traduce dal nostro modello a quello di Stripe
const stripeResponse = await this.stripeClient.charge({
amount: Math.round(amount.amount * 100), // Stripe usa centesimi
currency: amount.currency.toLowerCase(),
metadata: { order_id: orderId.value },
});
// Traduce dalla risposta di Stripe al nostro dominio
return this.toDomain(stripeResponse);
}
private toDomain(response: StripePaymentResponse): PaymentResult {
return new PaymentResult(
response.id,
Money.of(response.amount / 100, response.currency.toUpperCase()),
this.mapStatus(response.status),
new Date(response.created * 1000)
);
}
private mapStatus(stripeStatus: string): PaymentStatus {
const statusMap: Record<string, PaymentStatus> = {
'succeeded': PaymentStatus.COMPLETED,
'pending': PaymentStatus.PENDING,
'failed': PaymentStatus.FAILED,
};
return statusMap[stripeStatus] ?? PaymentStatus.UNKNOWN;
}
}
Comunicazione tra Bounded Contexts
I Bounded Contexts comunicano tra loro attraverso Domain Events e un Event Bus. Questa comunicazione asincrona mantiene i contesti disaccoppiati e permette a ciascuno di evolversi indipendentemente.
// shared-kernel/infrastructure/event-bus.ts
type EventHandler<T extends DomainEvent> = (event: T) => Promise<void>;
export class InMemoryEventBus {
private handlers = new Map<string, EventHandler<any>[]>();
subscribe<T extends DomainEvent>(
eventType: string,
handler: EventHandler<T>
): void {
const existing = this.handlers.get(eventType) || [];
existing.push(handler);
this.handlers.set(eventType, existing);
}
async publish(event: DomainEvent): Promise<void> {
const handlers = this.handlers.get(event.eventType) || [];
await Promise.all(
handlers.map(handler => handler(event))
);
}
async publishAll(events: DomainEvent[]): Promise<void> {
for (const event of events) {
await this.publish(event);
}
}
}
// Esempio: quando un ordine è confermato, il contesto Spedizioni reagisce
// shipping/application/event-handlers/on-order-confirmed.handler.ts
export class OnOrderConfirmedHandler {
constructor(private readonly shippingService: ShippingService) {}
async handle(event: OrderConfirmedEvent): Promise<void> {
await this.shippingService.createShipment(
event.orderId,
event.customerId
);
}
}
Errori Comuni nell'Adozione di DDD
| Errore | Conseguenza | Rimedio |
|---|---|---|
| Iniziare dal tattico senza il strategico | Bounded Contexts definiti male, modello confuso | Partire sempre dall'analisi del dominio con gli esperti |
| Aggregate troppo grandi | Conflitti di concorrenza, performance scarse | Seguire la regola: aggregate il più piccoli possibile |
| Trascurare l'Ubiquitous Language | Disallineamento tra codice e business, comunicazione difficile | Glossario condiviso, code review con focus sui nomi |
| DDD ovunque, anche per CRUD semplici | Over-engineering, complessità inutile | Applicare DDD solo ai contesti con logica complessa |
| Riferimenti diretti tra aggregate | Accoppiamento, impossibilita di scalare | Usare sempre identità (ID) per i riferimenti tra aggregate |
Quando Usare DDD
Guida alla Decisione
| Scenario | DDD Strategico | DDD Tattico |
|---|---|---|
| Dominio semplice, poche regole di business | Utile per la comunicazione | Non necessario |
| Dominio moderato, logica significativa | Consigliato | Selettivo (solo contesti complessi) |
| Dominio complesso, molte regole interdipendenti | Essenziale | Essenziale |
| Sistema legacy da modernizzare | Essenziale per la suddivisione | Progressivo, contesto per contesto |
Conclusione
Il Domain-Driven Design non è un framework ne una libreria: è un modo di pensare al software partendo dal problema di business anziche dalla tecnologia. Il DDD strategico ci aiuta a definire confini chiari e relazioni tra parti del sistema. Il DDD tattico ci fornisce strumenti concreti per modellare la logica di dominio in modo espressivo e manutenibile.
Applicato a TypeScript e Angular, DDD porta chiarezza nella struttura del progetto, migliora la comunicazione tra team tecnico e stakeholder, e produce codice che riflette fedelmente i processi di business. Come ogni approccio architetturale, richiede un investimento iniziale in analisi e design, ma i benefici in termini di manutenibilità e evoluzione del sistema sono significativi per progetti di media e alta complessità.
Domande frequenti
Qual è l'obiettivo principale del Domain-Driven Design (DDD) nella progettazione software?
L'obiettivo principale del DDD è porre il dominio di business al centro di ogni decisione architetturale, creando applicazioni la cui struttura rispecchia fedelmente il linguaggio e i processi del business che servono.
Che cos'è l'Ubiquitous Language e come si utilizza nel DDD?
L'Ubiquitous Language è il vocabolario condiviso tra sviluppatori e esperti di dominio, dove ogni termine ha un significato preciso e univoco all'interno di un contesto. Il codice deve riflettere questo linguaggio utilizzando nomi delle classi, dei metodi e delle variabili che corrispondono ai termini usati dal business.
Qual è la differenza tra Entities e Value Objects nel DDD?
Un'Entity è un oggetto definito dalla sua identità, non dai suoi attributi, mentre un Value Object è un oggetto immutabile definito interamente dal suo valore. Due entità con gli stessi dati ma identità diverse sono oggetti distinti, mentre due Value Objects con gli stessi attributi sono identici.
Come si comunicano i Bounded Contexts tra loro nel DDD?
I Bounded Contexts comunicano tra loro attraverso Domain Events e un Event Bus, mantenendo i contesti disaccoppiati e permettendo a ciascuno di evolversi indipendentemente.

Commenti
Caricamento commenti...
Accedi per commentare