Skip to main content

Multi-Tenancy with Hibernate 7 and Spring Boot 4: @TenantId vs Schema-per-Tenant

Hibernate 7.4 multi-tenancy on Spring Boot 4.1 and PostgreSQL 17, run end to end: @TenantId discriminator vs schema-per-tenant, tenant taken from a signed JWT claim, the search_path leak, one shared pool vs a pool per tenant (measured connection counts), and what happens when no tenant is set.

You are building a SaaS product. Customer A and customer B use the same app, the same URLs, even the same invoice number INV-1, and neither may ever see the other’s data. That is multi-tenancy, and Hibernate 7 gives you two built-in ways to do it with very different trade-offs: a discriminator column (@TenantId), where every tenant shares the same tables, and schema per tenant, where each tenant gets its own copy of the tables and a MultiTenantConnectionProvider points each connection at the right one.

This article builds both on Spring Boot 4 with a real PostgreSQL 17, takes the tenant from a signed JWT claim, and then breaks the isolation on purpose — no tenant set, a forgotten search_path reset, a native query, a tenant nobody provisioned — because that is where multi-tenant systems actually fail. Everything quoted below comes from a test in the companion module, run on the versions in the next box.

Versions verified on 2026-10-29. Hibernate ORM 7.4.14.Final (newest 7.x GA on Maven Central; Spring Boot 4.1.2 manages 7.4.13.Final, so the module overrides it), Spring Boot 4.1.2 (latest GA), Spring Security JOSE 7.1.2, PostgreSQL 17.11 with driver 42.7.14, Testcontainers 2.0.5, JDK 25 (Temurin 25.0.5+8). Companion code: hibernate-demo/multitenancy.

Two ways to keep tenants apart, and the question that decides between them

Start with the mental model. A tenant is one customer organisation. Multi-tenancy is the promise that a request made on behalf of tenant A can only touch tenant A’s rows. There are three common places to enforce that promise: in a column every row carries (discriminator), in a separate schema per tenant, or in a separate database per tenant.

Discriminator column1 database, 1 schemainvoice(tenant_id, …)isolation: Hibernate addsthe predicate (app-enforced)migrations: run oncepools: onenative SQL: not coveredSchema per tenant1 database, N schemasacme.invoice, globex.invoiceisolation: connection pointsat one schema (search_path)migrations: run N timespools: one, or one per tenantnative SQL: stays inside schemaDatabase per tenantN databases or serversnot built in this moduleisolation: strongest(credentials, backups, KMS)migrations: N timespools: Nnot run hereMoving right buys isolation you can point to in an audit, and charges for it in migrations, connections and onboarding work.Dashed box = described, not executed in this article.

The picture’s argument: the further right you go, the more of the isolation is enforced by the database instead of by your application code, and the more you pay in migrations, connections and onboarding work. This module implements the first two, because those are what Hibernate supports natively with an annotation or a provider class. Database-per-tenant uses the same MultiTenantConnectionProvider contract as schema-per-tenant, but I did not build or run it.

The question that decides it. Not “which is more elegant”, but what happens on the day a bug lets tenant A’s request run without tenant A’s filter? With a discriminator, that bug returns every tenant’s rows. With a schema per tenant, it returns an error or an empty table. The rest of the article is about how to make that day boring.
Going deeper: the Hibernate pieces, by name
  • @TenantId — org.hibernate.annotations.TenantId, a field or getter annotation. Discriminator mode.
  • CurrentTenantIdentifierResolver<T> — org.hibernate.context.spi. Tells Hibernate the current tenant every time it opens a session. Needed by both modes.
  • MultiTenantConnectionProvider<T> — org.hibernate.engine.jdbc.connections.spi. Hands Hibernate a connection for a tenant. Schema and database modes.
  • Setting names: hibernate.tenant_identifier_resolver and hibernate.multi_tenant_connection_provider, constants in org.hibernate.cfg.MultiTenancySettings.

All of these were read from the shipped jar, see output/00-javap.txt and output/01-api-shape.txt.

Verbatim from output/01-api-shape.txt:

== packages
TenantId                        -> org.hibernate.annotations.TenantId
CurrentTenantIdentifierResolver -> org.hibernate.context.spi.CurrentTenantIdentifierResolver
MultiTenantConnectionProvider   -> org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider
HibernatePropertiesCustomizer   -> org.springframework.boot.hibernate.autoconfigure.HibernatePropertiesCustomizer

