12 — Tenancy I: the context that follows the request
Read this first: MotorPH runs many companies in one Postgres database, and this lesson reads the seven files that keep them apart at the application layer — the entity base class, the ThreadLocal context, the filter pair that fills it, the Hibernate resolver that consumes it, and the DataSource wrapper that repeats it to Postgres. Code is quoted inline so you can read this without the repository open. By the end you can explain how a tenant id gets from an authenticated user into every SQL statement — and why a missing tenant returns nothing rather than everything.
Time: about 35 minutes. Assumes lesson 11.
One database, many companies
ADR 0013 chose shared-database multi-tenancy: every
company's rows live in the same tables, told apart by a tenant_id column. The obvious failure
mode is one forgotten WHERE clause serving one company's payroll to another, so the design goal
is that no ordinary code path can forget. TenantOwned states it, whole — this is the entire
file:
/**
* Base class for every entity owned by exactly one tenant.
*
* <p>Extending this does two things. Hibernate stamps {@code tenant_id} on insert from the tenant
* context, and it adds {@code where tenant_id = ?} to every query against the entity -- HQL,
* Criteria, Specifications and {@code find()} alike. Neither is something application code can
* forget to do, which is the entire point: a tenant leak should not be reachable by writing an
* ordinary repository method.
*
* <p>It is also a marker. {@code TenantCoverageTest} walks the JPA metamodel and fails the build
* for any entity that neither extends this nor is named in its list of deliberately global tables,
* so a new entity cannot quietly become a table every tenant shares.
*
* <p>The field has no setter. Hibernate owns the value; assigning it from application code would
* either be ignored or move a row between tenants.
*/
@MappedSuperclass
@Getter
public abstract class TenantOwned {
@TenantId
@Column(name = "tenant_id", nullable = false, updatable = false)
private Integer tenantId;
}
Three sentences to hold on to. First, stamping and filtering are Hibernate's job, not the
programmer's — "a tenant leak should not be reachable by writing an ordinary repository method."
Second, the class is a build gate: TenantCoverageTest walks the JPA metamodel, so a new entity
cannot quietly become a table every tenant shares (lesson 21 covers
that harness). Third, the no-setter rule: Hibernate owns the value.
But Hibernate stamps and filters from the tenant context. So the rest of this lesson is one question asked five ways: where does that context come from, and what happens when it is missing?
The context: one ThreadLocal, read by everything
TenantContext is a small static holder, and its javadoc is the map of this lesson and the next:
/**
* Holds the tenant the current thread is working on behalf of.
*
* <p>This is the single source of truth every tenancy mechanism reads: Hibernate's
* {@code CurrentTenantIdentifierResolver} (which turns it into the {@code WHERE tenant_id = ?}
* on every query), the hand-written SQL in the reporting services, and the Postgres session
* variable that backs row-level security.
*
* <p>The context is set per request by the authentication filter and cleared in a finally block.
* Work that runs outside a request -- scheduled jobs, startup seeders, payment webhooks -- has no
* authenticated user to derive a tenant from and must establish one explicitly via
* {@link #runAs} or {@link #runAsGlobal}.
*
* <p>Reads are deliberately strict: {@link #require()} throws rather than defaulting. A forgotten
* context has to fail loudly, because the alternative -- quietly falling back to tenant 1 -- would
* mean serving or, worse, writing one company's payroll data under another's.
*/
The API is one screen: a ThreadLocal, a sentinel, a strict read, and scoped runners that
restore whatever context was in place before:
public static final Integer GLOBAL = 0;
private static final ThreadLocal<Integer> CURRENT = new ThreadLocal<>();
public static Integer require() {
Integer tenantId = CURRENT.get();
if (tenantId == null) {
throw new TenantContextMissingException();
}
return tenantId;
}
public static <T> T runAs(Integer tenantId, Supplier<T> work) { ... }
public static <T> T runAsGlobal(Supplier<T> work) { ... }
GLOBAL is not "no tenant" by accident — its javadoc calls it "the sentinel for 'no single
tenant': platform-level work that legitimately spans tenants," and closes with the rule that
matters: "Never derived from a request; only ever set by runAsGlobal." Hibernate treats it as
the root tenant, which disables the discriminator filter — so global scope is a deliberate,
code-visible act, never a default you fall into.
A scope for every request, closed no matter what
Who sets the context? Two filters, in the chain order you read in
lesson 10. The first is TenantContextFilter, and its javadoc explains
both the ordering and the part that is easy to underrate:
/**
* Opens a tenant scope for every request and guarantees it is closed again.
*
* <p>Runs first in the chain, before authentication, and starts in global scope -- because at that
* point no tenant has been determined, and saying so is more honest than guessing. It has to be
* this way round: working out who is calling means reading the users and roles tables, which is
* itself database work that needs a scope.
*
* <p>{@link com.motorph.payroll.security.jwt.JwtAuthenticationFilter} narrows the scope to the
* caller's tenant as soon as it has resolved the principal. Requests that never authenticate --
* login, the public marketing endpoints, payment webhooks -- stay global, and are responsible for
* narrowing scope themselves; the public careers endpoints do it from the tenant slug in their path.
*
* <p>The clear in the finally block is the load-bearing part. Request threads are pooled, so a
* context left behind would hand the next request whatever tenant the previous one used -- a leak
* that would appear only under concurrency and look like data corruption rather than a bug.
*/
The body is exactly what the javadoc promises, and nothing more:
try {
TenantContext.set(TenantContext.GLOBAL);
filterChain.doFilter(request, response);
} finally {
TenantContext.clear();
}
Read that last javadoc paragraph again. Without the finally, everything works in every test and
in every single-user demo — and then, under load, one user occasionally sees another company's
data, in a way no stack trace points at. "Would appear only under concurrency and look like data
corruption rather than a bug" is the precise description of the worst class of defect: the kind
you cannot reproduce.
Predict: the token or the row?
By lesson 11 you know the JWT is validated on every request, and
a claim in it is tamper-proof once signed. So the cheap design is obvious: put tenantId in the
token at login and read it back here — no extra query, cryptographically bound to the caller.
Predict: this codebase deliberately does not do that. The tenant is read from the database instead. What does a signed-in-the-token tenant get wrong?
The resolution is the static method on the same filter, and it is the most quotable design decision in the package:
/**
* Narrows the current scope to the authenticated caller's tenant, if there is one.
*
* <p>The tenant is taken from the user record rather than the JWT. The token is trusted only
* for the subject, the user is re-loaded on every request regardless, and reading the tenant
* from the row means moving a user between tenants takes effect immediately instead of whenever
* their token happens to expire. A platform account has no tenant and stays in global scope.
*/
public static void narrowToPrincipal() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof AuthenticatedUser user
&& user.getTenantId() != null) {
TenantContext.set(user.getTenantId());
}
}
A JWT claim is a snapshot: true at signing time, assumed true until expiry. Move a user between
tenants — or revoke their access to one — and a token-borne tenant keeps them in the old company
until the token happens to expire. The row is live. And since lesson 11 showed the user is
re-loaded on every request anyway, the extra query is already paid for. The call site sits in
JwtAuthenticationFilter, right after the principal lands in the security context:
SecurityContextHolder.getContext().setAuthentication(authentication);
// Everything downstream of here reads and writes as this user's company. Until this
// point the request runs in global scope, which is what let the lookup above read
// the users and roles tables in the first place.
TenantContextFilter.narrowToPrincipal();
That comment is the whole two-filter dance in three lines: global scope exists so that identifying the caller is possible, and it lasts not one statement longer than it has to.
Hibernate asks, the context answers
The consumer side starts with TenantIdentifierResolver, Hibernate's
CurrentTenantIdentifierResolver. It resolves strictly — its javadoc ends: "A loud failure on a
code path that forgot to establish context is a bug report; a quiet fallback is a data breach
that nobody notices." But strictness meets a bootstrapping problem, and the flag that handles it
carries the subtlest javadoc in the package:
/**
* False until the context has finished refreshing.
*
* <p>Spring Data compiles every {@code @Query} method as it creates the repository beans, and
* compiling HQL opens a session, which asks this resolver for a tenant. That happens long
* before any request, so there is legitimately no tenant to give. Those sessions only parse
* queries -- they never read a row -- so answering "no particular tenant" is safe there and
* nowhere else.
*
* <p>Deliberately keyed to {@code ContextRefreshedEvent} rather than {@code ApplicationReady}:
* the startup seeders run as {@code ApplicationRunner}s in between, and they do touch real
* data, so they must be held to the strict rule and declare their tenant explicitly.
*/
private volatile boolean bootstrapping = true;
@Override
public Integer resolveCurrentTenantIdentifier() {
if (bootstrapping && TenantContext.get() == null) {
return TenantContext.GLOBAL;
}
return TenantContext.require();
}
Note how narrow the exemption is: query-parsing sessions never read a row, so they may answer
"no particular tenant" — and the event choice deliberately ends the grace period before the
seeders from lesson 03 run, because seeders touch real data and
must declare their tenant like everyone else. Two smaller overrides complete the class:
isRoot() maps GLOBAL to Hibernate's no-filtering root tenant, and
validateExistingCurrentSessions() returns true so a session outliving a context switch is
rejected instead of "quietly reading the previous tenant."
Predict: a lost scope
Predict: suppose some code path loses its scope anyway — a hand-rolled thread, say, running
raw SQL through JdbcTemplate, where no Hibernate resolver gets a chance to throw. Does that
query return all rows, or no rows? And which one did the designers pick on purpose?
TenantAwareDataSource answers. It wraps the pool so that every connection announces its tenant
to Postgres on checkout:
private static final String SET_SCOPE =
"SELECT set_config('app.tenant_id', ?, false), set_config('app.bypass_rls', ?, false)";
Row-level security policies (next lesson) read app.tenant_id from the session. Connections are
pooled, so the variable is set "on every checkout rather than once at connect time -- otherwise a
connection would keep the tenant of whoever used it last, which is the exact leak the policies
exist to prevent." And the javadoc resolves the prediction directly:
It is set unconditionally, including to the empty string when no tenant is established. An unset variable matches no rows, so a code path that lost its scope reads an empty database. That is the intended failure: visibly wrong beats invisibly wrong.
No rows. An empty grid on a page that had data yesterday gets reported within the hour; an over-wide query that leaks another company's rows might never be. Even the error path holds the line:
} catch (SQLException e) {
// A connection whose scope could not be set must not be used: it would carry whatever
// the previous borrower left behind.
connection.close();
throw e;
}
The last paragraph of the class javadoc explains why this layer catches what the resolver cannot: "Everything shares this, which is the point -- Hibernate, JdbcTemplate and the analytics views all draw from the same pool, so none of them can opt out."
Decorating the pool, not replacing it
How does the wrapper get installed? TenantDataSourceConfiguration is a BeanPostProcessor,
not a replacement DataSource bean, and its javadoc says why:
Done as a post-processor rather than by replacing the DataSource bean so that Boot keeps configuring Hikari exactly as it otherwise would -- pool sizing, metrics, health checks and the rest are untouched, and this only decorates the result.
And a payoff from lesson 02: "Flyway is unaffected: Boot gives it
its own DataSource built from spring.flyway.user, which is not a bean and so never reaches
this." Migrations run as the schema owner before any tenant exists; the wrapper only ever sees
the application's pool.
That is layer one, complete: the filter opens a scope, authentication narrows it to the caller's
row, Hibernate stamps and filters from it, and every pooled connection repeats it to Postgres.
Everything so far is Java, though — a native query that bypasses Hibernate, or a bug in any of
these files, still reaches the database. What the database itself does with app.tenant_id is
lesson 13.
Where this shows up in MotorPH
- ../../backend/src/main/java/com/motorph/payroll/model/TenantOwned.java — the base class quoted whole above; extend it for every new tenant-owned entity.
- ../../backend/src/main/java/com/motorph/payroll/tenancy/TenantContext.java — the ThreadLocal,
GLOBAL,require()and therunAsfamily. - ../../backend/src/main/java/com/motorph/payroll/tenancy/TenantContextFilter.java — scope open/close plus
narrowToPrincipal(). - ../../backend/src/main/java/com/motorph/payroll/security/jwt/JwtAuthenticationFilter.java — the call site that narrows scope after authentication.
- ../../backend/src/main/java/com/motorph/payroll/tenancy/TenantIdentifierResolver.java — Hibernate's consumer, with the bootstrap flag.
- ../../backend/src/main/java/com/motorph/payroll/tenancy/TenantAwareDataSource.java and ../../backend/src/main/java/com/motorph/payroll/config/TenantDataSourceConfiguration.java — the RLS bridge and its wiring.
- ../../backend/src/test/java/com/motorph/payroll/tenancy/TenantCoverageTest.java — the build gate
TenantOwned's javadoc names. - ../adr/0013-shared-db-multitenancy.md — the decision record behind all of it.
Recap
- The tenant lives in a ThreadLocal, set per request and cleared in a finally block — request threads are pooled, so the clear is the load-bearing part; a leaked context looks like data corruption, not a bug.
- The tenant comes from the user row, not the JWT. The token is trusted only for the subject; reading the row means moving a user between tenants takes effect immediately.
- Reads are strict:
require()throws rather than defaulting — a loud failure is a bug report; a quiet fallback to tenant 1 is a data breach nobody notices. - Every pooled connection is re-scoped on checkout, unconditionally — a code path that lost its scope reads an empty database, because visibly wrong beats invisibly wrong.
GLOBALis a sentinel, never a default — onlyrunAsGlobalsets it, and non-request work (jobs, seeders, webhooks) must declare its tenant explicitly.