Domain-Driven Design con Multi-Tenancy in Node.js

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.

Torna su