Two Hibernate 7 details worth knowing before you copy a Hibernate 5 tutorial: both interfaces are now generic (<T> is your identifier type, here String), and the resolver lives in org.hibernate.context.spi. The Spring Boot 4 customizer interface lives in org.springframework.boot.hibernate.autoconfigure — a new package in the modularised Boot 4.

Step one for both: take the tenant from a signed token, once, at the edge

Before Hibernate can isolate anything it must be told who is calling. The safe pattern: the tenant comes from a claim inside a signed JWT, a servlet filter verifies the signature, copies the claim into a ThreadLocal, and clears it when the request ends. Never take the tenant from a header, a query parameter, or a URL path segment the client can edit unless you also check it against the token.

HTTP requestBearer eyJ…JwtTenantFilterverify signature, expread tenant_id claimTenantContextThreadLocal<String>cleared in finallyHibernate opens a Sessionasks TenantIdResolver“which tenant is this?”Discriminator (@TenantId)WHERE tenant_id = ?added to every statementSchema per tenantconnection getssearch_path = tenantThe tenant is decided once, at the edge, from a signed claim. Everything below the filter only ever reads it.If the filter does not clear it, the next request on the same pooled thread inherits it.

What the diagram shows: the filter is the only place that writes the tenant, and Hibernate only reads it, through the resolver, at the moment it opens a session. The second sentence in the diagram is the trap: servlet containers reuse threads, so a tenant you forget to clear leaks into whatever request lands on that thread next.

From TenantContext.java (a ThreadLocal holder):

public final class TenantContext {
    private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();

    private TenantContext() { }

    /** The current tenant, or {@code null} when none is set. */
    public static String get() { return CURRENT.get(); }

    public static void set(String tenant) { CURRENT.set(tenant); }

    public static void clear() { CURRENT.remove(); }

    /** Runs {@code work} as {@code tenant} and restores whatever was set before. */
    public static <T> T runAs(String tenant, Supplier<T> work) {
        String previous = CURRENT.get();
        CURRENT.set(tenant);
        try {
            return work.get();
        } finally {
            if (previous == null) CURRENT.remove(); else CURRENT.set(previous);
        }
    }
}

From JwtTenantFilter.java (the filter body):

    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
        String header = req.getHeader("Authorization");
        if (header == null || !header.startsWith("Bearer ")) {
            reject(res, 401, "missing bearer token");
            return;
        }
        Jwt jwt;
        try {
            jwt = decoder.decode(header.substring("Bearer ".length()).trim());
        } catch (JwtException e) {
            reject(res, 401, "invalid token");
            return;
        }
        String tenant = jwt.getClaimAsString(claim);
        if (!TenantNames.isValid(tenant)) {
            reject(res, 403, "token carries no valid tenant");
            return;
        }
        TenantContext.set(tenant);
        try {
            chain.doFilter(req, res);
        } finally {
            TenantContext.clear();
        }
    }

I used the JOSE half of Spring Security only (spring-security-oauth2-jose, the Nimbus library underneath) and wrote the filter by hand, so the mechanism stays visible in a few dozen lines. In a real system you would use the resource-server support and a JWKS endpoint from your identity provider; the claim-to-TenantContext step stays the same. The module’s decoder is an HS256 shared secret purely so the tests need no identity provider:

From JwtConfig.java:

    JwtDecoder jwtDecoder(SecretKey jwtSecretKey) {
        // The Nimbus processor built here checks signature, algorithm = HS256, exp and nbf (60 s clock skew by default).
        return NimbusJwtDecoder.withSecretKey(jwtSecretKey).macAlgorithm(MacAlgorithm.HS256).build();
    }

Run against a real server, the filter produces these answers (full transcript: output/06-http-discriminator.txt; the schema application gives identical results in output/07-http-schema.txt):

Verbatim from output/06-http-discriminator.txt:

-- no Authorization header
   GET /invoices  ->  HTTP 401  {"error":"missing bearer token"}
-- garbage token
   GET /invoices  ->  HTTP 401  {"error":"invalid token"}
-- token signed with a different secret
   GET /invoices  ->  HTTP 401  {"error":"invalid token"}
-- expired token (exp = 10 minutes ago)
   GET /invoices  ->  HTTP 401  {"error":"invalid token"}
-- valid signature, no tenant_id claim
   GET /invoices  ->  HTTP 403  {"error":"token carries no valid tenant"}
