Progettare un sistema multi-tenant non significa solo “aggiungere un tenant_id ovunque”. Se vuoi mantenere il dominio espressivo, testabile e resistente al cambiamento, conviene affrontare la multi-tenancy come una preoccupazione architetturale che attraversa bounded context, persistenza, sicurezza e osservabilità, senza contaminare inutilmente la logica di business.
In questo articolo vediamo come applicare i principi di Domain-Driven Design (DDD) in un’applicazione Python multi-tenant, con esempi concreti: modellazione del dominio, isolamento dei dati (strategie e trade-off), infrastruttura (repository, unit of work, middleware), e controlli di sicurezza per evitare fughe di dati tra tenant.
1. Obiettivi e vincoli della multi-tenancy
Prima di modellare, chiarisci i requisiti. Le differenze tra progetti multi-tenant sono spesso qui:
- Isolamento: quanto deve essere forte? (nessuna commistione a livello DB, o basta a livello logico?)
- Personalizzazioni: ogni tenant ha configurazioni, regole, piani tariffari, feature flag?
- Scalabilità: i tenant sono tanti e piccoli o pochi e grandi?
- Compliance: requisiti di data residency, audit, crittografia, retention.
- Operatività: migrazioni, backup/restore per tenant, onboarding/offboarding.
DDD non risolve automaticamente questi aspetti, ma ti aiuta a separare correttamente le responsabilità: il dominio resta pulito, mentre la multi-tenancy viene gestita da infrastruttura e politiche applicative.
2. Strategie di isolamento dati
In pratica, le strategie più comuni sono tre. Non esiste “la migliore”; esiste la migliore per i tuoi vincoli.
2.1 Database per tenant
- Pro: isolamento massimo; backup/restore per tenant semplice; limiti di blast radius.
- Contro: costo operativo; migrazioni moltiplicate; gestione connessioni.
2.2 Schema per tenant (PostgreSQL)
- Pro: buon isolamento; una singola istanza DB; gestione accessi per schema.
- Contro: migrazioni più complesse; tooling e ORM devono supportare bene.
2.3 Tabelle condivise con tenant_id
- Pro: operazioni e migrazioni più semplici; costo più basso; onboarding rapido.
- Contro: rischio di fuga dati se il filtro tenant è dimenticato; indici più complessi; alcune query diventano più pesanti.
DDD entra in gioco quando devi evitare che la scelta tecnica “invada” il modello. L’obiettivo è: le invarianti di dominio non devono dipendere dal fatto che i dati siano isolati per schema o per colonna.
3. Il tenant come concetto di dominio
Un errore tipico: trattare il tenant come un dettaglio infrastrutturale e poi scoprire che influenza regole di business (piani, limiti, pricing, permessi). Oppure il contrario: far “girare” il tenant ovunque nel dominio, creando rumore.
Una regola pratica:
- Se il tenant influenza comportamenti di business (limiti, policy, configurazioni), allora esiste nel dominio come concetto (es. Organization, Account, Workspace).
- Se serve solo per scoping dei dati e sicurezza, resta principalmente una preoccupazione applicativa/infrastrutturale.
In molti sistemi B2B, il tenant coincide con un’entità di dominio “Organizzazione”. In quel caso, la multi-tenancy è solo una forma di scoping per organizzazione, non una variabile magica.
4. DDD essenziale: Entità, Value Object, Aggregati
In DDD, l’aggregato è l’unità di coerenza transazionale. In multi-tenancy, un’altra regola pratica utile è: un aggregato non dovrebbe mai attraversare tenant. In altre parole, tutte le entità che appartengono a un aggregato devono essere “scopate” allo stesso tenant.
Modello di esempio: un sistema di ordini B2B in cui ogni organizzazione (tenant) gestisce clienti e ordini.
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from typing import NewType
from uuid import UUID, uuid4
TenantId = NewType("TenantId", UUID)
OrderId = NewType("OrderId", UUID)
CustomerId = NewType("CustomerId", UUID)
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: str = "EUR"
def __post_init__(self) -> None:
if self.amount < 0:
raise ValueError("Money.amount non può essere negativo")
@dataclass
class OrderLine:
sku: str
quantity: int
unit_price: Money
def subtotal(self) -> Money:
return Money(self.unit_price.amount * Decimal(self.quantity), self.unit_price.currency)
class Order:
def __init__(self, tenant_id: TenantId, customer_id: CustomerId) -> None:
self.id: OrderId = OrderId(uuid4())
self.tenant_id = tenant_id
self.customer_id = customer_id
self.lines: list[OrderLine] = []
self.created_at = datetime.utcnow()
self._status: str = "DRAFT"
@property
def status(self) -> str:
return self._status
def add_line(self, line: OrderLine) -> None:
if self._status != "DRAFT":
raise ValueError("Non puoi modificare un ordine non DRAFT")
if line.quantity <= 0:
raise ValueError("La quantità deve essere > 0")
self.lines.append(line)
def total(self) -> Money:
if not self.lines:
return Money(Decimal("0.00"))
currency = self.lines[0].unit_price.currency
total = sum((l.subtotal().amount for l in self.lines), Decimal("0.00"))
return Money(total, currency)
def submit(self) -> None:
if not self.lines:
raise ValueError("Un ordine non può essere inviato senza righe")
self._status = "SUBMITTED"
Notare: l’aggregato Order ha un tenant_id. Qui è lecito perché fa parte dell’identità di scoping: un ordine appartiene a un tenant. Evita però di propagare il tenant in ogni metodo se non serve. Molto spesso basta che l’aggregato lo contenga e che i repository lo usino per filtrare.
5. Tenant Context: portare il tenant dove serve (senza sporcare il dominio)
Il tenant viene tipicamente determinato a livello di richiesta (host, JWT claims, API key, path). Una volta ottenuto, va reso disponibile ai casi d’uso e ai repository. In Python, un approccio comune è usare contextvars per un contesto per-request, compatibile con async.
from __future__ import annotations
from contextvars import ContextVar
from dataclasses import dataclass
from typing import Optional
from uuid import UUID
_current_tenant: ContextVar[Optional[UUID]] = ContextVar("_current_tenant", default=None)
@dataclass(frozen=True)
class TenantContext:
tenant_id: UUID
def set_current_tenant(tenant_id: UUID) -> None:
_current_tenant.set(tenant_id)
def get_current_tenant() -> UUID:
tenant_id = _current_tenant.get()
if tenant_id is None:
raise RuntimeError("Tenant non impostato nel contesto corrente")
return tenant_id
Il dominio non deve conoscere contextvars. Il dominio riceve tenant_id quando crea aggregati, mentre l’infrastruttura (repository/DB) lo usa per filtrare.
6. Casi d’uso applicativi (Application Services)
Un application service coordina: input, autorizzazione, transazione, chiamate a repository e pubblicazione eventi. È il posto giusto per “legare” tenant context e dominio.
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from uuid import UUID
# dipendenze astratte (porte)
class Orders:
def add(self, order: "Order") -> None: ...
def get(self, order_id: UUID) -> "Order | None": ...
class UnitOfWork:
orders: Orders
def commit(self) -> None: ...
def rollback(self) -> None: ...
def __enter__(self) -> "UnitOfWork": ...
def __exit__(self, exc_type, exc, tb) -> None: ...
@dataclass(frozen=True)
class AddOrderLineCommand:
order_id: UUID
sku: str
quantity: int
unit_price: Decimal
currency: str = "EUR"
class OrderService:
def __init__(self, uow: UnitOfWork) -> None:
self._uow = uow
def create_order(self, customer_id: UUID) -> UUID:
tenant_id = get_current_tenant()
order = Order(tenant_id=tenant_id, customer_id=customer_id)
with self._uow:
self._uow.orders.add(order)
self._uow.commit()
return order.id
def add_order_line(self, cmd: AddOrderLineCommand) -> None:
with self._uow:
order = self._uow.orders.get(cmd.order_id)
if order is None:
raise KeyError("Ordine non trovato")
# difesa: l'ordine deve appartenere al tenant corrente
if order.tenant_id != get_current_tenant():
raise PermissionError("Accesso negato")
order.add_line(
OrderLine(
sku=cmd.sku,
quantity=cmd.quantity,
unit_price=Money(amount=cmd.unit_price, currency=cmd.currency),
)
)
self._uow.commit()
Nota la “difesa in profondità”: anche se il repository filtra per tenant, l’application service può verificare lo scoping su operazioni sensibili. Non è sempre necessario, ma è utile in ambienti ad alto rischio.
7. Repository e Unit of Work: enforce del filtro tenant
Il repository è un adattatore tra dominio e persistenza. In multi-tenancy su tabelle condivise, il repository deve essere l’ultimo guardiano: ogni query deve includere il tenant, e ogni inserimento deve impostarlo.
Di seguito un esempio semplificato con SQLAlchemy (stile 2.0), usando una session per transazione.
from __future__ import annotations
from dataclasses import asdict
from typing import Optional
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.orm import Session
# mapping ORM minimale (esempio)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import String, Integer, DateTime, ForeignKey
from datetime import datetime
class Base(DeclarativeBase):
pass
class OrderRow(Base):
__tablename__ = "orders"
id: Mapped[UUID] = mapped_column(primary_key=True)
tenant_id: Mapped[UUID] = mapped_column(index=True, nullable=False)
customer_id: Mapped[UUID] = mapped_column(index=True, nullable=False)
status: Mapped[str] = mapped_column(String(32), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
class OrderLineRow(Base):
__tablename__ = "order_lines"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
order_id: Mapped[UUID] = mapped_column(ForeignKey("orders.id"), index=True)
tenant_id: Mapped[UUID] = mapped_column(index=True, nullable=False)
sku: Mapped[str] = mapped_column(String(64), nullable=False)
quantity: Mapped[int] = mapped_column(Integer, nullable=False)
unit_price_amount: Mapped[str] = mapped_column(String(32), nullable=False)
unit_price_currency: Mapped[str] = mapped_column(String(8), nullable=False)
class SqlAlchemyOrdersRepository:
def __init__(self, session: Session) -> None:
self._session = session
def add(self, order: Order) -> None:
tenant_id = get_current_tenant()
if order.tenant_id != tenant_id:
raise ValueError("Tenant mismatch: tentativo di salvare un aggregato di un altro tenant")
self._session.add(
OrderRow(
id=order.id,
tenant_id=order.tenant_id,
customer_id=order.customer_id,
status=order.status,
created_at=order.created_at,
)
)
for line in order.lines:
self._session.add(
OrderLineRow(
order_id=order.id,
tenant_id=order.tenant_id,
sku=line.sku,
quantity=line.quantity,
unit_price_amount=str(line.unit_price.amount),
unit_price_currency=line.unit_price.currency,
)
)
def get(self, order_id: UUID) -> Optional[Order]:
tenant_id = get_current_tenant()
order_row = self._session.execute(
select(OrderRow).where(OrderRow.id == order_id, OrderRow.tenant_id == tenant_id)
).scalar_one_or_none()
if order_row is None:
return None
# ricostruzione dell'aggregato (mapping inverso semplificato)
order = Order(tenant_id=TenantId(order_row.tenant_id), customer_id=CustomerId(order_row.customer_id))
order.id = OrderId(order_row.id) # attenzione: qui aggiriamo l'init, in un progetto reale preferisci factory
# carica righe
lines = self._session.execute(
select(OrderLineRow).where(OrderLineRow.order_id == order_id, OrderLineRow.tenant_id == tenant_id)
).scalars().all()
for lr in lines:
order.lines.append(
OrderLine(
sku=lr.sku,
quantity=lr.quantity,
unit_price=Money(amount=Decimal(lr.unit_price_amount), currency=lr.unit_price_currency),
)
)
return order
Due punti importanti:
- Filtro tenant obbligatorio su ogni query. Non delegare al chiamante.
- Tenant mismatch check in scrittura: se un bug prova a salvare un aggregato di un tenant diverso, meglio fallire subito.
8. Pattern di sicurezza: difesa in profondità
Il rischio principale di una multi-tenancy con tabelle condivise è un accesso non autorizzato a dati di un altro tenant. Riduci il rischio con più livelli:
- Livello applicativo: tenant dal contesto, controlli autorizzativi (RBAC/ABAC), verifiche su comandi sensibili.
- Livello repository: filtro tenant su query e scritture.
- Livello database: vincoli, indici, e se possibile Row-Level Security (RLS).
8.1 Vincoli e indici
Con tabelle condivise, conviene progettare chiavi e vincoli pensando al tenant. Alcuni esempi:
- Chiavi composte (tenant_id, id) oppure un id globale ma con vincoli di unicità che includono tenant.
- Indici su (tenant_id, colonne di ricerca frequente) per evitare scansioni cross-tenant.
8.2 PostgreSQL Row-Level Security (RLS)
Se usi PostgreSQL, RLS permette di far rispettare al DB lo scoping tenant. Il principio è: l’app imposta una variabile di sessione (es. app.current_tenant) e le policy filtrano automaticamente.
-- Abilita RLS
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- Policy: consenti accesso solo alle righe del tenant corrente
CREATE POLICY orders_tenant_isolation
ON orders
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- Ricorda di impostare la variabile per ogni connessione/sessione
-- SET app.current_tenant = '...';
In Python, devi impostare SET app.current_tenant all’inizio della transazione o quando acquisisci una connessione dal pool. Con SQLAlchemy puoi farlo in un hook (es. event.listen) o nel costruttore della Unit of Work.
from sqlalchemy import text
def set_tenant_on_connection(session: Session) -> None:
tenant_id = get_current_tenant()
session.execute(text("SET app.current_tenant = :t"), {"t": str(tenant_id)})
RLS non elimina la necessità del filtro a livello repository, ma riduce drasticamente l’impatto di un bug: anche se dimentichi un WHERE tenant_id = ..., il DB non restituirà righe di altri tenant.
9. Multi-tenancy e Bounded Context
In DDD, i bounded context delimitano modelli e linguaggi. La multi-tenancy può attraversarli, ma non necessariamente in modo identico. Esempi:
- Nel contesto Billing, il tenant può avere piani, fatture, limiti.
- Nel contesto Orders, il tenant “scopa” ordini e cataloghi.
- Nel contesto Identity & Access, il tenant è centrale: utenti, ruoli, policy.
Quando un tenant cambia piano o policy, non vuoi che il dominio “Orders” dipenda direttamente da tabelle di “Billing”. Usa integrazioni tramite eventi o un Anti-Corruption Layer: “Orders” riceve un fatto già tradotto (es. TenantPlanChanged) e applica regole locali.
10. Eventi di dominio e integrazione per tenant
In un sistema multi-tenant, gli eventi devono includere lo scoping. È una buona pratica che ogni evento contenga il tenant, così puoi:
- consumare eventi in modo isolato (code per tenant, partizioni, consumer group),
- applicare policy di retention e audit per tenant,
- eseguire replay mirati.
from dataclasses import dataclass
from uuid import UUID
@dataclass(frozen=True)
class DomainEvent:
tenant_id: UUID
@dataclass(frozen=True)
class OrderSubmitted(DomainEvent):
order_id: UUID
total_amount: str
currency: str
Evita di pubblicare eventi “globali” senza tenant, a meno che non rappresentino davvero un fatto globale (es. “nuova versione del software disponibile”).
11. Onboarding, migrazioni, e operatività
La multi-tenancy ha un costo operativo. DDD ti aiuta a mantenere il codice ordinato, ma devi anche progettare processi.
11.1 Onboarding tenant
- Database per tenant: provisioning DB, credenziali, migrazioni iniziali.
- Schema per tenant: creazione schema e migrazioni su schema.
- Tabelle condivise: inserimento record “tenant” e configurazioni iniziali.
11.2 Migrazioni
Con schema-per-tenant o DB-per-tenant, le migrazioni devono iterare su più target. Serve automation, e spesso una strategia rolling. Con tabelle condivise, le migrazioni sono più semplici ma richiedono attenzione a indici e lock su tabelle grandi.
11.3 Backup e restore
Se prevedi restore per tenant, database-per-tenant è il più semplice. Con tabelle condivise, potresti dover estrarre solo le righe di un tenant o costruire strumenti ad hoc.
12. Testing: prevenire regressioni cross-tenant
Oltre ai test di dominio “puri”, aggiungi test che verificano l’isolamento. Un approccio efficace è costruire una suite che:
- crea dati identici per due tenant diversi,
- verifica che ogni query e caso d’uso non “veda” l’altro tenant.
import pytest
from uuid import uuid4
def test_order_isolation_by_tenant(uow_factory):
tenant_a = uuid4()
tenant_b = uuid4()
# crea un ordine nel tenant A
set_current_tenant(tenant_a)
with uow_factory() as uow:
service = OrderService(uow)
order_id = service.create_order(customer_id=uuid4())
# provi a leggerlo dal tenant B: deve fallire (None o PermissionError)
set_current_tenant(tenant_b)
with uow_factory() as uow:
repo = uow.orders
assert repo.get(order_id) is None
Se usi RLS, includi test di integrazione che verificano che il DB blocchi accessi non scopati, anche quando la query non filtra esplicitamente.
13. Osservabilità: tracciare per tenant
In produzione, quando qualcosa va storto, quasi sempre vuoi sapere “per quale tenant”. Integra il tenant in:
- log (campo strutturato
tenant_id), - tracing (tag/span attribute),
- metriche (attenzione alle cardinalità: meglio raggruppare per tier/segmento se i tenant sono moltissimi),
- audit (chi ha fatto cosa, quando, su quali risorse, per quale tenant).
Un antipattern: aggiungere il tenant come stringa in ogni messaggio di log non strutturato. Preferisci log JSON/strutturati, così puoi filtrare e correlare con facilità.
14. Checklist pratica
- Il tenant è determinato in modo affidabile (JWT/host/API key) e validato.
- Esiste un Tenant Context per-request, testabile e compatibile con async.
- I repository applicano sempre il filtro tenant, e le scritture verificano mismatch.
- Se possibile, usa protezioni a livello DB (vincoli, RLS, ruoli/permessi).
- Gli eventi includono sempre il tenant (salvo eventi globali reali).
- Test automatici coprono scenari cross-tenant e regressioni.
- Logging/tracing includono
tenant_idsenza esplodere la cardinalità delle metriche. - Processi operativi (migrazioni, backup, onboarding) sono progettati insieme al modello.
Conclusione
Domain-Driven Design e multi-tenancy possono convivere bene se tieni separati i livelli: il dominio rimane focalizzato su invarianti e comportamenti, mentre la multi-tenancy viene applicata tramite contesto applicativo, repository rigorosi e difesa in profondità nel database.
Il risultato è un sistema più sicuro (meno fughe di dati), più manutenibile (meno inquinamento del modello) e più evolvibile (bounded context indipendenti, integrazioni tramite eventi).