Quarkus Multitenancy Without Tenant Plumbing in Every Service
What the Quarkiverse Multitenancy extension gives you: one resolution point, clean domain code, and three tested resolution modes for database-per-tenant isolation.
Tenant plumbing leaking through every service method gets ugly fast. createInvoice(tenantId, ...), loadDashboard(tenantId, ...), cacheKey(tenantId, ...) all look harmless for a while. Then the service layer turns into a mix of domain logic and reminders that isolation is still your job.
ThreadLocal is the usual shortcut. It works until the request flow changes. A filter runs too late, a background task forgets cleanup, or a refactor crosses a boundary where the tenant should have been explicit.
Quarkus already covers two big pieces. OIDC multitenancy picks the right authentication tenant. Hibernate ORM multitenancy isolates data. The missing part is the boring middle: resolve one tenant id from the request, keep it in request state, and let the rest of the app stop caring.
The preview Quarkus Multitenancy extension fills that gap. It gives you a small resolution API, a request-scoped TenantContext, HTTP resolvers for headers, JWT claims, cookies, and path segments, plus an ORM bridge that lets Hibernate route by the tenant already in that context.
We build a small fictional SaaS service called VaultBoard. It has two tenants, acme and globex, each backed by its own PostgreSQL database. We expose one REST API, create records for both tenants through the same endpoint, and prove that each tenant only sees its own data. After that, we switch the same app to JWT resolution and then add a custom host-based resolver.
What we build
A Quarkus REST API with
GET /api/dashboards,POST /api/dashboards, andGET /api/dashboards/tenantTwo tenant databases,
acmeandglobex, started automatically by Quarkus Dev ServicesHeader-based tenant resolution with
X-TenantTenant-aware Hibernate ORM routing with no
tenantIdparameter in the service layerA JWT-based tenant slice verified with a dedicated test profile
A custom host-based resolver verified with a dedicated test profile
What you need
You need a normal Java and Quarkus setup plus a working container runtime. The version set here is Java 25, Quarkus 3.37.0, and Quarkus Multitenancy 0.1.0.
JDK 25
Docker or Podman
curlAbout 45 minutes
Create the project
Use the CLI to create the application without starter code and follow along or grab the code from my Github repository:
quarkus create app io.mainthread:vaultboard \
--package-name=io.mainthread.vaultboard \
--extension='rest-jackson,hibernate-orm-panache,jdbc-postgresql' \
--java=25 \
--no-codeThe Quarkus CLI keeps the platform-managed dependencies aligned. The multitenancy extension is outside the platform, so add it and the Flyway extension manually to pom.xml:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-flyway</artifactId>
</dependency>
<dependency>
<groupId>io.quarkiverse.multitenancy</groupId>
<artifactId>quarkus-multitenancy-http</artifactId>
<version>0.1.0</version>
</dependency>
<dependency>
<groupId>io.quarkiverse.multitenancy</groupId>
<artifactId>quarkus-multitenancy-orm</artifactId>
<version>0.1.0</version>
</dependency>The two multitenancy artifacts handle different concerns:
quarkus-multitenancy-httpresolves the tenant from the incoming request and stores it inTenantContextquarkus-multitenancy-ormbridges that resolved tenant into Hibernate ORM
One detail is easy to miss if you saw older examples of this extension. The coordinates are now io.quarkiverse.multitenancy:*, not the earlier pre-Quarkiverse group. That is one good reason to pin the preview version explicitly.
Let Dev Services own the databases
As soon as the named datasources exist and jdbc-postgresql is on the classpath, Quarkus Dev Services starts the PostgreSQL containers for us. No Compose file. No JDBC URLs to configure by hand.
Put this in src/main/resources/application.properties:
quarkus.hibernate-orm.datasource=__bootstrap
quarkus.hibernate-orm.schema-management.strategy=none
quarkus.hibernate-orm.multitenant=DATABASE
quarkus.hibernate-orm.log.sql=true
quarkus.hibernate-orm.validate-in-dev-mode=false
quarkus.datasource.devservices.enabled=false
quarkus.datasource.__bootstrap.db-kind=postgresql
quarkus.datasource.__bootstrap.devservices.db-name=bootstrap
quarkus.datasource.acme.db-kind=postgresql
quarkus.datasource.acme.devservices.db-name=acme
quarkus.datasource.globex.db-kind=postgresql
quarkus.datasource.globex.devservices.db-name=globex
quarkus.flyway.acme.locations=classpath:db/migration
quarkus.flyway.acme.migrate-at-start=true
quarkus.flyway.globex.locations=classpath:db/migration
quarkus.flyway.globex.migrate-at-start=true
quarkus.multi-tenant.http.enabled=true
quarkus.multi-tenant.http.strategy=header
quarkus.multi-tenant.http.header-name=X-Tenantquarkus.datasource.devservices.enabled=false disables Dev Services only for the unused default datasource. The named datasources still start their own PostgreSQL containers. I set this so Quarkus does not invent a default datasource we do not want.
quarkus.datasource.__bootstrap.* defines the bootstrap datasource. The ORM bridge uses it before a request-scoped tenant exists. If you skip it, Hibernate has no safe datasource to use during startup.
quarkus.multi-tenant.http.strategy=header keeps this slice in header mode. The preview extension still defaults to header,jwt,cookie. If you leave the property unset, jwt stays in the chain, and the app starts asking for JWT verification settings even though this demo is still on headers.
quarkus.flyway.<tenant>.migrate-at-start=true runs Flyway migrations on each tenant datasource when the app boots. Both tenants point at the same classpath:db/migration location because they share the same table structure. Hibernate’s own schema management does not support DATABASE multitenancy, so Flyway handles it instead.
Model the data
Create the entity first:
package io.mainthread.vaultboard.dashboard;
import java.math.BigDecimal;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = "dashboards")
public class Dashboard extends PanacheEntity {
@Column(nullable = false)
public String name;
@Column(name = "owner_email", nullable = false)
public String ownerEmail;
@Column(name = "monthly_budget", nullable = false, precision = 12, scale = 2)
public BigDecimal monthlyBudget;
}There is no tenant field on the row model. Each tenant gets its own datasource, so the entity stays clean.
Create the request type next:
package io.mainthread.vaultboard.dashboard;
import java.math.BigDecimal;
public record CreateDashboardRequest(
String name,
String ownerEmail,
BigDecimal monthlyBudget) {
}Keep the service layer clean
DashboardService is small on purpose:
package io.mainthread.vaultboard.dashboard;
import java.util.List;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class DashboardService {
public List<Dashboard> listAll() {
return Dashboard.listAll();
}
@Transactional
public Dashboard create(CreateDashboardRequest request) {
Dashboard dashboard = new Dashboard();
dashboard.name = request.name();
dashboard.ownerEmail = request.ownerEmail();
dashboard.monthlyBudget = request.monthlyBudget();
dashboard.persist();
return dashboard;
}
}The service never asks which tenant is active. No tenantId parameter. No context lookup. Hibernate already knows which datasource to use.
The resource follows the same pattern:
package io.mainthread.vaultboard.dashboard;
import java.util.List;
import java.util.Map;
import io.quarkiverse.multitenancy.core.runtime.context.TenantContext;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/api/dashboards")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class DashboardResource {
private final DashboardService dashboardService;
private final TenantContext tenantContext;
public DashboardResource(DashboardService dashboardService, TenantContext tenantContext) {
this.dashboardService = dashboardService;
this.tenantContext = tenantContext;
}
@GET
public List<Dashboard> list() {
return dashboardService.listAll();
}
@POST
public Dashboard create(CreateDashboardRequest request) {
return dashboardService.create(request);
}
@GET
@Path("/tenant")
public Map<String, String> currentTenant() {
return Map.of("tenant", tenantContext.getTenantId().orElse("missing"));
}
}/tenant is a cheap probe. It shows what the resolver stored before we involve the database.
Migrate the tenant schemas with Flyway
Hibernate’s built-in schema management (drop-and-create, update) does not run against tenant datasources in DATABASE mode. It only targets the single datasource Hibernate is pointed at, which is __bootstrap here. The Quarkus documentation recommends Flyway per named datasource instead.
Both tenants share the same table structure, so one migration file is enough. Create src/main/resources/db/migration/V1__create_dashboards.sql:
CREATE SEQUENCE IF NOT EXISTS dashboards_SEQ START WITH 1 INCREMENT BY 50;
CREATE TABLE IF NOT EXISTS dashboards (
monthly_budget NUMERIC(12,2) NOT NULL,
id BIGINT NOT NULL,
name VARCHAR(255) NOT NULL,
owner_email VARCHAR(255) NOT NULL,
PRIMARY KEY (id)
);The Flyway properties we added earlier point both acme and globex at this same classpath:db/migration location and run the migration on startup. No Java code needed.
Run the header-based slice
The first worked slice should prove the full path: resolve the tenant from a header, store it in TenantContext, route Hibernate to the right datasource, and keep each tenant’s rows separate.
Start the app in one terminal:
./mvnw quarkus:devOn the first boot, Dev Services starts three PostgreSQL containers: __bootstrap, acme, and globex. Keep that terminal running. Open a second terminal in the same project directory for the requests below.
Confirm that the resolver sees the tenant header and that the database starts empty.
curl -H "X-Tenant: acme" http://localhost:8080/api/dashboards/tenant
curl -H "X-Tenant: globex" http://localhost:8080/api/dashboards/tenant
curl -H "X-Tenant: acme" http://localhost:8080/api/dashboardsExpected output:
{"tenant":"acme"}
{"tenant":"globex"}
[]/tenant proves the HTTP resolver is working. The empty list matters too. It tells you there is no stale data hiding in the tenant database before the real demo starts.
Create one dashboard for
acme.
curl -X POST \
-H "X-Tenant: acme" \
-H "Content-Type: application/json" \
-d '{"name":"ARR","ownerEmail":"alice@acme.example","monthlyBudget":120000.00}' \
http://localhost:8080/api/dashboardsExpected output:
{"id":1,"name":"ARR","ownerEmail":"alice@acme.example","monthlyBudget":120000.00}Create one dashboard for
globex.
curl -X POST \
-H "X-Tenant: globex" \
-H "Content-Type: application/json" \
-d '{"name":"Cash Flow","ownerEmail":"finops@globex.example","monthlyBudget":98000.00}' \
http://localhost:8080/api/dashboardsExpected output:
{"id":1,"name":"Cash Flow","ownerEmail":"finops@globex.example","monthlyBudget":98000.00}Before the next step, predict what each tenant should see. The right answer is one row per tenant, not a shared list with two rows.
Read the data back through the same endpoint.
curl -H "X-Tenant: acme" http://localhost:8080/api/dashboards
curl -H "X-Tenant: globex" http://localhost:8080/api/dashboardsExpected output on a fresh start:
[{"id":1,"name":"ARR","ownerEmail":"alice@acme.example","monthlyBudget":120000.00}]
[{"id":1,"name":"Cash Flow","ownerEmail":"finops@globex.example","monthlyBudget":98000.00}]Both rows start at id=1. That is normal because each tenant has its own database and its own sequence. If one response shows both rows, tenant resolution or datasource routing is wrong.
Check the failure path for header mode.
curl -i http://localhost:8080/api/dashboardsExpected output starts with:
HTTP/1.1 400 Bad RequestThat error tells you the header-based resolver chain is active. In this mode, the request must include X-Tenant.
Add a repeatable header-mode test
I want the first slice under test before I add more resolution modes.
Dev Services keeps the tenant containers alive for the whole test run, so I add one helper that clears both tenant databases before and after each test. Outside HTTP there is no incoming request to populate TenantContext, so the helper sets the tenant directly:
package io.mainthread.vaultboard.support;
import java.util.List;
import io.mainthread.vaultboard.dashboard.Dashboard;
import io.quarkiverse.multitenancy.core.runtime.context.TenantContext;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class TenantDataCleaner {
private final TenantContext tenantContext;
public TenantDataCleaner(TenantContext tenantContext) {
this.tenantContext = tenantContext;
}
@Transactional
public void clearAll() {
for (String tenant : List.of("acme", "globex")) {
tenantContext.setTenantId(tenant);
Dashboard.deleteAll();
}
tenantContext.clear();
}
}Then add the test class:
package io.mainthread.vaultboard.dashboard;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasSize;
import java.math.BigDecimal;
import io.mainthread.vaultboard.support.TenantDataCleaner;
import jakarta.inject.Inject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
@QuarkusTest
class DashboardResourceTest {
@Inject
TenantDataCleaner tenantDataCleaner;
@BeforeEach
void resetBeforeEachTest() {
tenantDataCleaner.clearAll();
}
@AfterEach
void cleanup() {
tenantDataCleaner.clearAll();
}
@Test
void acmeDoesNotSeeGlobexData() {
given()
.header("X-Tenant", "acme")
.contentType("application/json")
.body(new CreateDashboardRequest("ARR", "alice@acme.example", new BigDecimal("120000.00")))
.when().post("/api/dashboards")
.then()
.statusCode(200);
given()
.header("X-Tenant", "globex")
.contentType("application/json")
.body(new CreateDashboardRequest("Cash Flow", "finops@globex.example", new BigDecimal("98000.00")))
.when().post("/api/dashboards")
.then()
.statusCode(200);
given()
.header("X-Tenant", "acme")
.when().get("/api/dashboards")
.then()
.statusCode(200)
.body("$", hasSize(1))
.body("[0].ownerEmail", equalTo("alice@acme.example"));
given()
.header("X-Tenant", "globex")
.when().get("/api/dashboards")
.then()
.statusCode(200)
.body("$", hasSize(1))
.body("[0].ownerEmail", equalTo("finops@globex.example"));
}
@Test
void missingHeaderFailsFastInHeaderMode() {
given()
.when().get("/api/dashboards")
.then()
.statusCode(400);
}
}That second test matters. In header mode, the ORM module adds a filter that rejects requests without X-Tenant.
Why the resolution API matters
The contract matters more than any single resolver. The built-in header resolver is one implementation. TenantResolver no longer returns Optional<String>. It returns a sealed TenantResolution with three outcomes:
Resolvedmeans a resolver found a usable tenant id, so the chain stopsNotApplicablemeans this resolver had nothing to work with, so the next strategy may tryRejectedmeans input was present but invalid, so the request must stop with HTTP 401
A missing header and a bad JWT are different cases. Missing input may allow fallback. Present but invalid input should stop the request. The extension makes that rule explicit in the API.
Add JWT resolution without rewriting the main config
I leave the application itself in header mode. For the JWT slice, I switch strategies only inside a test profile. That keeps the first slice stable while we add another one.
Add the JWT dependencies:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-jwt</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-jwt-build</artifactId>
</dependency>Generate a dev-only key pair and place both files under src/main/resources:
openssl genrsa -out src/main/resources/privateKey.pem 2048
openssl rsa -pubout -in src/main/resources/privateKey.pem -out src/main/resources/publicKey.pemNow add the profile:
package io.mainthread.vaultboard.dashboard;
import java.util.Map;
import io.quarkus.test.junit.QuarkusTestProfile;
public class JwtTenantTestProfile implements QuarkusTestProfile {
@Override
public Map<String, String> getConfigOverrides() {
return Map.of(
"quarkus.multi-tenant.http.strategy", "jwt",
"quarkus.multi-tenant.orm.header-filter.enabled", "false",
"mp.jwt.verify.publickey.location", "publicKey.pem",
"mp.jwt.verify.publickey.algorithm", "RS256",
"mp.jwt.verify.issuer", "https://auth.vaultboard.example",
"smallrye.jwt.sign.key.location", "privateKey.pem");
}
}quarkus.multi-tenant.orm.header-filter.enabled=false disables the ORM-side header filter. That filter is right for header mode, but in JWT mode it keeps asking for X-Tenant.
Then add the test:
package io.mainthread.vaultboard.dashboard;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import java.time.Duration;
import io.smallrye.jwt.build.Jwt;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.TestProfile;
@QuarkusTest
@TestProfile(JwtTenantTestProfile.class)
class JwtTenantResolutionTest {
@Test
void resolvesTenantFromVerifiedJwtClaim() {
String token = Jwt.upn("alice")
.issuer("https://auth.vaultboard.example")
.claim("tenant", "acme")
.expiresIn(Duration.ofMinutes(15))
.sign();
given()
.header("Authorization", "Bearer " + token)
.when().get("/api/dashboards/tenant")
.then()
.statusCode(200)
.body("tenant", equalTo("acme"));
}
@Test
void missingTenantClaimRejectsRequest() {
String token = Jwt.upn("alice")
.issuer("https://auth.vaultboard.example")
.expiresIn(Duration.ofMinutes(15))
.sign();
given()
.header("Authorization", "Bearer " + token)
.when().get("/api/dashboards/tenant")
.then()
.statusCode(401);
}
}This is the behavior I want. A verified token with a tenant claim resolves the tenant. A verified token without that claim gets a 401.
Add a host-based resolver
JWT is one input source. The custom resolver API lets you add your own.
This resolver reads the tenant from the subdomain and rejects unknown values:
package io.mainthread.vaultboard.tenant;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import io.quarkiverse.multitenancy.core.runtime.api.TenantResolution;
import io.quarkiverse.multitenancy.core.runtime.api.TenantResolutionContext;
import io.quarkiverse.multitenancy.core.runtime.api.TenantResolver;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.container.ContainerRequestContext;
@ApplicationScoped
public class HostTenantResolver implements TenantResolver {
private static final Set<String> KNOWN_TENANTS = Set.of("acme", "globex");
@Override
public TenantResolution resolve(TenantResolutionContext context) {
Optional<ContainerRequestContext> request = context.get(ContainerRequestContext.class);
if (request.isEmpty()) {
return TenantResolution.notApplicable();
}
String hostHeader = request.get().getHeaderString("Host");
if (hostHeader == null || hostHeader.isBlank()) {
return TenantResolution.notApplicable();
}
String authority = hostHeader.trim().toLowerCase(Locale.ROOT);
String host = authority.split(":", 2)[0];
if (host.equals("localhost") || host.equals("127.0.0.1")) {
return TenantResolution.notApplicable();
}
int firstDot = host.indexOf('.');
if (firstDot < 1) {
return TenantResolution.notApplicable();
}
String tenant = host.substring(0, firstDot);
if (!KNOWN_TENANTS.contains(tenant)) {
return TenantResolution.rejected("Unknown tenant host: " + tenant);
}
return TenantResolution.resolved(tenant);
}
}The localhost branch matters. Without it, normal local traffic starts behaving like a broken multitenant DNS setup.
Custom resolvers run before the built-in strategy chain, so the host slice only needs one test profile change:
package io.mainthread.vaultboard.dashboard;
import java.util.Map;
import io.quarkus.test.junit.QuarkusTestProfile;
public class HostTenantTestProfile implements QuarkusTestProfile {
@Override
public Map<String, String> getConfigOverrides() {
return Map.of(
"quarkus.multi-tenant.orm.header-filter.enabled", "false");
}
}The test stays small:
package io.mainthread.vaultboard.dashboard;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.TestProfile;
@QuarkusTest
@TestProfile(HostTenantTestProfile.class)
class HostTenantResolutionTest {
@Test
void resolvesTenantFromHostHeader() {
given()
.header("Host", "acme.vaultboard.example")
.when().get("/api/dashboards/tenant")
.then()
.statusCode(200)
.body("tenant", equalTo("acme"));
}
@Test
void rejectsUnknownTenantHost() {
given()
.header("Host", "unknown.vaultboard.example")
.when().get("/api/dashboards/tenant")
.then()
.statusCode(401);
}
}At this point the same TenantContext supports three inputs: header, JWT, and a custom host resolver.
What I would change for production
Before I would call this production-ready, I would change three things:
Map external tenant ids to an allowed datasource map instead of trusting raw headers, claims, or hostnames directly
Keep
quarkus.multi-tenant.http.strategyexplicit on every entry point instead of relying on the default chainLeave the ORM header filter enabled only for header mode, and disable it for JWT, host, path, or cookie-driven flows
Prove it
I run the slices one by one while building:
./mvnw test -Dtest=DashboardResourceTest
./mvnw test -Dtest=JwtTenantResolutionTest
./mvnw test -Dtest=HostTenantResolutionTestEach test covers one resolution mode:
Header mode proves tenant-aware CRUD and the ORM-side header filter
JWT mode proves claim-based tenant resolution and request rejection
Host mode proves a custom resolver can participate without rewriting the rest of the app
Result
This is the shape I want for multi-tenant code. Resolve the tenant once, store it in TenantContext, and let domain code talk about dashboards, invoices, and users. Tenant routing stays at the edge unless there is a real reason to pull it inward.