-- valid signature, tenant_id = "x; drop schema public cascade"
   GET /invoices  ->  HTTP 403  {"error":"token carries no valid tenant"}
-- valid signature, tenant_id = "public" (reserved name)
   GET /invoices  ->  HTTP 403  {"error":"token carries no valid tenant"}
Two choices that are easy to miss. 401 means “I do not know who you are” (no token, bad signature, expired). 403 means “I know who you are but the token has no usable tenant”, including a tenant string that is not a plain lowercase identifier. The shape check ([a-z][a-z0-9_]{2,30}, minus public and pg_*, see TenantNames.java) matters later: in schema mode this string becomes a schema name, so it is validated by allow-list rather than escaped and hoped for.

Library trap found while writing the tests: to mint an already-expired token, NimbusJwtEncoder throws IllegalArgumentException: expiresAt must be after issuedAt, so the test helper sets iat in the past too (Tokens.java).
Going deeper: threads, async and virtual threads

A ThreadLocal does not follow work onto another thread. @Async methods, CompletableFuture.supplyAsync, scheduled jobs and message listeners all start with no tenant, which with the strict resolver below fails loudly — the behaviour you want. Capture the tenant before handing off and set it again inside the task (the TenantContext.runAs helper exists for that). Scoped values are the newer alternative on JDK 25; I did not build that variant, so treat it as a pointer rather than a recipe.

The filter runs before Spring MVC’s Open-EntityManager-in-View interceptor, which is why the tenant is already set when the EntityManager is created in my HTTP tests (spring.jpa.open-in-view is left at its default). If you ever move tenant resolution to an interceptor ordered after it, the session would be opened with no tenant.

Discriminator tenancy: one table, one extra column, one annotation

The smallest thing that works. Every tenant lives in the same invoice table; a tenant_id column says whose row it is. Mark that field @TenantId and Hibernate does two jobs for you: it fills the column on insert from the resolver, and it adds tenant_id = ? to the SQL it generates for reads, updates and deletes.

From Invoice.java:

@Entity
@Table(name = "invoice", uniqueConstraints = @UniqueConstraint(columnNames = { "tenant_id", "number" }))
public class Invoice {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @TenantId
    private String tenantId;

    private String number;
    private BigDecimal amount;

Two things in that snippet are not optional. The field is a plain String with no setter used by your code — nobody in Java sets it. And the unique constraint is (tenant_id, number), not number alone, otherwise tenant B cannot use an invoice number that tenant A already used. Hibernate generated this DDL (read back from the PostgreSQL catalog):

Verbatim from output/02-discriminator.txt:

== DDL Hibernate generated (read back from the catalog)
column amount numeric nullable=YES
column id bigint nullable=NO
column number character varying nullable=YES
column tenant_id character varying nullable=NO
unique UNIQUE (tenant_id, number)

Now Hibernate has to be told where the tenant comes from. This is the single line that connects everything, and Spring Boot does not do it for you: I checked the spring-boot-hibernate-4.1.2.jar and not one file in it mentions tenants (output/00-javap.txt). I did not test leaving the customizer out, so I cannot tell you what Boot would do; registering explicitly is what the tests exercise. You register the resolver through a HibernatePropertiesCustomizer:

From TenantIdResolver.java:

@Component
public class TenantIdResolver implements CurrentTenantIdentifierResolver<String> {
    /** true = fail loudly when no tenant is set (the default and the only sane production setting). false = return null. */
    private volatile boolean strict = true;

    public void setStrict(boolean strict) { this.strict = strict; }

    @Override
    public String resolveCurrentTenantIdentifier() {
        String tenant = TenantContext.get();
        if (tenant == null && strict) throw new MissingTenantException();
        return tenant;
    }

