We just published spring-boot-multitenant-starter, an open source repo that shows how we wire Spring Boot and Hibernate together for multi-tenancy at PresenceWave. It is not a library and there's nothing to implementation(...) into your build.gradle. It's the tenancy plumbing from our production app — request filter, Hibernate hook, save-time guard, schema, and an integration test that proves the isolation — with the package renamed and everything that wasn't tenancy trimmed out. It has not been compiled or published anywhere as an artifact; it exists to be read next to this post.
If you're building a SaaS product and deciding how to keep tenant A from ever seeing tenant B's rows, this is one concrete way to do it. Not the only way, and not free of rough edges — we kept the rough edges in on purpose, because they're the most useful part of the discussion.
Three kinds of requests
Before the architecture makes sense, it helps to see what it has to handle. A multi-tenant app like this ends up serving three different kinds of traffic, and each needs a different answer to "whose data is this":
- No tenant yet. Login, magic-link validation, health checks — anything that happens before, or instead of, authentication. There's nothing to scope.
- A tenant from an authenticated session. A logged-in user working inside their own tenant. This is the case this repo is built around, and where Hibernate's discriminator does the actual isolating.
- A tenant resolved from the request, with nobody logged in. A visitor on a tenant's own public page — a blog post, a links page, a widget embed — reached through that tenant's own domain rather than a login. The tenant is known here too; it just comes from the request's host instead of a session.
This repo's TenantResolutionFilter is the session-based half of the real one — it covers (1) and (2). (3) is real, and in production it's probably the largest category by request volume (every anonymous visitor to a customer's blog is this case), but it runs on host-based tenant resolution, which is product-specific and out of scope here (see "What's not here"). It's also a different problem than the one this post is about: most of what gets read that way is supposed to be public, so the interesting question isn't isolating reads — it's making sure a write that comes in anonymously, like a form submission, still lands on the right tenant. That's its own post.
The isolation model, and why we picked it
There are three common ways to keep tenants apart in a relational database:
| Model | Isolation | Operational cost |
|---|---|---|
| Database per tenant | Strongest | A connection pool and a migration run per tenant — cost grows with tenant count |
| Schema per tenant | Strong | Needs a custom MultiTenantConnectionProvider and a migration per schema |
| Shared schema + discriminator column | Enforced in the ORM | One schema, one migration, cheap to add a tenant. Isolation depends on every query actually going through Hibernate |
We use the third one. Hibernate 6 supports it natively: set hibernate.multiTenancy=DISCRIMINATOR, mark a column @TenantId, and hand Hibernate a CurrentTenantIdentifierResolver. From then on, Hibernate appends AND tenant_id_filter = ? to every query against a tenant-scoped entity, and fills that column on insert. No WHERE tenant = ? in any repository method. No tenant parameter threaded through service calls. The ORM does it, which means it's as hard to forget as it is to opt out of by accident.
The honest tradeoff is in the last column: this model is cheap precisely because it pushes the isolation guarantee down to "every code path goes through Hibernate." The repo's test suite and the "known limitations" section below exist to make that boundary explicit rather than implicit.
Architecture
Here's the whole mechanism in one picture — from the browser down to the exact query Postgres receives:

The color coding tracks the one fork that matters: the normal path resolves an actual tenant and Hibernate appends WHERE tenant_id_filter = ? to every query; the amber path is what happens for a request marked public — no predicate at all, every tenant's rows visible. That second path is drawn differently on purpose. It's the one place in this design where isolation stops being automatic and becomes whatever the handler does.
Four pieces do all the work:
TenantResolutionFilterruns once per request, after Spring Security, and decides whether this request has a tenant.TenantContextHolderis where that decision gets stored — a pair ofThreadLocals, read by everything downstream.CurrentTenantIdentifierResolverImplis Hibernate's hook into that holder. It's the thing that actually makesWHERE tenant_id_filter = ?happen.TenantAwareAspectcloses the one gap Hibernate's read-side filtering doesn't cover: writes.
Resolving the tenant: a filter that runs after security, on purpose
// web/TenantResolutionFilter.java (trimmed) — the decision the whole filter boils down to
private void resolveTenant(String path) {
if (!path.startsWith(TENANT_SCOPED_PATH_PREFIX)) {
// Not a tenant-scoped endpoint: public request, Hibernate runs in root mode.
TenantContextHolder.setPublicRequest(true);
return;
}
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof UserDetails userDetails) {
TenantUser membership = tenantUserRepository
.findByUserEmail(userDetails.getUsername())
.orElseThrow(() -> new IllegalStateException("Authenticated user has no tenant membership"));
TenantContextHolder.setTenant(membership.getTenant());
} else {
TenantContextHolder.setTenant(null);
TenantContextHolder.setPublicRequest(true);
}
}
Three outcomes come out of this one method: a non-/api path, an unauthenticated /api call, and an authenticated /api call are each handled differently, and only the last one gets an actual tenant set.*
Two details here are easy to get wrong and worth calling out:
The filter order is load-bearing. Spring Boot registers its security filter chain at order -100. We register this filter at order 1 — after security — because it reads the Authentication off SecurityContextHolder, which doesn't exist until security has run. Our own production code used to carry a comment saying the opposite ("keep before security"); it was wrong, and we fixed it while extracting this repo. A stale comment pointing the wrong way on something this order-sensitive is exactly the kind of thing worth a second look in your own codebase.
The lookup that finds the tenant can't be filtered by the tenant. At the moment findByUserEmail runs, no tenant is known yet — that's the whole point of the query. If it were a normal derived query against a @TenantId-annotated entity, Hibernate would quietly append tenant_id_filter = '-1' (the fail-closed default, more on that below) and find nothing, ever. So it's a native query instead:
// tenant/TenantUserRepository.java (trimmed)
@Query(value = "SELECT tu.* FROM tenant_users tu JOIN users u ON tu.user_id = u.id WHERE u.email = :email", nativeQuery = true)
Optional<TenantUser> findByUserEmail(@Param("email") String email);
Native queries bypass @TenantId entirely — that's exactly what this bootstrap lookup needs, and exactly why every other native query in a tenant-aware codebase is a place to double-check manually. Hibernate's safety net only covers JPQL and derived queries.
Teaching Hibernate who's asking
// tenant/CurrentTenantIdentifierResolverImpl.java
public static final Long DEFAULT_TENANT_ID = -1L; // can never match a real row — real ids start at 1
@Override
public String resolveCurrentTenantIdentifier() {
try {
if (TenantContextHolder.getCurrentTenantId() != null) {
return TenantContextHolder.getCurrentTenantId();
}
} catch (Exception e) {
return "" + DEFAULT_TENANT_ID;
}
return "" + DEFAULT_TENANT_ID;
}
@Override
public boolean isRoot(Object tenantId) {
Boolean publicRequest = TenantContextHolder.getPublicRequest();
return publicRequest != null && publicRequest;
}
Two decisions in fifteen lines carry most of the design:
- Fail closed, not fail open. A request that forgot to set a tenant doesn't see every tenant's data — it sees nothing, because
-1matches no real row. The failure mode of a bug here is an empty page, not a data leak. isRoot()is the one escape hatch, and it's a real one: when it returnstrue, Hibernate stops appending the predicate at all — the amber path in the diagram above. We use it for exactly two things — public pages like login, and background jobs that legitimately need to iterate across tenants — and we're not going to pretend it isn't the single riskiest line in this design.
This gets registered on the EntityManagerFactory explicitly, because we build it by hand instead of letting Spring Boot autoconfigure it:
// config/PrimaryDataSourceConfig.java
return builder
.dataSource(dataSource)
.packages("com.example.multitenant")
.persistenceUnit("primaryPU")
.properties(Map.of("hibernate.tenant_identifier_resolver", tenantIdentifierResolver))
.build();
Opting an entity in: one extends, two columns
// tenant/TenantAwareEntity.java (trimmed)
@MappedSuperclass
public abstract class TenantAwareEntity {
@Id @Tsid
protected Long id;
@ManyToOne @JoinColumn(name = "tenant_id")
protected Tenant tenant; // relational FK — for joins and integrity
@TenantId
protected String tenantIdFilter; // Hibernate's discriminator — what it actually filters on
@CreationTimestamp protected LocalDateTime createdAt;
@UpdateTimestamp protected LocalDateTime updatedAt;
}
Two tenant columns, doing two different jobs. tenant is a normal foreign key — it's what you join on, and what keeps the schema relationally sound. tenantIdFilter is the column @TenantId marks for Hibernate; it's a String because that's what the resolver returns. An entity's entire opt-in to tenancy is extending this class:
// example/Project.java (trimmed — Lombok annotations and imports omitted)
@Entity
public class Project extends TenantAwareEntity {
@Column(nullable = false)
private String name;
}
// example/ProjectRepository.java (trimmed)
public interface ProjectRepository extends JpaRepository<Project, Long> {
List<Project> findByName(String name); // no tenant parameter — Hibernate adds it
}
Nothing in findByName mentions a tenant. That's the appeal of discriminator-based filtering: the isolation lives in one base class and one resolver, not in every method you write afterward.
The gap Hibernate doesn't close: writes
@TenantId fills tenantIdFilter on insert, but it doesn't touch the tenant relationship, and nothing stops application code from constructing an entity that points at someone else's tenant — say, from an id a client handed you. That's TenantAwareAspect's job, running on every save()/saveAll() across any Spring Data repository:
// tenant/TenantAwareAspect.java (trimmed)
@Before("execution(* org.springframework.data.repository.CrudRepository+.save(..)) || "
+ "execution(* org.springframework.data.repository.CrudRepository+.saveAll(..))")
public void beforeSave(JoinPoint joinPoint) {
if (!tenantAwareUtil.hasTenantContext()) return;
Object entity = joinPoint.getArgs()[0];
if (entity instanceof TenantAwareEntity tenantAwareEntity) {
Tenant currentTenant = tenantAwareUtil.getCurrentTenant();
if (tenantAwareEntity.getTenant() == null) {
// New entity: stamp it from the current context.
tenantAwareEntity.setTenant(currentTenant);
tenantAwareEntity.setTenantIdFilter("" + currentTenant.getId());
} else if (!tenantAwareEntity.belongsToTenant(currentTenant.getId())) {
// Points at a different tenant than the one this request is running as.
throw new SecurityException("Attempt to save entity with mismatched tenant");
}
}
}
New entity, no tenant set yet → stamp it. Existing entity, wrong tenant → throw, loudly, rather than write it. It's a pointcut on the repository layer, which means it's one piece of code that every save passes through, the same way the resolver is one piece of code that every read passes through.
It also has a known gap we left in rather than quietly fixing: it only inspects joinPoint.getArgs()[0]. For save(entity) that's the entity. For saveAll(collection), that first argument is the collection, the instanceof TenantAwareEntity check fails, and the call passes through completely unstamped and unchecked. If your codebase leans on saveAll, that's worth fixing before you adopt this pattern, not after.
Proving it, against a real database
Mocking the repository would prove nothing here — the actual guarantee is the SQL Hibernate generates. So the isolation test spins up real Postgres via Testcontainers and asserts against it:
// TenantIsolationIntegrationTest.java (trimmed)
@Test
void queries_AsAnotherTenant_DoNotSeeTheRows() {
TenantContextHolder.setTenant(tenantA);
Project alpha = projectRepository.save(Project.builder().name("alpha").build());
TenantContextHolder.setTenant(tenantB);
assertThat(projectRepository.findAll()).isEmpty();
assertThat(projectRepository.findById(alpha.getId())).isEmpty(); // even by exact id
assertThat(projectRepository.findByName("alpha")).isEmpty();
}
@Test
void queries_WithoutTenantContext_SeeNothing() {
TenantContextHolder.setTenant(tenantA);
projectRepository.save(Project.builder().name("alpha").build());
TenantContextHolder.clear(); // no tenant, not public — the fail-closed path
assertThat(projectRepository.findAll()).isEmpty();
}
@Test
void save_WithEntityOfAnotherTenant_IsRejected() {
TenantContextHolder.setTenant(tenantA);
Project foreign = Project.builder().name("gamma").tenant(tenantB).build();
assertThatThrownBy(() -> projectRepository.save(foreign))
.isInstanceOf(SecurityException.class)
.hasMessageContaining("mismatched tenant");
}
Three different things are being checked here, and they map directly onto the design decisions above: cross-tenant reads come back empty (not an error — empty, which is what fail-closed is supposed to look like from the caller's side), a forgotten tenant context sees nothing rather than everything, and a write that points at the wrong tenant is rejected instead of silently corrected. There's a fourth test (not shown) that deliberately sets publicRequest = true and checks that it does see every tenant's rows — because that's the documented tradeoff of root mode, and a test suite that didn't check it would be hiding the one behavior most worth knowing about.
What we didn't smooth over
We left the limitations in the README instead of fixing them before publishing, because they're better discussion material than a spotless repo would be:
- Context doesn't follow work onto other threads by default.
TenantContextHolderusesTransmittableThreadLocal, which only propagates through TTL-aware executors or the TTL Java agent — neither is wired up here. A plain@Asyncmethod or@Scheduledjob has to set the tenant itself and clear it in afinally, or it runs with no tenant at all. saveAllbypasses the aspect, as covered above.- Public requests are unfiltered, full stop.
isRoot()isn't a softer filter — it's no filter. Isolation for that code path is whatever the handler does, not what the ORM does. - Native queries are outside the safety net by construction.
TenantUserRepository.findByUserEmailrelies on that on purpose; any other native query in a tenant-aware codebase has to scope itself. - The discriminator column needs its own index, leading it, or every tenant-scoped query scans the table. The reference migration (
V1__tenancy_core.sql) adds these; it's easy to forget on a table added later.
What's not here
This repo is scoped to the tenancy plumbing specifically. It doesn't include how we resolve a tenant from a custom domain, API-token authentication, invitations, or anything product-specific — those are a different, separate story from "how does Hibernate know whose row this is."
Try it, read it, take it apart
The repo's README has a suggested reading order (TenantContextHolder → CurrentTenantIdentifierResolverImpl → TenantAwareEntity → the config → the filter → the aspect → the isolation test), and docs/architecture.md goes deeper on a couple of things this post only touched — the request lifecycle in full, and what public-request handling actually costs.
We built this because we needed exactly this for PresenceWave. If you're weighing shared-schema-with-discriminator against schema-per-tenant or database-per-tenant for your own SaaS, hopefully seeing one real, slightly-imperfect implementation — including the parts we'd still like to fix — is more useful than another description of the pattern in the abstract.
→ github.com/JulioLabs/spring-boot-multitenant-starter
* This post is scoped to how tenant identity gets determined and enforced once a request is in play. We'll cover authentication, authorization, and how we keep tenant isolation intact end to end in a follow-up post.