Il Domain-Driven Design (DDD) è un insieme di principi e pattern per progettare software partendo dal dominio del problema. Nel contesto web, dove API, frontend e sistemi distribuiti si intrecciano, il DDD aiuta a mantenere il codice allineato al linguaggio del business, a ridurre l’accoppiamento e a gestire complessità e cambiamenti continui.
Perché DDD nel web
- Allineamento con il business: API e UI rispecchiano concetti e regole del dominio.
- Scalabilità organizzativa: i Bounded Context riducono le dipendenze tra team.
- Manutenibilità: una struttura chiara del modello di dominio rende più facile evolvere funzionalità.
- Qualità delle API: contratti espliciti, coerenti e versionabili.
Mappare il DDD nelle tipiche architetture web
DDD | Responsabilità | Web |
---|---|---|
Application Layer | casi d’uso, orchestrazione | controller, handlers, orchestrazioni |
Domain Layer | modello, invarianti, eventi | entities, value objects, domain services |
Infrastructure Layer | persistenza, messaging, I/O | ORM, broker, HTTP client |
Boundary | contratti, anti-corruption | DTO, mapper, ACL, API gateway |
Progettare i Bounded Context
In un ecosistema web, i Bounded Context diventano naturali confini di deploy, versioning e ownership. Ogni contesto pubblica contratti ben definiti (REST/GraphQL/eventi) e gestisce i propri dati. Il Context Map esplicita le relazioni tra contesti (Partnership, Customer/Supplier, Conformist, Anti-Corruption Layer).
Esempio di Context Map (testuale)
Checkout BC --(Customer/Supplier)--> Pricing BC
Checkout BC --(ACL)--> Legacy ERP
Catalog BC --(Published Language)--> Search BC
Ubiquitous Language e API
Nel web, l’UL vive nei nomi delle rotte, nei tipi GraphQL, nei payload eventi e nei componenti UI. Evitare termini tecnici generici quando il dominio offre parole più precise.
- Restituire
OrderPlaced
invece diCreated
. - Endpoint come
POST /orders/{orderId}/confirm
riflettono comandi del dominio. - In GraphQL, tipi come
Price
conamount
ecurrency
come Value Object.
Aggregates: regole e dimensionamento
Gli Aggregates sono fondamentali per la coerenza. Nel web distribuito tendiamo a preferire Aggregates piccoli con limiti chiari e operazioni transazionali locali.
- Una sola radice: tutte le modifiche passano dall’Aggregate Root.
- Invarianti locali: garantite sincronicamente.
- Riferimenti esterni per id: niente oggetti annidati di altri Aggregates.
- Eventi come output: dopo una modifica valida, emettere eventi di dominio.
Repository e persistenza
I Repository espongono operazioni orientate al dominio (load, save, ricerche per specifiche). L’infrastruttura gestisce ORM, mapping e strategie come Outbox per consegna affidabile di eventi.
Servizi di dominio e applicativi
- Domain Service: incapsula regole pure (es. calcolo sconti) senza dipendere dall’I/O.
- Application Service: coordina casi d’uso: valida il comando, carica Aggregates, invoca metodi, persiste e pubblica eventi.
Eventi di dominio, integrazione e comunicazione
Gli eventi danno coesione al modello e abilitano integrazione asincrona tra Bounded Context.
- Event Carried State Transfer: includere nel payload le informazioni minime per sincronizzare proiezioni.
- Outbox Pattern: atomicità tra scrittura del dato e dell’evento.
- Idempotenza e deduplicazione: requisiti fondamentali lato consumer.
CQRS ed Event Sourcing
CQRS separa il modello di scrittura da quello di lettura, utile nel web per scalare query e UI ricche. Event Sourcing persiste la sequenza di eventi, ricostruendo lo stato a runtime o in snapshot. Non è obbligatorio: applicarlo solo quando serve audit, time-travel o invarianti complesse.
Interfacce web: REST, GraphQL e WebSocket
- REST per comandi espliciti e risorse stabili; usare verbi del dominio (
/orders/{id}/confirm
). - GraphQL per query flessibili e aggregazioni cross-contesto tramite gateway; evitare di esporre direttamente Entities, preferire DTO orientati alla lettura.
- WebSocket/Server-Sent Events per notifiche real-time di Domain Event rilevanti alla UI.
Front-end e DDD
Anche la UI trae beneficio da concetti DDD:
- State local al contesto: segmentare lo stato per sottodomini, evitando uno global store monolitico.
- Event-driven UI: aggiornare proiezioni locali in risposta a eventi dal backend.
- Terminologia consistente: componenti e route usano UL (es.
OrderSummary
,PricingBadge
).
Esempio minimale in TypeScript (Node) con invarianti ed eventi
// Domain: Order aggregate
type Currency = "EUR" | "USD";
class Money {
constructor(readonly amount: number, readonly currency: Currency) {
if (amount < 0) throw new Error("Negative amount");
}
add(other: Money) {
if (this.currency !== other.currency) throw new Error("Currency mismatch");
return new Money(this.amount + other.amount, this.currency);
}
}
type OrderId = string;
class OrderPlaced {
constructor(public readonly orderId: OrderId, public readonly total: Money) {}
}
class OrderLine {
constructor(readonly sku: string, readonly qty: number, readonly unitPrice: Money) {
if (qty <= 0) throw new Error("Invalid qty");
}
get lineTotal() {
return new Money(this.unitPrice.amount * this.qty, this.unitPrice.currency);
}
}
class Order {
private events: any[] = [];
private confirmed = false;
private _total: Money;
private constructor(readonly id: OrderId, readonly lines: OrderLine[]) {
const currency = lines[0].unitPrice.currency;
this._total = lines.reduce((acc, l) => acc.add(l.lineTotal), new Money(0, currency));
this.events.push(new OrderPlaced(this.id, this._total));
}
static place(id: OrderId, lines: OrderLine[]) {
if (lines.length === 0) throw new Error("Empty order");
return new Order(id, lines);
}
confirm() {
if (this.confirmed) throw new Error("Already confirmed");
this.confirmed = true;
}
pullDomainEvents() {
const out = [...this.events];
this.events = [];
return out;
}
get total() { return this._total; }
}
// Application service (use case)
async function placeOrder(cmd: {
id: OrderId; lines: { sku: string; qty: number; unitPrice: number; currency: Currency }[] }, repo: any, bus: any) {
const lines = cmd.lines.map(l => new OrderLine(l.sku, l.qty, new Money(l.unitPrice,l.currency)));
const order = Order.place(cmd.id, lines);
await repo.save(order); // transactional write + outbox
for (const e of order.pullDomainEvents()) await bus.publish(e);
return { orderId: order.id, total: order.total.amount, currency: order.total.currency };
}
Esempio di API DD (Express)
import express from "express";
const app = express();
app.use(express.json());
app.post("/orders/:id/confirm", async (req, res) => {
const id = req.params.id;
const order = await repo.byId(id);
try {
order.confirm();
await repo.save(order);
res.status(204).end();
} catch (err) {
if (String(err).includes("Already confirmed")) return res.status(409).json({ error: "Order already confirmed" });
res.status(400).json({ error: String(err) });
}
});
Esempio di repository con Outbox (concettuale)
class OrderRepository {
constructor(private readonly db: any) {}
async save(order: Order) {
await this.db.transaction(async (trx: any) => {
await trx("orders").upsert( /* ... */ );
const events = order.pullDomainEvents();
for (const e of events) await trx("outbox").insert({ type: e.constructor.name, payload: JSON.stringify(e) });
});
}
}
Strategie di integrazione tra contesti
- Anti-Corruption Layer: traduce contratti e modelli esterni nel linguaggio del tuo contesto.
- Published Language: schema condiviso e stabile per eventi o API tra contesti.
- Open-Host Service: un contesto espone un servizio standardizzato che altri usano senza accoppiarsi al suo modello interno.
Consistenza, resilienza e performance nel web
- Consistenza eventuale: accettabile per viste di lettura e notifiche; comunicare gli stati intermedi alla UI.
- Idempotenza: usare chiavi di deduplica e requestId per comandi riprovati.
- Timeout e backoff: obbligatori nelle chiamate tra servizi.
- Cache e proiezioni: materializzare letture pesanti (es. lista ordini) con invalidazioni guidate da eventi.
- Saga/Process Manager: coordinano transazioni distribuite attraverso eventi e comandi compensativi.
Testing guidato dal dominio
- Specification by Example: casi d’uso espressi nel linguaggio del dominio.
- Test “given-when-then” sugli Aggregates: input evento o stato iniziale, comando, eventi/emissioni attese.
- Contract testing per API tra contesti.
- Property-based per regole numeriche (prezzi, sconti, limiti).
Sicurezza e confini
- Autorizzazione nel dominio: regole come “chi può confermare un ordine” vivono nel Domain/Application layer.
- Multi-tenancy: il Tenant fa parte dell’identità o del contesto; evitare filtri “magici” in infrastruttura.
- Validazione a più livelli: DTO (forma), dominio (invarianti), persistenza (vincoli).
Osservabilità guidata dagli eventi
- Log strutturati che includono
aggregateId
ecorrelationId
. - Tracce distribuite legando comandi ed eventi.
- Metrice per tassi di fallimento, code backlog, tempi di proiezione.
Versioning e migrazioni
- API: compatibilità in retro, header di versione o URL versionato.
- Eventi: evolvere lo schema con upcaster o doppia pubblicazione temporanea.
- Database: migrazioni incrementali; per Event Sourcing, snapshot e replays controllati.
Monolite modulare o microservizi?
Il DDD non impone microservizi. Un monolite modulare con moduli allineati ai Bounded Context è spesso la scelta iniziale più economica. Si estrae un servizio solo quando emergono chiari motivi (autonomia di rilascio, scalabilità, confidenzialità dei dati, carichi molto diversi).
Passi operativi per introdurre DDD in un progetto web
- Event Storming iniziale: scoprire eventi e comandi chiave con i domain expert.
- Disegnare la Context Map: identificare relazioni e dipendenze.
- Definire UL: stabilire glossario condiviso e linee guida di naming per API e UI.
- Progettare Aggregates: fissare invarianti e confini.
- Stabilire contratti: DTO, schemi eventi, politiche di versioning.
- Implementare Outbox, idempotenza e tracing: fondamenta dell’affidabilità.
- Iterare con feedback: misurare aderenza tra modello e reali processi di business.
Anti-pattern comuni da evitare
- CRUD disguised as DDD: controller che espongono repository grezzi senza invarianti.
- Aggregates enormi: tutto in un’unica radice, lock e conflitti continui.
- UL “tradito” nell’infrastruttura: mapper che introducono termini diversi da quelli del dominio.
- Accoppiamento stretto tra contesti: dipendenze sincrone transitive che generano cascata di failure.
Checklist di revisione per pull request
- I nomi rispettano l’Ubiquitous Language?
- Le invarianti sono enforce nel dominio, non solo in controller o database?
- Il caso d’uso pubblica eventi necessari e sufficienti?
- Il repository è transazionale e supporta outbox?
- Endpoint e DTO rappresentano comandi e viste, non Entities grezze?
- Idempotenza e correlazione sono coperte?
Template di struttura di progetto (Node/TS)
src/
app/
use-cases/
controllers/
domain/
aggregates/
services/
events/
repositories/
infra/
orm/
messaging/
http/
shared/
types/
utils/
Conclusioni
Applicare DDD allo sviluppo web significa far emergere i confini giusti, usare un linguaggio condiviso e modellare il software attorno a regole e fatti del dominio. Con Bounded Context chiari, Aggregates piccoli, eventi affidabili e contratti ben versionati, le API restano comprensibili, i team autonomi e l’evoluzione del sistema meno rischiosa. Iniziare in modo modulare, misurare, e solo poi distribuire responsabilità su più servizi è spesso la via più sostenibile.