    /** true: Hibernate checks that an already-open current Session still belongs to the tenant being resolved. */
    @Override
    public boolean validateExistingCurrentSessions() { return true; }
}

From TenantHibernateConfig.java:

@Configuration
public class TenantHibernateConfig {
    @Bean
    HibernatePropertiesCustomizer tenantResolverCustomizer(TenantIdResolver resolver) {
        return props -> props.put(MultiTenancySettings.MULTI_TENANT_IDENTIFIER_RESOLVER, resolver);
    }
}

What happens on the wire, with two tenants using the same invoice number:

Verbatim from output/02-discriminator.txt:

== insert: nobody sets tenant_id in Java
-- as acme: save(INV-1)
   sql> insert into invoice (amount,number,tenant_id) values (?,?,?)
   entity.tenantId after save = acme
-- as globex: save(INV-1) - same invoice number, allowed by UNIQUE(tenant_id, number)
   sql> insert into invoice (amount,number,tenant_id) values (?,?,?)
   ids: acme INV-1=1, globex INV-1=2 (one IDENTITY column shared by all tenants)

== reads are filtered
-- as acme: findAll()
   sql> select i1_0.id,i1_0.amount,i1_0.number,i1_0.tenant_id from invoice i1_0 where i1_0.tenant_id = ?
   rows = 2, numbers = [INV-1, INV-2]
-- as globex: findById(acme's id 1)
   sql> select i1_0.id,i1_0.amount,i1_0.number,i1_0.tenant_id from invoice i1_0 where i1_0.id=? and i1_0.tenant_id = ?
   result = Optional.empty

The generated SQL is the proof. The insert carries tenant_id though no Java code set it; findAll() and findById() both end in tenant_id = ?; and when tenant globex asks for the id that belongs to acme it gets Optional.empty, not a forbidden error. Bulk HQL is scoped as well:

Verbatim from output/02-discriminator.txt:

== bulk HQL is scoped too
-- as acme: update Invoice set amount = 0
   sql> update invoice i1_0 set amount=0 where i1_0.tenant_id = ?
   rows updated = 2 (globex's row untouched)
The discriminator does not cover native SQL. A native query is sent to the database exactly as written. In the transcript, tenant acme owns two rows, yet select count(*) from invoice run by acme returned 3 — every tenant’s rows. Anything that bypasses Hibernate-generated SQL (native queries, JdbcTemplate, reports, a read-replica script) must add the predicate itself, or be banned. If you want the database to enforce the rule, PostgreSQL row-level security is the usual next layer; I did not build or run it here.

Verbatim from output/02-discriminator.txt:

== what @TenantId does NOT cover: native SQL
-- as acme: native query  select count(*) from invoice
   result = 3  (acme owns 2 rows; the native query saw every tenant)
Going deeper: what the id column does and does not isolate

All tenants share one IDENTITY column, so ids are unique across tenants (acme got 1, globex got 2 in the transcript) and leak a little information: a tenant can watch its ids jump and infer how busy the platform is. If that matters, use a UUID key. Because findById is tenant-filtered, guessing another tenant’s id returns empty rather than data — the HTTP test shows 404 for exactly that.

Second-level cache keys are also tenant-aware in Hibernate, but this module does not enable a cache, so I did not run that case.

When no tenant is set: Hibernate refuses, and so should you

The most common multi-tenant bug is not an attack; it is code that runs without a tenant — a scheduled job, a startup hook, a new thread. What should happen? Hibernate’s own answer is on the second half of the transcript. With the lenient resolver (returns null when nothing is set), Hibernate still refuses to open a session. With the strict resolver in this module (throws our own exception), you get a message that names the real problem:

Verbatim from output/02-discriminator.txt:

== no tenant set
-- strict resolver (throws when TenantContext is empty): repo.findAll() with no tenant
   org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction  <- caused by com.ankurm.multitenancy.tenant.MissingTenantException: no tenant in TenantContext: refusing to open a tenant-scoped session
-- lenient resolver (returns null): repo.findAll() with no tenant
   org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction  <- caused by org.hibernate.HibernateException: SessionFactory configured for multi-tenancy, but no tenant identifier specified

So in Hibernate 7.4.14 a missing tenant on a @TenantId setup fails closed even if your resolver returns null; the strict resolver exists to give a better error message, not to add safety. The other direction — the session has tenant acme but the code tries to write a row for globex — is also caught:

Verbatim from output/02-discriminator.txt:

== writing a row for a different tenant than the session's
-- as acme: new Invoice with tenantId manually set to globex, saveAndFlush
   org.springframework.dao.DataIntegrityViolationException: assigned tenant id differs from current tenant id [globex != acme] for entity com.ankurm.multitenancy.discriminator.Invoice.tenantId  <- caused by org.hibernate.PropertyValueException: assigned tenant id differs from current tenant id [globex != acme] for entity com.ankurm.multitenancy.discriminator.Invoice.tenantId
A subtle fail-open: new tenants appear on their own. On the discriminator application, a token whose tenant claim is ghostco — a tenant nobody onboarded — gets 200 [] on a read and 201 on a write: ghostco’s first POST simply creates its data (see the last section of output/06-http-discriminator.txt). Isolation still holds, but “any validly signed token is a customer” is now your onboarding policy. If that is wrong for you, check the tenant against a tenants table, or against an allow-list claim, in the filter.

Verbatim from output/06-http-discriminator.txt:

== a well-formed tenant nobody has onboarded
-- token for tenant "ghostco": GET /invoices
   GET /invoices  ->  HTTP 200  []
-- token for tenant "ghostco": POST /invoices
   POST /invoices  ->  HTTP 201  {"id":4,"number":"INV-G","amount":1.00,"tenant":"ghostco"}

Schema per tenant: same entity, no tenant column, a different connection

In this model the entity does not know about tenants at all. Each tenant has a PostgreSQL schema (a namespace inside one database) containing its own invoice table. Hibernate sends unqualified SQL — select ... from invoice — and the connection decides which schema that means, through PostgreSQL’s search_path. Your job is the MultiTenantConnectionProvider.

From Invoice.java:

@Entity
@Table(name = "invoice")
public class Invoice {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String number;
    private BigDecimal amount;

The simplest provider uses one shared connection pool: borrow a connection, point it at the tenant’s schema, hand it to Hibernate; when Hibernate releases it, reset it. The two methods that matter:

From SearchPathConnectionProvider.java (checkout and release):

    public Connection getConnection(String tenant) throws SQLException {
        Connection c = dataSource.getConnection();
        try {
            c.setSchema(TenantNames.requireValid(tenant));
        } catch (SQLException | RuntimeException e) {
            c.close();
            throw e;
        }
        return c;
    }

    @Override
    public void releaseConnection(String tenant, Connection c) throws SQLException {
        try {
            if (resetOnRelease) {
                try (Statement st = c.createStatement()) { st.execute("RESET search_path"); }
            }
        } finally {
            c.close();
        }
    }

Wiring it is the second customizer property, hibernate.multi_tenant_connection_provider, in SchemaTenancyConfig.java. Provisioning a tenant is not Hibernate’s job in this module; SchemaProvisioner.java runs create schema and create table with a validated, double-quoted name (I did not try Hibernate’s schema export per tenant; in production this is a Flyway or Liquibase run per schema). Results:

Verbatim from output/03-schema-per-tenant.txt:

== the SQL Hibernate sends is unqualified; the connection decides which schema it hits
-- as acme: save(INV-1)
   sql> insert into invoice (amount,number) values (?,?)
-- as acme: findAll()
   sql> select i1_0.id,i1_0.amount,i1_0.number from invoice i1_0
   rows = 2, numbers = [INV-1, INV-2]
-- as acme, inside the transaction: select current_setting('search_path')
   acme

== cross-tenant reads: blocked by the schema, but ids overlap
ids: acme INV-1=1, acme INV-2=2, globex INV-9=1 (each schema has its own identity column)
-- as globex: findById(1) -> INV-9   (acme's id 1 is also globex's id 1: a different row)
-- as globex: findById(2) -> Optional.empty   (acme's INV-2 is unreachable)

Notice what is different from the discriminator run. The SQL has no tenant_id predicate; ids overlap (acme’s INV-1 and globex’s INV-9 are both id 1, each schema has its own identity column); and the native count that leaked three rows before now returns only acme’s two:

Verbatim from output/03-schema-per-tenant.txt:

== native SQL needs no workaround here
-- as acme: native  select count(*) from invoice  -> 2  (acme's schema only; globex's row is not counted)
Overlapping ids mean an id is no longer global. Id 1 now exists in every tenant. Anything that stores an id outside the tenant’s schema — a cache key, a log line, a queue message — must carry the tenant next to it, or it cannot say which row it means.

One more difference: a validly signed token for a tenant that was never provisioned (ghostco) does not quietly create data here. The read and the write both return HTTP 500 because the schema does not exist, as the last section of output/07-http-schema.txt shows. A 500 is an honest failure but a poor API; map it to a 404 or 403 in a real service.

Going deeper: what else the provider contract asks for

getAnyConnection() is used by Hibernate at startup (dialect detection, metadata) when no tenant exists, so your provider must hand out a connection that is not tied to a tenant. supportsAggressiveRelease() returning false keeps the connection for the whole transaction; I did not test the true setting. The interface also has handlesConnectionSchema() and read-only variants as default methods (see 00-javap.txt), which I left at their defaults. AbstractMultiTenantConnectionProvider exists in org.hibernate.engine.jdbc.connections.spi for the pool-per-tenant style, where you only choose a ConnectionProvider per tenant; my pool-per-tenant class implements the interface directly instead.

The search_path leak: the bug that makes this model dangerous

With a shared pool, a connection is re-used by many tenants. If the provider sets the schema on checkout but forgets to undo it on release, the physical connection goes back to the pool still pointing at the last tenant’s schema. The next borrower — Hibernate for another tenant (which sets its own, so you might never notice), but also any other code that uses the same DataSource — inherits it. The module has a deliberately broken variant (tenancy.mode=leaky) with a pool of size 1 to make the reuse deterministic:

Verbatim from output/04-search-path-leak.txt:

== Hibernate finished a request as acme and gave the connection back; now an unrelated component borrows it
-- direct DataSource connection, tenant context empty
   show search_path                   -> acme
   select count(*) from invoice       -> 1   (acme's data, read with no tenant at all)

An unrelated component, with no tenant set at all, read acme’s invoices. The correct provider (reset on release) leaves the connection on the default path and the same query fails with relation "invoice" does not exist:

Verbatim from output/04-search-path-leak.txt:

== the same experiment with the correct provider (resetOnRelease=true), pool size 1, provider called the way Hibernate calls it
-- after getConnection("acme") + releaseConnection("acme", ..): show search_path -> "$user", public
-- select count(*) from invoice -> java.lang.IllegalStateException: org.postgresql.util.PSQLException: ERROR: relation "invoice" does not exist  <- caused by org.postgresql.util.PSQLException: ERROR: relation "invoice" does not exist
Make this a test in your own project. The check in A04_LeakTest.java is three lines: use a pool of size one, run a tenant request, then borrow from the raw DataSource and run show search_path. Any shared-pool tenancy code should have it. A reset that is skipped when a transaction aborts is the usual way this regresses; the module resets in a finally.

Pooling: one pool and SET search_path, or a pool per tenant

The leak above is the argument for the alternative: give each tenant its own connection pool whose connections are opened already pointed at the right schema (currentSchema=<tenant> in the JDBC URL). Nothing to reset, nothing to forget. The cost is connections. Both numbers below were read from pg_stat_activity on the PostgreSQL 17 container after the same six-tenant workload:

One shared pool + SET search_path6 tenants touched, pool max = 5shared-pool: 5 connectionsa connection is re-pointed on checkoutmust be reset on release (see leak)One HikariCP pool per tenant6 tenants + 1 bootstrap pool, max = 3 each7 pools x 3 = 21 connectionseach connection fixed to one schemanothing to reset, but connections scale with tenantsNumbers come from output/03 and output/05. Hikari opens minimumIdle (default = max) connections per pool eagerly,so the per-tenant design pays its connection cost whether or not the tenant is active.

The diagram’s argument is the ratio. The shared pool stayed at its configured ceiling of 5 no matter how many tenants appeared; the per-tenant design scaled with tenants: 7 pools of 3. Here are the actual lines:

Verbatim from output/05-pool-per-tenant.txt:

config: tenancy.pool.max-size=3 (minimumIdle defaults to the same), 6 tenants touched, tenancy.mode=pool-per-tenant
pool-acme        3 connections
pool-any         3 connections
pool-globex      3 connections
pool-hooli       3 connections
pool-initech     3 connections
pool-stark       3 connections
pool-umbrella    3 connections
total across tenant pools: 21
pools created by the provider: 7 (6 tenants + 1 'any' pool used by Hibernate at bootstrap)
server max_connections: 100

Verbatim from output/03-schema-per-tenant.txt:

== connections held by the shared pool after exercising 6 tenants
config: spring.datasource.hikari.maximum-pool-size=5, 6 tenants touched
pg_stat_activity rows with application_name='shared-pool': 5

From PoolPerTenantConnectionProvider.java (one pool per tenant, created on first use):

    private HikariDataSource pool(String key, String schemaOrNull) {
        return pools.computeIfAbsent(key, k -> {
            HikariConfig c = new HikariConfig();
            String sep = baseUrl.contains("?") ? "&" : "?";
            c.setJdbcUrl(baseUrl + sep + "ApplicationName=pool-" + k + (schemaOrNull == null ? "" : "&currentSchema=" + schemaOrNull));
            c.setUsername(user);
            c.setPassword(password);
            c.setMaximumPoolSize(maxPoolSize);   // minimumIdle defaults to maximumPoolSize: Hikari opens that many eagerly
            c.setPoolName("tenant-" + k);
            return new HikariDataSource(c);
        });
    }
Arithmetic, not a measurement. I set the per-tenant ceiling to 3 to keep the sandbox small. HikariCP’s documented defaults are maximumPoolSize=10 and minimumIdle equal to it, and the 21 connections above (including the three the bootstrap “any” pool holds) show the eager fill is real. If you left the defaults, each active tenant would hold 10 server connections, so ten active tenants would already use all of PostgreSQL’s default max_connections of 100 (the value the transcript shows on this server). That is multiplication from the documented defaults and the observed eager behaviour, not something I ran at 100 tenants. I measured nothing about latency or throughput, and I am not claiming either design is faster.
ConcernOne pool + search_pathPool per tenant
Server connectionsBounded by one number (5 in the run)Grows with tenants (21 for 6)
Forgotten resetCross-tenant leak (reproduced)Impossible by construction
Per-tenant limits / noisy neighbourNone: tenants share the poolA busy tenant can only exhaust its own pool
Behind PgBouncer in transaction modeSession-level SET is unsafe (not tested here; PgBouncer docs)Per-pool URL works, but still multiplies backends
Tenant churnNothing to clean upYou must close pools of idle/removed tenants (not implemented)
Going deeper: the PgBouncer row, honestly

The PgBouncer cell is the only claim in the table I did not run: it comes from PgBouncer’s documented behaviour that session state set with SET is not preserved across transactions in transaction-pooling mode. If you use a transaction-mode pooler, SET LOCAL search_path inside each transaction is the usual alternative; that would be a different provider from the one in this module.

Testing isolation: make the failure the assertion

The module’s tests are the transcripts: each test writes its observations to output/ and asserts the same facts, so a regression breaks the build. Tests that matter for any tenancy code you write:

  • Cross-tenant read by id is empty (discriminator) or unreachable (schema), asserted explicitly (A02_DiscriminatorTest.java, A03_SchemaPerTenantTest.java).
  • No tenant fails, in both strict and lenient resolver modes.
  • The raw DataSource sees no tenant after a tenant request (the leak test, with pool size 1).
  • The HTTP edge: 401 for bad tokens, 403 for no or malformed tenant, tested over real HTTP with @SpringBootTest(webEnvironment = RANDOM_PORT) (HttpFlow.java).

The tests use Testcontainers 2.x, where the artifact ids are testcontainers-postgresql and testcontainers-junit-jupiter and the class is org.testcontainers.postgresql.PostgreSQLContainer (not generic any more). One container is shared by every test class; each class creates its own database in it (Pg.java). The whole suite is seven test classes; run it with ./scripts/run-all.sh, which also regenerates every transcript.

Going deeper: what the suite does not test

Concurrency (two tenants hitting the same pool at once), load, row-level security, second-level cache with tenants, Envers, tenant-aware Flyway migrations, database-per-tenant, JWKS/asymmetric keys, and virtual-thread/@Async propagation. The module README lists these under “Not covered (not run)”.

Which one should you actually use?

If this describes you…Lean towards
Thousands of small tenants, one product, a small teamDiscriminator (@TenantId); add PostgreSQL row-level security if you need the database to enforce it
Tens to low hundreds of tenants, each wanting its own backup/export/restore, customers asking “is our data separated?”Schema per tenant, shared pool with a tested reset, or a pool per tenant if you have connection headroom
Regulated customers or contracts demanding separate credentials and keysDatabase per tenant (same provider contract; not built here)
Honest advice (my judgement, not a benchmark). If you are starting a SaaS and cannot say “we will have a hundred tenants with strict separation needs”, start with the discriminator. It is one annotation, one migration, and it fails closed when the tenant is missing. Add the two defences this article shows you need anyway: ban native SQL that skips the predicate (or add database-level row security), and put the tenant check in a single place in the filter. Choose schema-per-tenant when separation is a selling point and you are ready to run every migration N times and operate the pool design deliberately. Do not choose it just because it feels more “isolated”: the shared-pool variant has a leak failure mode the discriminator does not, and I reproduced it in a few lines.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.