tenantlayer.io

Getting started

1. Add the dependency

<dependency>
  <groupId>io.tenantlayer</groupId>
  <artifactId>tenantlayer-spring-boot-starter</artifactId>
  <version>0.4.0</version>
</dependency>

Java 17+, Spring Boot 3.3+, Postgres. Hibernate 6 and 7 are both supported.

2. Configure resolution and your datasource

tenantlayer.resolvers=HEADER
tenantlayer.header=X-Tenant-ID
tenantlayer.strict=true
 
spring.datasource.url=jdbc:postgresql://localhost:5432/app
spring.datasource.username=orders_app
spring.datasource.password=${DB_PASSWORD}

Nothing else changes about your datasource — TenantLayer wraps whichever one your application already defines, so your pool settings, your URL and your credentials stay exactly as they are.

strict=true is the default and should stay that way. A request with no resolvable tenant is rejected with 400 rather than served with no tenant bound — which would return an empty result set that reads as "no data" and sends the caller hunting for a bug in their query.

Read Securing resolution before exposing this to the internet. A header is whatever the caller typed. It is safe behind a gateway that overwrites it, and unsafe otherwise.

3. Give your tables a tenant column

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);

The entity has no tenancy logic either — the column is read back after insert, never written:

@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;
 
    @Generated(event = EventType.INSERT)
    @Column(name = "tenant_id", insertable = false, updatable = false)
    private String tenantId;
}

4. Add the policy

Generate it rather than writing it by hand:

@Autowired TenantScopedEntityScanner scanner;
@Autowired RlsPolicyGenerator generator;
 
System.out.println(generator.generate(scanner.scan()));

Review the output, commit it as a migration, apply it with Flyway or Liquibase. It is plain SQL — the policies keep working if you remove TenantLayer.

5. Connect as a role that is not the owner

This is the step people skip, and skipping it silently disables everything above.

create role orders_app login password '...';
grant usage on schema public to orders_app;
grant select, insert, update, delete on orders to orders_app;

A Postgres superuser bypasses row-level security entirely, and so does the table owner unless the table has FORCE ROW LEVEL SECURITY (the generator emits it). Connect as a least-privileged role, or your policies are decorative.

6. Write nothing else

@RestController
@RequestMapping("/orders")
class OrderController {
 
    private final OrderRepository orders;
 
    @GetMapping
    List<Order> list() {
        return orders.findAll();   // no tenant parameter, no filter, no predicate
    }
}

findAll() emits select ... from orders with no where tenant_id = ?. The rows come back scoped because Postgres applies the policy to the connection.

Two requests, two answers, one endpoint:

curl localhost:8080/orders -H "X-Tenant-ID: acme"
# [{"id":1,"customer":"Wile E. Coyote","amountCents":4999,"tenantId":"acme"}]
 
curl localhost:8080/orders -H "X-Tenant-ID: globex"
# [{"id":2,"customer":"Hank Scorpio","amountCents":999999,"tenantId":"globex"}]
 
curl localhost:8080/orders
# 400 — strict mode, no tenant could be resolved

7. Prove it

@Test
@WithTenant("acme")
void oneTenantCannotSeeAnother() {
    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; assertTenantCannotSee refuses to run unless the other tenant genuinely has rows. See Testing.

Where to go next

You now have isolation on one datasource, for requests that carry a header. The next questions people hit, in the order they hit them:

NextGuide
"A header is spoofable — how do I resolve from a token?"Tenant resolution
"How do I stop a caller claiming a tenant they don't belong to?"Securing resolution
"My @Async method returns nothing"Context propagation
"How do I run a nightly job for every tenant?"The tenant registry
"I use @Cacheable"Caching — read this one, it is a hole RLS cannot cover
"How do I migrate every tenant's schema?"Migrations
"I want a schema or a database per tenant instead"Isolation strategies
"It returns nothing and I don't know why"Troubleshooting

If you are adding this to a system that already has customers rather than a new one, read Adopting in an existing app instead — it goes on gradually, table by table, rather than as a flag day.