Examples
Two complete walkthroughs. The first starts from an empty application; the second starts from one that already has customers and cannot have a flag day. Both end in the same place — application code with no tenancy logic in it.
A new application
Roughly twenty minutes, and most of it is SQL you would have written anyway.
- 1
Add the dependency
<dependency> <groupId>io.tenantlayer</groupId> <artifactId>tenantlayer-spring-boot-starter</artifactId> <version>0.3.0</version> </dependency>Java 17+, Spring Boot 3.3+, Postgres. Hibernate 6 and 7 both work.
- 2
Create the table, the policy and the role
The tenant column is filled in by the database, so application code cannot set it wrongly or forget it. The role matters as much as the policy: a superuser or the table owner bypasses row-level security entirely.
create table orders ( id bigserial primary key, -- Filled in by the database from the connection's tenant. Your code never sets it. tenant_id varchar(64) not null default current_setting('tenantlayer.tenant', true), customer varchar(255) not null, amount_cents bigint not null ); create index idx_orders_tenant on orders (tenant_id); alter table orders enable row level security; alter table orders force row level security; create policy tenant_isolation on orders using (tenant_id = nullif(current_setting('tenantlayer.tenant', true), '')); -- The role your application connects as. Neither superuser nor table owner, -- or the policy is never applied to it. create role orders_app login password :'app_password'; grant usage on schema public to orders_app; grant select, insert, update, delete on orders to orders_app; grant usage, select on all sequences in schema public to orders_app; - 3
Tell it where the tenant comes from
tenantlayer.resolvers=JWT tenantlayer.jwt-claim=tenant_id tenantlayer.strict=true spring.security.oauth2.resourceserver.jwt.issuer-uri=https://your-idp.example.com/ spring.datasource.url=jdbc:postgresql://localhost:5432/app spring.datasource.username=orders_app spring.datasource.password=${DB_PASSWORD}A JWT claim rather than a header, because a header is whatever the caller typed. See tenant resolution for the other sources and how a chain orders them.
- 4
Write the entity with no tenant in it
@Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String customer; @Column(name = "amount_cents") private long amountCents; // Written by the database, read back after insert. Never set by this class. @Generated(event = EventType.INSERT) @Column(name = "tenant_id", insertable = false, updatable = false) private String tenantId; } - 5
Write the repository and controller with no tenant in them either
public interface OrderRepository extends JpaRepository<Order, Long> { } @RestController @RequestMapping("/orders") class OrderController { private final OrderRepository orders; OrderController(OrderRepository orders) { this.orders = orders; } @GetMapping List<Order> list() { return orders.findAll(); // the acting tenant's rows, and no others } @PostMapping @ResponseStatus(HttpStatus.CREATED) Order place(@RequestBody Order order) { return orders.save(order); // stamped with the acting tenant by the database } }findAll()really does mean all — Postgres returns only the rows this connection is allowed to see. The same is true of a native query, aJdbcTemplatecall or a bulk update. - 6
Prove it, then break it
@Test @WithTenant("acme") void acmeSeesOnlyItsOwnOrders() { assertThat(orders.findAll()).isNotEmpty(); // acme can see its own assertTenantCannotSee("globex"); // and nothing of globex's }Both halves matter: “cannot see the other tenant” passes trivially against an empty table, so
assertTenantCannotSeerefuses to run unless globex genuinely has rows. Then drop the policy and confirm the test goes red — see testing.
An existing application
You already have customers, a tenant column of some kind, and predicates scattered through your repositories. The order below is deliberate: nothing changes behaviour until step 4, and each step before it is independently revertible.
Where you probably are
// Every method carries the tenant, and every one of them is a chance to forget.
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByTenantId(String tenantId);
Optional<Order> findByIdAndTenantId(Long id, String tenantId);
}
@GetMapping("/orders")
List<Order> list(@RequestHeader("X-Tenant-ID") String tenantId) {
return orders.findByTenantId(tenantId);
}This works until someone writes one query without the predicate. That is the bug class being removed — not a performance problem, and not a tidiness problem.
- 1
Add the dependency and leave it switched off
tenantlayer.resolvers=HEADER tenantlayer.strict=false # temporarily: nothing is rejected yetThe filter resolves and binds a tenant, and nothing yet depends on it. Deploy this on its own and confirm nothing changed.
- 2
Backfill the tenant column
-- 1. The column, if you do not already have one. Backfill before adding the constraint. alter table orders add column tenant_id varchar(64); update orders set tenant_id = accounts.slug from accounts where orders.account_id = accounts.id; alter table orders alter column tenant_id set not null; alter table orders alter column tenant_id set default current_setting('tenantlayer.tenant', true); create index concurrently idx_orders_tenant on orders (tenant_id);concurrentlybecause the table has rows and traffic. The default is added after the backfill so existing writes are unaffected while it runs. - 3
Add the policy — one table at a time
-- 2. The policy, one table at a time. Nothing changes until the role does. alter table orders enable row level security; alter table orders force row level security; create policy tenant_isolation on orders using (tenant_id = nullif(current_setting('tenantlayer.tenant', true), '')); -- 3. The switch: move the application to a role the policy applies to. -- Until this line, every query still behaves exactly as it did before. create role orders_app login password :'app_password'; grant select, insert, update, delete on orders to orders_app;Adding a policy changes nothing while your application still connects as the owner. That is what makes this safe to do table by table, over as many deploys as you like.
- 4
Switch the role — the only step that changes behaviour
spring.datasource.username=orders_app spring.datasource.password=${APP_DB_PASSWORD} tenantlayer.strict=trueNow the policy applies. Everything still works, because your queries still carry their own predicates — they are simply redundant. Revert by pointing the datasource back at the old role.
- 5
Delete the predicates, once they are redundant
// The predicates come out only once the policy is enforcing them. public interface OrderRepository extends JpaRepository<Order, Long> { } @GetMapping("/orders") List<Order> list() { return orders.findAll(); }This is the payoff, and it comes last on purpose. Removing a predicate before the policy is enforcing it turns a redundant filter into no filter at all.
- 6
Verify that the policy — not the query — is doing the work
@Test @WithTenant("acme") void thePolicyIsDoingTheWorkNowAndNotTheQuery() { // findAll() has no predicate at all. If the policy is not applied, // this returns every tenant's rows and the assertion fails loudly. assertThat(orders.findAll()).isNotEmpty(); assertTenantCannotSee("globex"); }Run this after step 5, when there is no predicate left to pass the test for the wrong reason. If it goes green with the policy dropped, the isolation was never coming from the database.
Then what?
Recipes covers what comes next — onboarding a tenant, suspending one so it actually stops being readable, nightly jobs across every tenant, admin endpoints that span tenants, Kafka, and migrations. Each says what breaks when it is done wrong, because most of these fail silently rather than loudly.
And examples/order-service is all of the above as a running application, with thirty-two tests that each assert something which can fail.