Costruire un SaaS moderno significa spesso affrontare due sfide contemporaneamente: mantenere un modello di dominio ricco e coerente (Domain-Driven Design, DDD) e garantire l’isolamento tra clienti (multi-tenancy). In Java con Spring Boot, queste due esigenze possono convivere molto bene, ma richiedono scelte architetturali esplicite: dove “vive” il tenant nel modello, come si propaga lungo le dipendenze, come si applica l’isolamento a livello di persistenza e come si testa il tutto.
Questo articolo propone un approccio pratico e dettagliato: partiamo dai concetti DDD che contano davvero nel quotidiano, poi analizziamo le strategie di multi-tenancy più comuni e infine implementiamo una soluzione “tenant-aware” in Spring Boot con attenzione a sicurezza, consistenza transazionale, migrazioni e osservabilità.
Obiettivi
- Applicare DDD (bounded context, aggregate, invarianti, domain event) in un’applicazione Spring Boot.
- Supportare multi-tenancy con isolamento coerente e verificabile.
- Minimizzare la complessità: introdurre multi-tenancy senza “inquinare” ovunque il dominio.
- Definire un’implementazione concreta con codice riutilizzabile e testabile.
DDD in breve: cosa serve davvero
DDD non è una libreria, ma un modo di progettare. Per un progetto multi-tenant, alcuni concetti diventano particolarmente importanti:
- Bounded Context: confini espliciti del modello. Un tenant potrebbe avere regole diverse, ma è preferibile esprimerle come variabilità di dominio (policy) dentro lo stesso contesto o come contesti separati, non come if sparsi.
- Aggregates: cluster di entità e value object con invarianti protette. L’aggregate root è l’unico punto di modifica.
- Repository: interfaccia di collezione per aggregates, indipendente dalla tecnologia di storage.
- Domain Services: logica di dominio che non appartiene naturalmente a una singola entità/aggregate.
- Domain Events: fatti accaduti nel dominio utili per integrazione e consistenza eventuale tra bounded context.
Nel multi-tenant, l’invariante più frequente è: “un’operazione non deve mai leggere o scrivere dati di un tenant diverso”. Questo vincolo deve essere garantito a più livelli (applicazione, persistenza, sicurezza), e non solo “per convenzione”.
Multi-Tenancy: strategie e compromessi
Le opzioni principali sono tre. La scelta dipende da requisiti di isolamento, costi operativi e scalabilità.
1) Database per tenant
- Pro: isolamento massimo, facile cancellazione/backup per singolo tenant, blast radius ridotto.
- Contro: gestione più complessa (connessioni, migrazioni, provisioning), costi più alti con molti tenant.
- Quando: tenant enterprise, compliance stringente, dati molto sensibili.
2) Schema per tenant
- Pro: buon isolamento, meno overhead rispetto a DB separati, migrazioni gestibili (ma comunque più articolate).
- Contro: in PostgreSQL è comodo, in altri DB meno; gestione di centinaia/migliaia di schemi può diventare difficile.
- Quando: isolamento forte ma non vuoi un DB per tenant.
3) Tabelle condivise con discriminatore (tenant_id)
- Pro: più semplice operativamente, buona efficienza con tanti tenant, migrazioni uniche.
- Contro: isolamento logico (non fisico), rischio di query non filtrate, necessità di controlli rigorosi (RLS, filtri globali, test).
- Quando: SaaS con molti tenant e requisiti standard di isolamento logico.
In questo articolo useremo la strategia “tabelle condivise + tenant_id” perché è la più comune nei SaaS con molti tenant. Indicheremo anche come passare a soluzioni più forti quando serve.
Architettura proposta
Un buon equilibrio tra DDD e multi-tenancy prevede:
- Il dominio non deve dipendere da Spring o JPA.
- Il tenant non deve diventare un parametro che si trascina ovunque.
- La “tenant context” deve essere risolta una volta per richiesta e propagata ai layer inferiori in modo controllato.
- La persistenza deve applicare un filtro globale (o policy equivalente) per impedire letture/scritture cross-tenant.
Un modo pratico per ottenerlo:
- TenantId come value object (infrastruttura/app layer) e TenantContext in ThreadLocal (o alternativa reattiva).
- Un filtro HTTP che estrae il tenant da header/subdomain/JWT e valorizza il context.
- Un meccanismo di enforcement a livello di persistenza (Hibernate filter, interceptor, o RLS a livello DB).
- Test di integrazione che provano esplicitamente che una query senza tenant non torna nulla o fallisce.
Modellazione: TenantId e contesto
TenantId è un value object semplice: non “accetta” stringhe vuote, normalizza, e rende esplicito il concetto nel codice.
package com.example.multitenancy.common;
import java.util.Objects;
public final class TenantId {
private final String value;
private TenantId(String value) {
this.value = value;
}
public static TenantId of(String raw) {
if (raw == null) throw new IllegalArgumentException("TenantId nullo");
var v = raw.trim();
if (v.isEmpty()) throw new IllegalArgumentException("TenantId vuoto");
return new TenantId(v);
}
public String value() {
return value;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof TenantId other)) return false;
return Objects.equals(value, other.value);
}
@Override
public int hashCode() {
return Objects.hash(value);
}
@Override
public String toString() {
return value;
}
}
Il contesto del tenant per richiesta può essere mantenuto in un ThreadLocal. È una scelta ragionevole in applicazioni Spring MVC tradizionali. In ambiente reattivo (WebFlux) va usato il Reactor Context, non il ThreadLocal.
package com.example.multitenancy.common;
public final class TenantContext {
private static final ThreadLocal<TenantId> CURRENT = new ThreadLocal<>();
private TenantContext() {}
public static void set(TenantId tenantId) {
CURRENT.set(tenantId);
}
public static TenantId getRequired() {
var t = CURRENT.get();
if (t == null) throw new IllegalStateException("Tenant non impostato nel contesto");
return t;
}
public static TenantId getOrNull() {
return CURRENT.get();
}
public static void clear() {
CURRENT.remove();
}
}
Risoluzione tenant: header, subdomain, JWT
La sorgente del tenant dipende dal vostro modello di autenticazione e routing. Tre pattern comuni:
- Header (es.
X-Tenant): semplice e utile per API interne. Va protetto: un client non autenticato non deve poter scegliere un tenant arbitrario. - Subdomain (es.
tenantA.example.com): ottimo per SaaS B2B con UI web. Richiede gestione DNS/ingress. - JWT claim (es.
tenant): preferibile quando il token è emesso dal vostro IdP e “lega” l’utente a un tenant.
In generale: il tenant dovrebbe essere derivato da una credenziale verificata (JWT o sessione) e non accettato come input libero. Se usate l’header, consideratelo un “routing hint” da verificare contro l’identità autenticata.
Filtro Spring: estrazione e setup del TenantContext
Qui estraiamo il tenant da header e lo impostiamo per la durata della richiesta.
package com.example.multitenancy.web;
import com.example.multitenancy.common.TenantContext;
import com.example.multitenancy.common.TenantId;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
public class TenantResolutionFilter extends OncePerRequestFilter {
private final String headerName;
public TenantResolutionFilter(String headerName) {
this.headerName = headerName;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String rawTenant = request.getHeader(headerName);
if (rawTenant == null || rawTenant.isBlank()) {
response.sendError(400, "Header tenant mancante: " + headerName);
return;
}
TenantContext.set(TenantId.of(rawTenant));
filterChain.doFilter(request, response);
} finally {
TenantContext.clear();
}
}
}
Registrazione del filtro:
package com.example.multitenancy.config;
import com.example.multitenancy.web.TenantResolutionFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean<TenantResolutionFilter> tenantFilter() {
var reg = new FilterRegistrationBean<TenantResolutionFilter>();
reg.setFilter(new TenantResolutionFilter("X-Tenant"));
reg.setOrder(1); // prima possibile
return reg;
}
}
Enforcement a livello di persistenza
Se usate “tenant_id nelle tabelle”, il rischio maggiore è una query non filtrata. Ci sono varie contromisure:
- Hibernate Filter: applica automaticamente una clausola al SQL generato.
- Intercettore/StatementInspector: valida le query (utile ma più fragile).
- Row-Level Security (RLS) nel DB: molto robusto (specialmente PostgreSQL), ma aumenta complessità e richiede disciplina operativa.
Una soluzione pragmatica è combinare: filtro Hibernate + vincoli/indici + test automatici. Se avete requisiti di compliance elevati, valutate RLS.
Mapping con filtro Hibernate
Definiamo un’interfaccia per le entità “tenant-aware” e un mapping base con tenantId.
package com.example.multitenancy.persistence;
public interface TenantScopedEntity {
String getTenantId();
void setTenantId(String tenantId);
}
In una base entity JPA aggiungiamo il campo e un filtro globale. Notare: questa è infrastruttura, non dominio. Nel dominio useremo concetti più ricchi e mapperemo verso entità persistenti.
package com.example.multitenancy.persistence;
import org.hibernate.annotations.Filter;
import org.hibernate.annotations.FilterDef;
import org.hibernate.annotations.ParamDef;
import jakarta.persistence.Column;
import jakarta.persistence.MappedSuperclass;
@MappedSuperclass
@FilterDef(name = "tenantFilter", parameters = @ParamDef(name = "tenantId", type = String.class))
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public abstract class TenantBaseJpaEntity implements TenantScopedEntity {
@Column(name = "tenant_id", nullable = false, updatable = false, length = 64)
private String tenantId;
@Override
public String getTenantId() {
return tenantId;
}
@Override
public void setTenantId(String tenantId) {
this.tenantId = tenantId;
}
}
Abilitare il filtro a ogni sessione
Serve un punto centralizzato che, all’apertura della sessione Hibernate, abiliti il filtro con il tenant corrente. Un modo comune in Spring Boot è usare un HibernatePropertiesCustomizer o un interceptor. Qui usiamo un approccio basato su EntityManager e un interceptor per semplicità concettuale.
package com.example.multitenancy.persistence;
import com.example.multitenancy.common.TenantContext;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.hibernate.Session;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
@Component
public class TenantHibernateFilterEnabler {
@PersistenceContext
private EntityManager entityManager;
public void enableForCurrentTransaction() {
// Enabler idempotente: abilita una volta per transazione.
if (TransactionSynchronizationManager.isActualTransactionActive()) {
var key = TenantHibernateFilterEnabler.class.getName() + ".enabled";
if (TransactionSynchronizationManager.getResource(key) != null) return;
TransactionSynchronizationManager.bindResource(key, Boolean.TRUE);
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCompletion(int status) {
TransactionSynchronizationManager.unbindResourceIfPossible(key);
}
});
}
Session session = entityManager.unwrap(Session.class);
session.enableFilter("tenantFilter")
.setParameter("tenantId", TenantContext.getRequired().value());
}
}
Ora dobbiamo chiamare l’enabler in un punto sistematico prima di eseguire query. Un posto pratico è un AOP advice sui metodi dei repository o dei servizi applicativi.
package com.example.multitenancy.config;
import com.example.multitenancy.persistence.TenantHibernateFilterEnabler;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class TenantFilterAspect {
private final TenantHibernateFilterEnabler enabler;
public TenantFilterAspect(TenantHibernateFilterEnabler enabler) {
this.enabler = enabler;
}
@Before("within(@org.springframework.stereotype.Service *) || within(@org.springframework.stereotype.Repository *)")
public void enableTenantFilter() {
enabler.enableForCurrentTransaction();
}
}
Questo non è l’unico modo, ma è efficace: tutte le operazioni che passano dai service/repository avranno il filtro attivo. Per ridurre rischio, evitate accessi “diretti” all’EntityManager fuori da questi layer.
Creazione automatica del tenant_id in insert
Oltre a filtrare le query, dobbiamo impedire inserimenti con tenant errato o nullo. Per le entità persistenti, possiamo valorizzare tenantId in modo centralizzato usando callback JPA.
package com.example.multitenancy.persistence;
import com.example.multitenancy.common.TenantContext;
import jakarta.persistence.PrePersist;
public class TenantEntityListener {
@PrePersist
public void setTenant(Object entity) {
if (entity instanceof TenantScopedEntity scoped) {
if (scoped.getTenantId() == null) {
scoped.setTenantId(TenantContext.getRequired().value());
}
}
}
}
Colleghiamo il listener alle entità che estendono la base class:
package com.example.multitenancy.persistence.order;
import com.example.multitenancy.persistence.TenantBaseJpaEntity;
import com.example.multitenancy.persistence.TenantEntityListener;
import jakarta.persistence.*;
@Entity
@EntityListeners(TenantEntityListener.class)
@Table(name = "orders", indexes = {
@Index(name = "idx_orders_tenant", columnList = "tenant_id"),
@Index(name = "idx_orders_tenant_number", columnList = "tenant_id, order_number", unique = true)
})
public class OrderJpaEntity extends TenantBaseJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "order_number", nullable = false, length = 40)
private String orderNumber;
// altri campi...
public Long getId() { return id; }
public String getOrderNumber() { return orderNumber; }
public void setOrderNumber(String orderNumber) { this.orderNumber = orderNumber; }
}
Gli indici con tenant_id sono essenziali: proteggono performance e impediscono collisioni di chiavi naturali tra tenant (es. stesso order_number in tenant diversi).
Dominio “pulito”: aggregate e repository
Nel dominio, vogliamo evitare annotazioni JPA. Definiamo un aggregate Order con invarianti e value object. Il tenant non deve essere un campo “facoltativo” nel dominio: o è implicito nel contesto dell’operazione applicativa, oppure è parte dell’identità dell’aggregate. Per tabelle condivise è comune trattarlo come parte dell’identità persistente ma non sempre come concetto che l’utente del dominio manipola direttamente.
package com.example.domain.order;
import java.time.Instant;
import java.util.Objects;
public class Order {
private final OrderId id;
private final OrderNumber orderNumber;
private final Instant createdAt;
private Order(OrderId id, OrderNumber orderNumber, Instant createdAt) {
this.id = id;
this.orderNumber = orderNumber;
this.createdAt = createdAt;
}
public static Order createNew(OrderNumber orderNumber, Instant now) {
return new Order(OrderId.newId(), orderNumber, now);
}
public OrderId id() { return id; }
public OrderNumber orderNumber() { return orderNumber; }
public Instant createdAt() { return createdAt; }
// Esempio di invariante: l'orderNumber non può cambiare dopo la creazione.
// (nel caso reale potrebbero esserci invarianti più ricche)
}
final class OrderId {
private final String value;
private OrderId(String value) { this.value = value; }
public static OrderId newId() { return new OrderId(java.util.UUID.randomUUID().toString()); }
public String value() { return value; }
@Override public boolean equals(Object o){ return (o instanceof OrderId other) && Objects.equals(value, other.value); }
@Override public int hashCode(){ return Objects.hash(value); }
}
final class OrderNumber {
private final String value;
private OrderNumber(String value) { this.value = value; }
public static OrderNumber of(String raw) {
if (raw == null || raw.isBlank()) throw new IllegalArgumentException("OrderNumber vuoto");
return new OrderNumber(raw.trim());
}
public String value(){ return value; }
}
Il repository è un’interfaccia di dominio:
package com.example.domain.order;
import java.util.Optional;
public interface OrderRepository {
void save(Order order);
Optional<Order> findByOrderNumber(OrderNumber orderNumber);
}
L’implementazione concreta userà JPA e sarà automaticamente tenant-scoped dal filtro Hibernate. Il servizio applicativo può restare pulito, purché lavori in un contesto tenant correttamente risolto.
Implementazione repository: mapping dominio ↔ JPA
Per mantenere il dominio indipendente, introduciamo un mapper tra Order e OrderJpaEntity.
package com.example.multitenancy.persistence.order;
import com.example.domain.order.Order;
import com.example.domain.order.OrderNumber;
import java.time.Instant;
final class OrderMapper {
static OrderJpaEntity toJpa(Order domain) {
var jpa = new OrderJpaEntity();
jpa.setOrderNumber(domain.orderNumber().value());
// mappare altri campi...
return jpa;
}
static Order toDomain(OrderJpaEntity jpa) {
// In un progetto reale useresti OrderId persistente o un mapping dedicato
return Order.createNew(OrderNumber.of(jpa.getOrderNumber()), Instant.now());
}
}
Repository Spring Data (infrastruttura):
package com.example.multitenancy.persistence.order;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
interface OrderSpringDataRepository extends JpaRepository<OrderJpaEntity, Long> {
Optional<OrderJpaEntity> findByOrderNumber(String orderNumber);
}
Adapter verso l’interfaccia di dominio:
package com.example.multitenancy.persistence.order;
import com.example.domain.order.Order;
import com.example.domain.order.OrderNumber;
import com.example.domain.order.OrderRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public class JpaOrderRepository implements OrderRepository {
private final OrderSpringDataRepository repo;
public JpaOrderRepository(OrderSpringDataRepository repo) {
this.repo = repo;
}
@Override
public void save(Order order) {
repo.save(OrderMapper.toJpa(order));
}
@Override
public Optional<Order> findByOrderNumber(OrderNumber orderNumber) {
return repo.findByOrderNumber(orderNumber.value()).map(OrderMapper::toDomain);
}
}
Grazie al filtro, findByOrderNumber non potrà “vedere” ordini di un altro tenant, anche se lo stesso numero esiste altrove.
Applicazione: use case e transazioni
Nel DDD pragmatico, un Application Service coordina repository, domain service e integrazioni, mantenendo transazioni e autorizzazioni. Qui un esempio: creare un ordine.
package com.example.application.order;
import com.example.domain.order.Order;
import com.example.domain.order.OrderNumber;
import com.example.domain.order.OrderRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
@Service
public class CreateOrderUseCase {
private final OrderRepository orderRepository;
private final Clock clock;
public CreateOrderUseCase(OrderRepository orderRepository, Clock clock) {
this.orderRepository = orderRepository;
this.clock = clock;
}
@Transactional
public void create(String rawOrderNumber) {
var number = OrderNumber.of(rawOrderNumber);
// Regola di dominio applicata tramite repository tenant-scoped:
orderRepository.findByOrderNumber(number).ifPresent(o -> {
throw new IllegalStateException("Ordine già esistente: " + number.value());
});
var order = Order.createNew(number, clock.instant());
orderRepository.save(order);
}
}
Osservazione importante: la regola “unicità per tenant” va sostenuta anche dal DB (indice unico su tenant_id, order_number). Il controllo applicativo riduce gli errori “user friendly”, ma l’indice è la garanzia finale contro race condition.
Sicurezza: legare identità e tenant
Una multi-tenancy sicura richiede che il tenant non sia solo un “selettore” ma sia autorizzato. Alcune regole pratiche:
- Il tenant deve essere derivato dall’identità autenticata (es. claim nel JWT) oppure verificato contro i permessi dell’utente.
- Se esponi
X-Tenant, non fidarti: verifica che l’utente abbia accesso a quel tenant. - Se un utente può appartenere a più tenant, implementa un meccanismo esplicito di “tenant selection” (e auditing).
Con Spring Security, un approccio tipico: salvare il tenant nel Authentication (es. GrantedAuthority o details) e compararlo con l’header. Di seguito un esempio schematico di validazione (da adattare al vostro modello):
package com.example.multitenancy.security;
import com.example.multitenancy.common.TenantId;
import org.springframework.security.core.Authentication;
public final class TenantAuthorization {
private TenantAuthorization() {}
public static boolean canAccess(Authentication auth, TenantId tenantId) {
// Esempio: authority del tipo TENANT:tenantA
return auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("TENANT:" + tenantId.value()));
}
}
Bounded Context e multi-tenant: dove mettere le regole “per tenant”
Quando i tenant differiscono per configurazioni (piani, feature flag, limiti), conviene evitare if sparsi e introdurre un concetto di dominio:
- Tenant Policy o Plan: oggetto che descrive limiti e comportamenti (es. “max utenti”, “fatturazione”, “workflow”).
- Domain Service che applica la policy.
- Anti-Corruption Layer se le configurazioni arrivano da un servizio esterno (billing, entitlement).
Esempio: una policy che decide se un’azione è consentita. Notare che qui non servono annotazioni o dettagli di persistenza.
package com.example.domain.tenant;
public interface TenantPolicy {
boolean canCreateOrder();
}
Database migration: indici, vincoli e retrocompatibilità
Con tabelle condivise:
- Aggiungere
tenant_idNOT NULL e popolare dati esistenti richiede una migrazione in più fasi. - Gli indici con
tenant_idvanno pianificati per evitare lock lunghi (dipende dal DB). - Gli indici unici devono includere
tenant_idper preservare la semantica “unico per tenant”.
Con Flyway, uno schema di migrazione tipico:
-- V3__add_tenant_id.sql
ALTER TABLE orders ADD COLUMN tenant_id VARCHAR(64);
-- Backfill: in un sistema reale scegli una strategia (default tenant, mapping, ecc.)
UPDATE orders SET tenant_id = 'default' WHERE tenant_id IS NULL;
ALTER TABLE orders ALTER COLUMN tenant_id SET NOT NULL;
CREATE INDEX idx_orders_tenant ON orders(tenant_id);
CREATE UNIQUE INDEX uq_orders_tenant_number ON orders(tenant_id, order_number);
Test: come dimostrare che l’isolamento funziona
Non basta “sapere” che il filtro c’è: serve una suite di test che lo rompa se qualcuno lo rimuove o lo aggira. Tre tipi di test utili:
- Integration test repository: inserisci dati per tenant A e tenant B, verifica che una query in contesto A non veda B.
- Test web: richieste HTTP con diversi tenant devono restituire risposte diverse e non devono mai “leakare” dati.
- Test di sicurezza: se l’utente non ha accesso al tenant, la richiesta deve fallire (403) anche se invia un header valido.
Esempio schematico di test (JUnit + Spring Boot):
package com.example;
import com.example.multitenancy.common.TenantContext;
import com.example.multitenancy.common.TenantId;
import com.example.multitenancy.persistence.order.OrderSpringDataRepository;
import com.example.multitenancy.persistence.order.OrderJpaEntity;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class TenantIsolationIT {
@Autowired
OrderSpringDataRepository repo;
@AfterEach
void cleanup() {
TenantContext.clear();
}
@Test
@Transactional
void repository_must_isolate_by_tenant() {
// tenant A
TenantContext.set(TenantId.of("A"));
var a = new OrderJpaEntity();
a.setOrderNumber("ORD-1");
repo.save(a);
// tenant B
TenantContext.set(TenantId.of("B"));
var b = new OrderJpaEntity();
b.setOrderNumber("ORD-1");
repo.save(b);
// verifica: in tenant A vedo solo A
TenantContext.set(TenantId.of("A"));
var foundA = repo.findByOrderNumber("ORD-1");
assertThat(foundA).isPresent();
assertThat(foundA.get().getTenantId()).isEqualTo("A");
// verifica: in tenant B vedo solo B
TenantContext.set(TenantId.of("B"));
var foundB = repo.findByOrderNumber("ORD-1");
assertThat(foundB).isPresent();
assertThat(foundB.get().getTenantId()).isEqualTo("B");
}
}
Nota: questo test assume che l’aspect abiliti il filtro anche in transazione di test. Se non accade, è un segnale che il punto di aggancio del filtro non è abbastanza generale: meglio scoprirlo in test che in produzione.
Osservabilità e auditing
In ambienti multi-tenant, i log e le metriche devono includere il tenant, altrimenti diagnosi e supporto diventano difficili. Due accorgimenti:
- MDC logging: inserire il tenant nel Mapped Diagnostic Context (ad es. in un filtro) così appare automaticamente nei log.
- Audit trail: per operazioni sensibili, registrare chi ha fatto cosa e per quale tenant.
Esempio di inserimento tenant in MDC:
package com.example.multitenancy.web;
import com.example.multitenancy.common.TenantContext;
import org.slf4j.MDC;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
public class TenantMdcFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
var t = TenantContext.getOrNull();
if (t != null) MDC.put("tenant", t.value());
filterChain.doFilter(request, response);
} finally {
MDC.remove("tenant");
}
}
}
Alternative robuste: Row-Level Security (PostgreSQL)
Se avete bisogno di una garanzia a prova di errore applicativo, PostgreSQL offre Row-Level Security: il DB stesso applica policy per riga. In pratica:
- abiliti RLS sulle tabelle;
- definisci una policy che confronta
tenant_idcon un parametro di sessione; - all’apertura della connessione imposti quel parametro (ad es.
SET app.tenant = 'A').
Questa soluzione riduce drasticamente il rischio di “leak” anche in caso di query non filtrate, ma richiede disciplina (connection pool, reset del parametro, migrazioni delle policy, tooling).
Errori comuni e come evitarli
- TenantContext non pulito: se non rimuovi il ThreadLocal, rischi cross-request leak. Usa sempre
finally. - Query native non filtrate: il filtro Hibernate non si applica sempre alle native query. Evitale o applica condizioni manuali e test.
- Indici senza tenant_id: peggiori performance e rischi collisioni semantiche.
- Accettare tenant da input non autenticato: porta a data exposure. Verifica sempre l’autorizzazione.
- Condivisione di cache tra tenant: se usi cache applicativa o second-level cache, includi il tenant nel key space o disabilita dove non sicuro.
Checklist operativa
- Risoluzione tenant robusta (da JWT/IdP o validazione forte dell’header).
- TenantContext per richiesta con cleanup garantito.
- Filtro di persistenza globale + indici e vincoli DB.
- Test di integrazione che provano l’isolamento e falliscono se viene rimosso.
- Log/metriche con tenant e auditing per operazioni sensibili.
- Piano di migrazione e backfill se introduci
tenant_idsu un DB esistente.
Conclusione
DDD e multi-tenancy non sono in conflitto: anzi, DDD aiuta a mantenere coerenza e chiarezza quando i requisiti di isolamento e variabilità crescono. La chiave è separare bene le responsabilità: dominio pulito e ricco, applicazione che coordina e valida, infrastruttura che applica enforcement non negoziabile (filtri, vincoli, policy DB) e una suite di test che rende l’isolamento un requisito verificabile.
Se il vostro SaaS deve evolvere verso un isolamento più forte, potete iniziare con “tenant_id in tabelle condivise” e poi migrare verso schema-per-tenant o DB-per-tenant per clienti specifici, mantenendo la stessa interfaccia di dominio e cambiando soprattutto la strategia infrastrutturale.