Progettare un sistema multi-tenant significa servire più clienti (tenant) con la stessa applicazione, garantendo isolamento, sicurezza e prestazioni. Applicare il Domain-Driven Design (DDD) in questo contesto aiuta a mantenere il dominio chiaro e a contenere la complessità: il modello non dovrebbe “sapere” troppo di come gestiamo la tenancy, ma deve essere protetto dalle contaminazioni tra tenant e dalle scorciatoie infrastrutturali.
In questo articolo vediamo un approccio pratico per Node.js (TypeScript), con esempi riusabili sia in Express/Fastify sia in framework più strutturati. L’obiettivo è costruire: un Tenant Context affidabile, repository che applicano automaticamente il filtro tenant, confini di dominio puliti, e un’integrazione coerente con eventi di dominio e outbox.
1. Multi-tenancy: cosa cambia davvero
“Multi-tenant” non è un’unica architettura. Le scelte principali ruotano attorno all’isolamento dei dati:
- Database per tenant: massimo isolamento; provisioning e costi più alti.
- Schema per tenant: buon isolamento logico; gestione schema e migrazioni più articolata.
- Tabelle condivise con tenant_id (row-level): semplice da scalare e gestire; richiede disciplina e/o RLS.
DDD entra in gioco perché il dominio non dovrebbe dipendere da come “partizioniamo” i dati. Tuttavia, l’isolamento del tenant è un requisito trasversale: deve essere applicato ovunque si leggano o scrivano dati, e deve essere testabile. Il rischio maggiore è il classico tenant leak: una query che dimentica il filtro e mostra dati di un altro tenant.
2. DDD in breve: dove mettere la tenancy
Nel DDD classico distinguiamo:
- Domain: entità, value object, aggregate, invarianti, eventi di dominio.
- Application: casi d’uso, orchestrazione, transazioni, autorizzazione, integrazione.
- Infrastructure: database, code, HTTP, cache, filesystem.
- Interfaces: controller, handler HTTP, API, CLI.
Il Tenant Context è informazione di “scopo” richiesta per eseguire un caso d’uso. È naturale gestirlo nell’Application Layer, e passarne un identificatore (o un oggetto di contesto) ai repository. Il dominio può ignorare completamente la multi-tenancy, a meno che il concetto di tenant faccia parte del linguaggio ubiquitario (ad esempio: “Organization” come Aggregate Root).
Regola pratica
Se “tenant” è solo un confine tecnico, trattalo come concern infrastrutturale applicato dai repository e dalla pipeline di richiesta. Se invece è un concetto di dominio (es. un’azienda che possiede utenti, progetti, ruoli), modellalo esplicitamente come Aggregate e usa la tenancy per isolare il contesto, non per “riparare” un dominio ambiguo.
3. Tenant Resolution: estrarre il tenant dalla richiesta
Serve una strategia deterministica per identificare il tenant. Le opzioni comuni:
- Subdomain:
tenantA.example.com - Path:
/t/tenantA/... - Header:
X-Tenant-Id(tipico per API interne o gateway) - Token: claim nel JWT (utile, ma attenzione a trust e revoca)
In genere conviene un approccio “trust boundary”: il client non deve poter inventare un tenant arbitrario. Per esempio, se usi JWT, il tenant dovrebbe essere assegnato dal server e firmato nel token, non passato liberamente in un header.
Middleware con AsyncLocalStorage
In Node.js, AsyncLocalStorage permette di propagare un contesto per-request senza passarlo manualmente in ogni funzione.
Questo è utile per applicare automaticamente il filtro tenant nei repository. Va usato con attenzione e testato, soprattutto in presenza
di code, job e parallelismo.
import { AsyncLocalStorage } from "node:async_hooks";
export type TenantId = string;
export interface RequestContext {
tenantId: TenantId;
requestId: string;
userId?: string;
}
export const requestContext = new AsyncLocalStorage<RequestContext>();
export function getContext(): RequestContext {
const ctx = requestContext.getStore();
if (!ctx) throw new Error("RequestContext non disponibile");
return ctx;
}
import type { Request, Response, NextFunction } from "express";
import crypto from "node:crypto";
import { requestContext } from "./request-context";
function resolveTenantId(req: Request): string {
// Esempio: subdomain
const host = req.headers.host ?? "";
const subdomain = host.split(".")[0];
if (!subdomain) throw new Error("Tenant non risolto");
return subdomain.toLowerCase();
}
export function contextMiddleware(req: Request, _res: Response, next: NextFunction) {
const tenantId = resolveTenantId(req);
const requestId = crypto.randomUUID();
requestContext.run({ tenantId, requestId }, () => next());
}
Se non vuoi usare AsyncLocalStorage, passa esplicitamente il contesto al layer Application. È più verboso ma più semplice da ragionare,
specialmente in codebase con molte integrazioni asincrone.
4. Modellare il dominio: Aggregate, invarianti e identificatori
Un errore frequente è aggiungere tenantId a ogni entità come fosse un dettaglio di persistenza. In DDD, l’identità degli oggetti
dipende dal dominio: a volte il tenant è parte dell’identità, a volte no.
- Se l’identificatore deve essere unico globalmente, puoi usare UUID e non includere tenant.
- Se l’identificatore è unico nel tenant (es. “numero ordine”), allora tenant o “organization” fanno parte del contesto di unicità.
Un approccio robusto è usare un Aggregate Root legato al tenant (es. Organization) e far discendere i confini da lì.
A livello dati, però, conviene mantenere sempre un tenant_id per ogni record “tenant-scoped”, anche quando l’identità è UUID, così da poter
filtrare e imporre vincoli.
export class Money {
private constructor(public readonly amount: number, public readonly currency: string) {
if (!Number.isFinite(amount)) throw new Error("Amount non valido");
if (!currency) throw new Error("Currency mancante");
}
static of(amount: number, currency = "EUR") {
return new Money(amount, currency);
}
}
export class OrderPlaced {
constructor(
public readonly orderId: string,
public readonly occurredAt: Date = new Date()
) {}
}
export class Order {
private events: unknown[] = [];
private constructor(
public readonly id: string,
private total: Money,
private status: "DRAFT" | "PLACED"
) {}
static draft(id: string) {
return new Order(id, Money.of(0), "DRAFT");
}
addLine(price: Money) {
if (this.status !== "DRAFT") throw new Error("Ordine non modificabile");
this.total = Money.of(this.total.amount + price.amount, this.total.currency);
}
place() {
if (this.status !== "DRAFT") throw new Error("Ordine già inviato");
if (this.total.amount <= 0) throw new Error("Totale non valido");
this.status = "PLACED";
this.events.push(new OrderPlaced(this.id));
}
pullEvents() {
const out = [...this.events];
this.events = [];
return out;
}
}
Nota: qui il dominio non vede tenantId. L’isolamento avviene altrove (repository e transazioni), evitando che ogni metodo
debba “trascinarsi” un parametro tenant.
5. Repository tenant-aware: il punto di controllo
Il repository è un candidato ideale per imporre invarianti di accesso ai dati (incluso il tenant). Anche se usi ORM come Prisma o TypeORM, la logica “tenant filter” dovrebbe essere centralizzata. Due pattern utili:
- Repository decorator: un wrapper che aggiunge sempre il filtro tenant.
- Tenant-scoped client: costruisci un client DB già “confinato” al tenant.
Esempio con Prisma: client per-tenant
Prisma non applica automaticamente filtri a tutte le query, quindi è importante disciplinare l’accesso. Una tecnica semplice è costruire
un’API di repository che richiede sempre il tenant e che internamente aggiunge tenantId a where.
import { PrismaClient } from "@prisma/client";
import { getContext } from "./request-context";
import { Order } from "../domain/order";
export class OrderRepository {
constructor(private prisma: PrismaClient) {}
async findById(id: string): Promise<Order | null> {
const { tenantId } = getContext();
const row = await this.prisma.order.findFirst({
where: { id, tenantId }
});
if (!row) return null;
const order = Order.draft(row.id);
// mappa stato/total dal database...
return order;
}
async save(order: Order): Promise<void> {
const { tenantId } = getContext();
// upsert tenant-safe
await this.prisma.order.upsert({
where: { tenantId_id: { tenantId, id: order.id } }, // indice composto consigliato
create: {
id: order.id,
tenantId,
status: "PLACED"
},
update: {
status: "PLACED"
}
});
}
}
Qui l’indice composto (tenantId, id) riduce i rischi: anche se qualcuno prova a leggere/scrivere con un id corretto
ma tenant sbagliato, la query non combacerà. Per una sicurezza ancora migliore, valuta il supporto del database:
ad esempio, Row Level Security (RLS) in PostgreSQL può impedire accessi cross-tenant anche in caso di bug applicativi.
6. Row Level Security in PostgreSQL: una rete di sicurezza
Se usi tabelle condivise con tenant_id, RLS può diventare il “guardiano finale”. L’idea è impostare una variabile di sessione per la
connessione (ad esempio app.tenant_id) e creare policy che consentono righe solo per quel tenant. In questo modo anche una query
dimenticata non vedrebbe dati di altri tenant.
-- Esempio concettuale (PostgreSQL)
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY orders_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id')::text);
-- Nella transazione/request:
-- SELECT set_config('app.tenant_id', 'tenantA', true);
L’implementazione concreta dipende dal driver e dal pooling: devi garantire che la variabile sia impostata per ogni richiesta e resettata correttamente. Se non hai bisogno di RLS, resta valido il filtro applicativo, ma RLS aiuta molto in sistemi con forte criticità di isolamento.
7. Application Layer: casi d’uso e transazioni
Un caso d’uso DDD non dovrebbe conoscere dettagli DB, ma deve coordinare:
- caricamento degli aggregate
- esecuzione delle regole di dominio
- salvataggio atomico
- pubblicazione di eventi (idealmente con outbox)
import { OrderRepository } from "../infrastructure/order-repository";
import { Order } from "../domain/order";
export class PlaceOrder {
constructor(private orders: OrderRepository) {}
async execute(input: { orderId: string }): Promise<void> {
const order = await this.orders.findById(input.orderId);
if (!order) throw new Error("Ordine non trovato");
order.place();
await this.orders.save(order);
// In un sistema reale, gli eventi vanno consegnati tramite outbox
const events = order.pullEvents();
// publish(events) ...
}
}
Nota: la tenancy qui è implicita nel contesto di richiesta. Se preferisci esplicitarla, fai accettare al caso d’uso un tenantId e
passalo ai repository. La scelta è di architettura, ma deve essere consistente.
8. Outbox e integrazioni: evitare eventi “fantasma” tra tenant
In multi-tenancy è facile perdere il riferimento del tenant quando pubblichi eventi verso message broker o webhook. Regola semplice: ogni evento integrativo deve includere il tenant (o un identificatore equivalente, come organizationId). Anche se il dominio interno non lo conosce, l’infrastruttura lo deve aggiungere al momento della persistenza o pubblicazione.
Il pattern Transactional Outbox salva evento e cambi dati nella stessa transazione; poi un worker legge l’outbox e pubblica. Questo evita la situazione in cui l’ordine è salvato ma l’evento non parte (o viceversa).
export interface OutboxMessage {
id: string;
tenantId: string;
type: string;
payload: unknown;
createdAt: Date;
publishedAt?: Date;
}
// Durante la stessa transazione di save(order):
// insert into outbox (tenantId, type, payload) values (...)
9. Migrazioni e provisioning tenant
Il provisioning dipende dal modello di isolamento:
| Modello | Provisioning | Migrazioni | Note |
|---|---|---|---|
| Database per tenant | crea DB, utenti, permessi | ripeti per ogni tenant | isolamento massimo, operazioni più lente |
| Schema per tenant | crea schema e permessi | per schema o con tool multi-schema | buon compromesso |
| Row-level (tenant_id) | inserisci record tenant | una sola migrazione | richiede filtri rigorosi o RLS |
Se scegli “DB per tenant”, automatizza: un servizio di provisioning, template di migrazione, e osservabilità. Se scegli row-level, investi in test e, quando possibile, in vincoli e policy DB.
10. Sicurezza: autenticazione, autorizzazione e confini
L’isolamento del tenant non basta: dentro un tenant devi controllare chi può fare cosa. In genere:
- Autenticazione: identifica l’utente (JWT, sessione, OIDC).
- Tenant binding: collega l’utente a uno o più tenant; il tenant attivo deriva dalla richiesta o dalla selezione utente.
- Autorizzazione: ruoli e permessi per risorse del dominio.
Evita di fidarti di un semplice X-Tenant-Id se l’utente può modificarlo. Se usi un gateway, fai in modo che il gateway imposti l’header
e lo renda non modificabile dal client pubblico. Oppure usa un claim firmato nel token e valida che l’utente appartenga a quel tenant.
11. Testing: prevenire tenant leak con test automatici
La multi-tenancy è un terreno perfetto per test di regressione mirati. Alcune idee:
- Integration test: crea due tenant, inserisci dati simili, verifica che ogni query veda solo il proprio.
- Contract test dei repository: per ogni metodo, assicurati che il filtro tenant sia applicato.
- Mutation test (facoltativo): simula bug rimuovendo il filtro tenant e verifica che i test falliscano.
import { requestContext } from "../infrastructure/request-context";
import { OrderRepository } from "../infrastructure/order-repository";
test("findById non deve leggere dati di un altro tenant", async () => {
const repo = new OrderRepository(prisma);
// seed: stesso id in tenant diversi, o id distinti con tenantId diverso
await prisma.order.create({ data: { id: "o1", tenantId: "t1", status: "PLACED" } });
await prisma.order.create({ data: { id: "o1", tenantId: "t2", status: "PLACED" } });
const orderT1 = await requestContext.run({ tenantId: "t1", requestId: "r1" }, () =>
repo.findById("o1")
);
const orderT2 = await requestContext.run({ tenantId: "t2", requestId: "r2" }, () =>
repo.findById("o1")
);
expect(orderT1?.id).toBe("o1");
expect(orderT2?.id).toBe("o1");
});
12. Checklist di progettazione
- Tenant resolution deterministica e protetta (non basata su input non fidato).
- Tenant context disponibile in modo coerente (passaggio esplicito o AsyncLocalStorage).
- Repository che applicano sempre il filtro tenant.
- Indici/chiavi composte su
(tenantId, id)o vincoli equivalenti. - Eventi integrativi con tenant incluso; preferire outbox.
- Osservabilità: log e metriche sempre taggati con tenant e requestId.
- Test automatici contro tenant leak.
Conclusione
Un’architettura multi-tenant ben progettata non è solo “aggiungere tenant_id al database”. Con DDD puoi mantenere il dominio pulito,
e spostare l’isolamento dove è più efficace: pipeline di richiesta, repository, vincoli DB e processi di integrazione. La chiave è rendere la tenancy
un’invariante verificabile: non un accordo implicito tra sviluppatori, ma una proprietà del sistema sostenuta da codice, policy e test.