When building B2B SaaS products, the design decision of “how to separate data between tenants” is something you simply can’t avoid. In this article, I’ll go through a comparison of the three approaches, along with working code on Spring Boot 3.2 + Hibernate 6.4.

Prerequisites

  • Spring Boot 3.2.x
  • Hibernate ORM 6.4.x (the version bundled with Spring Boot 3.2)
  • Java 21
  • PostgreSQL 16

In Hibernate 6.4, MultiTenantConnectionProvider<T> and CurrentTenantIdentifierResolver<T> are parameterized by type, so the code in this article assumes <String>. Note that some signatures differ in the Hibernate 6.1 series bundled with Spring Boot 3.0 / 3.1.

Three Isolation Approaches

  • Database-per-tenant — Separate physical databases per tenant
  • Schema-per-tenant — Separate only the schema within a single database
  • Shared-schema — All tenants share the same tables, identified by a tenant_id column

Comparing the Approaches

AspectDatabaseSchemaShared-schema
Isolation strengthVery strongStrongWeak (depends on app)
Operational costHighMediumLow
Backup granularityPer tenantPer schemaWhole database
ScalabilityTens of tenantsHundreds of tenantsThousands possible
Best fitHealthcare, financeMid-sized B2BMany small customers

The scale figures are rough guidelines; in reality they depend on your operations team, connection pool limits, and provisioning costs. If compliance demands full data isolation, choose Database; if cost is the top priority and you have many customers, go with Shared-schema; Schema sits between the two. A practical strategy is to start with Shared-schema for the PoC and migrate to Schema or Database only when needed.

Hibernate Configuration

In Hibernate 6, the approach is specified via the hibernate.multiTenancy property. In Spring Boot 3.x, if you register MultiTenantConnectionProvider and CurrentTenantIdentifierResolver as @Component, they’re auto-detected via HibernatePropertiesCustomizer, so you generally don’t need to specify class names.

spring:
  jpa:
    properties:
      hibernate:
        multiTenancy: SCHEMA

Valid values are DATABASE / SCHEMA / DISCRIMINATOR.

Implementing MultiTenantConnectionProvider

This is the core component that switches the connection per tenant. With Schema-per-tenant, you need to explicitly issue SET search_path. PgConnection.setSchema() is essentially a no-op that doesn’t change search_path, so relying on JDBC’s setSchema breaks isolation.

@Component
public class TenantConnectionProvider
    implements MultiTenantConnectionProvider<String> {

  private final DataSource defaultDataSource;
  private static final Pattern SAFE_IDENT = Pattern.compile("[A-Za-z0-9_]+");

  public TenantConnectionProvider(DataSource defaultDataSource) {
    this.defaultDataSource = defaultDataSource;
  }

  @Override
  public Connection getAnyConnection() throws SQLException {
    return defaultDataSource.getConnection();
  }

  @Override
  public void releaseAnyConnection(Connection conn) throws SQLException {
    conn.close();
  }

  @Override
  public Connection getConnection(String tenantId) throws SQLException {
    if (!SAFE_IDENT.matcher(tenantId).matches()) {
      throw new IllegalArgumentException("invalid tenant id");
    }
    Connection conn = defaultDataSource.getConnection();
    try (Statement s = conn.createStatement()) {
      s.execute("SET search_path TO \"" + tenantId + "\"");
    }
    return conn;
  }

  @Override
  public void releaseConnection(String tenantId, Connection conn) throws SQLException {
    try (Statement s = conn.createStatement()) {
      s.execute("SET search_path TO public");
    }
    conn.close();
  }

  @Override public boolean isUnwrappableAs(Class<?> c) { return false; }
  @Override public <T> T unwrap(Class<T> c) { return null; }
}

Since the tenant ID is embedded into SQL as an identifier, bind parameters can’t be used. Block invalid input with a strict whitelist like SAFE_IDENT to prevent SQL injection. With MySQL, you’d write the equivalent as USE `tenant_xxx`.

For Database-per-tenant, the structure is to keep a ConcurrentHashMap<String, DataSource> as a field, create a HikariDataSource when adding a tenant and register it via put(), then return dataSources.get(tenantId).getConnection() from getConnection(tenantId). For pool sizing, refer to How to Correctly Configure and Tune HikariCP Connection Pool in Spring Boot.

Resolving the Tenant ID

CurrentTenantIdentifierResolver is what tells Hibernate which tenant ID to use for the current request.

@Component
public class TenantIdentifierResolver
    implements CurrentTenantIdentifierResolver<String> {

  @Override
  public String resolveCurrentTenantIdentifier() {
    String tenantId = TenantContext.getTenantId();
    if (tenantId == null) {
      throw new IllegalStateException("tenant not resolved");
    }
    return tenantId;
  }

  @Override
  public boolean validateExistingCurrentSessions() {
    return true;
  }
}

When validateExistingCurrentSessions() returns true, the resolver is called again when an existing session is returned, and an exception is thrown if the tenant has changed. This prevents accidents where a session reused from the pool carries over state from another tenant.

