Domain-Driven Design con Multi-Tenancy in Java Spring Boot

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_id NOT NULL e popolare dati esistenti richiede una migrazione in più fasi.
  • Gli indici con tenant_id vanno pianificati per evitare lock lunghi (dipende dal DB).
  • Gli indici unici devono includere tenant_id per 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_id con 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_id su 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.

Torna su