Skip to content
Mahesh Kadambala
All projects

Developer Tools · Maintained · 2024

Multi-Tenant Starter

Schema-per-tenant Spring Boot starter

JavaSpring BootPostgreSQLOpen Source

Role

Author

Timeline

2024

Links

Repository

Problem

Multi-tenancy gets re-implemented on every project, and the bugs are always the same: a missed tenant filter, a cache key without a tenant prefix, a background job running against the wrong schema. Manual datasource routing is error-prone, and the failure mode — cross-tenant data leakage — is the worst kind an enterprise product can have.

Business Context

Extracted from the tenancy layer of a production multi-tenant SaaS. The bet: if isolation is the default rather than a discipline, whole categories of bugs disappear. The starter targets teams building B2B SaaS on Spring Boot who need strong isolation without running a database per customer.

Architecture

Request → Tenant resolution (header | subdomain | custom)
        → TenantContext (ThreadLocal)
        → Routing DataSource (schema-per-tenant)
        → Execute → Clear context

An auto-configured servlet filter resolves the tenant, binds it to a context holder, and a routing DataSource selects the tenant's schema for the duration of the request. A TaskDecorator copies the context into pooled worker threads so @Async work stays correctly scoped — and clears it afterward, because a stale ThreadLocal on a pooled thread is itself a leakage bug.

Technology

Java 17, Spring Boot auto-configuration, Spring Data JPA, PostgreSQL schemas. Ships with three tenant-resolution strategies (header, subdomain, custom resolver) and a Docker Compose harness for local multi-schema testing.

Challenges

The hard problem was context propagation across async boundaries. ThreadLocals don't follow work onto pooled executor threads, so @Async methods silently ran without a tenant. The alternatives were passing the tenant explicitly through every method signature (invasive) or decorating every executor (one-time setup). The TaskDecorator approach won, with an aggressive "fail closed" stance: no tenant in context means an exception, never a default schema.

Solution

Add the dependency, choose a resolution strategy, done. Schema-per-tenant was chosen over row-level tenancy (stronger isolation, no forgettable predicate) and database-per-tenant (operationally heavier); the trade-off is migration tooling across many schemas, which the roadmap addresses with Flyway multi-schema helpers.

Results

  • Isolation by default — services built on the starter have had zero cross-tenant incidents.
  • New services get tenancy in minutes instead of days.
  • Async and scheduled work is tenant-safe without per-call ceremony.

Lessons Learned

ThreadLocal context needs deliberate propagation in async code — and deliberate cleanup. Fail-closed defaults matter more than flexibility when the failure mode is data leakage.