Holding Context in a ThreadLocal

public final class TenantContext {
  private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();
  public static void setTenantId(String id) { CURRENT.set(id); }
  public static String getTenantId() { return CURRENT.get(); }
  public static void clear() { CURRENT.remove(); }
}

If you forget clear(), a thread reused from the thread pool will carry over the previous tenant’s ID, leading to data leakage. This is a disaster to be avoided at all costs, so always call it from a finally block in the Filter described below.

A Filter to Extract the Tenant from the HTTP Header or JWT

We resolve it per request using OncePerRequestFilter. Since we want to reference JwtAuthenticationToken, this filter needs to be placed downstream of SecurityFilterChain — that is, in a position that runs after authentication is complete (register it with something like addFilterAfter(filter, BearerTokenAuthenticationFilter.class)).

@Component
public class TenantResolutionFilter extends OncePerRequestFilter {

  @Override
  protected void doFilterInternal(
      HttpServletRequest request, HttpServletResponse response, FilterChain chain)
      throws ServletException, IOException {
    try {
      String tenantId = resolve(request);
      if (tenantId == null) {
        response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Tenant required");
        return;
      }
      TenantContext.setTenantId(tenantId);
      MDC.put("tenantId", tenantId);
      chain.doFilter(request, response);
    } finally {
      TenantContext.clear();
      MDC.remove("tenantId");
    }
  }

  private String resolve(HttpServletRequest req) {
    String header = req.getHeader("X-Tenant-ID");
    if (header != null) return header;
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    if (auth instanceof JwtAuthenticationToken jwt) {
      return jwt.getToken().getClaimAsString("tid");
    }
    return null;
  }

  @Override
  protected boolean shouldNotFilter(HttpServletRequest req) {
    return req.getRequestURI().startsWith("/actuator");
  }
}

If you put tenantId in MDC, simply embedding %X{tenantId} in your Logback pattern will surface the tenant in every log line. For how the JWT authentication side is wired up, see How to Implement an Authentication API with Spring Security + JWT — reading them together will help you grasp the picture faster.

The Thread-Switching Problem with @Async

This is the biggest pitfall of ThreadLocal. Propagate the value with a TaskDecorator.

public class TenantAwareTaskDecorator implements TaskDecorator {
  @Override
  public Runnable decorate(Runnable runnable) {
    String tenantId = TenantContext.getTenantId();
    return () -> {
      try {
        TenantContext.setTenantId(tenantId);
        runnable.run();
      } finally {
        TenantContext.clear();
      }
    };
  }
}

It won’t take effect unless you register it on the Executor, so don’t forget the configuration class.

@Configuration
@EnableAsync
public class AsyncConfig {
  @Bean
  public ThreadPoolTaskExecutor taskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(4);
    executor.setTaskDecorator(new TenantAwareTaskDecorator());
    executor.initialize();
    return executor;
  }
}

Row-Level Isolation for the Shared-schema Approach

If you identify tenants via a tenant_id column, Hibernate’s @FilterDef lets you automate the addition of conditions to queries. The sample below has been verified to work on Hibernate 6.4.

@Entity
@FilterDef(
  name = "tenantFilter",
  parameters = {@ParamDef(name = "tenantId", type = String.class)}
)
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class Order {
  @Id @GeneratedValue private Long id;
  private String tenantId;
  private BigDecimal amount;
}

Enable it at the start of a session.

Session session = entityManager.unwrap(Session.class);
session.enableFilter("tenantFilter")
       .setParameter("tenantId", TenantContext.getTenantId());

However, it doesn’t apply to native queries. When writing @Query(nativeQuery = true), it’s mandatory to append WHERE tenant_id = :tenantId yourself. For the basics of entity design, see JPA Entity Relationship Mapping.

It’s also worth understanding the difference from PostgreSQL RLS. @Filter rewrites queries at the application layer, so it can be bypassed by native SQL or direct DB access. RLS, on the other hand, is enforced at the DB layer, so isolation applies regardless of the access path — but operations and performance tuning shift into DBA territory. Roughly speaking, choose @Filter if you want to keep things self-contained in the app, and RLS if leakage must be prevented at all costs.

Migrations

For Database-per-tenant, run Flyway sequentially against each DataSource. For Schema-per-tenant, iterate over schemas with Flyway.configure().schemas("tenant_xyz").load().migrate(). The flow for triggering this automatically when creating a new tenant is summarized in Database Migration with Flyway in Spring Boot.

Conclusion

In multi-tenant design, the initial choice is what matters most. Once you’ve picked an approach based on compliance requirements and the number of customers, and have the three-piece set of MultiTenantConnectionProvider, CurrentTenantIdentifierResolver, and a tenant-resolution Filter in place, you’ll have a working foundation. From there, eliminate the pitfalls around MDC, TaskDecorator, and @FilterDef one by one, and you should be able to grow it into something that holds up in production.