Careeroza
Implementing Multi-tenancy
Tenant Context per Request
Example
The tenant is kept in a ThreadLocal for the duration of a request. Always clear it in finally: pool threads are reused, and a leftover tenant id leaks one customer's data to the next request:
import java.util.*;
import java.util.concurrent.*;
public class Main {
static final ThreadLocal<String> TENANT = new ThreadLocal<>();
static final List<String[]> ROWS = List.of(
new String[]{"brand-a", "Cotton Shirt"}, new String[]{"brand-b", "Leather Wallet"}, new String[]{"brand-a", "Linen Kurta"});
static List<String> findAllProducts() { // Hibernate adds: where tenant_id = ?
String t = TENANT.get();
List<String> out = new ArrayList<>();
for (String[] r : ROWS) if (r[0].equals(t)) out.add(r[1]);
return out;
}
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newSingleThreadExecutor(); // one reused worker thread
System.out.println(pool.submit(() -> { TENANT.set("brand-a"); return "req 1 brand-a: " + findAllProducts(); }).get());
// BUG: req 1 forgot to clear. req 2 has no tenant header but runs on the same thread:
System.out.println(pool.submit(() -> "req 2 no tenant: " + findAllProducts() + " <- leaked!").get());
Callable<String> safe = () -> {
TENANT.set("brand-b");
try { return "req 3 brand-b: " + findAllProducts(); }
finally { TENANT.remove(); }
};
System.out.println(pool.submit(safe).get());
System.out.println(pool.submit(() -> "req 4 no tenant: " + findAllProducts()).get());
pool.shutdown();
}
}Output
req 1 brand-a: [Cotton Shirt, Linen Kurta] req 2 no tenant: [Cotton Shirt, Linen Kurta] <- leaked! req 3 brand-b: [Leather Wallet] req 4 no tenant: []
Discriminator with @TenantId
@Entity
@Table(name = "products")
public class Product {
@Id @GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
@TenantId // org.hibernate.annotations.TenantId (Hibernate 6)
@Column(name = "tenant_id", nullable = false, updatable = false)
private String tenantId; // set automatically on insert, added to every query
private String name;
}
@Component
public class TenantResolver implements CurrentTenantIdentifierResolver<String>, HibernatePropertiesCustomizer {
@Override
public String resolveCurrentTenantIdentifier() {
String t = TenantContext.get();
return t != null ? t : "public"; // value used for system jobs; never null
}
@Override
public boolean validateExistingCurrentSessions() { return true; }
@Override
public void customize(Map<String, Object> props) {
props.put(AvailableSettings.MULTI_TENANT_IDENTIFIER_RESOLVER, this);
}
}Request
curl -H "X-Tenant-Id: brand-a" localhost:8080/api/productsExample output
select p1_0.id,p1_0.name,p1_0.tenant_id from products p1_0 where p1_0.tenant_id=?
binding parameter (1:VARCHAR) <- [brand-a]
HTTP/1.1 200 OK
[{"id":1,"name":"Cotton Shirt"},{"id":3,"name":"Linen Kurta"}]Setting the Tenant from the Request
@Component
public class TenantFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
String tenant = req.getHeader("X-Tenant-Id"); // better: a verified JWT claim
if (tenant == null || !tenant.matches("[a-z0-9-]{3,40}")) {
res.sendError(HttpServletResponse.SC_BAD_REQUEST, "Missing or invalid tenant");
return;
}
TenantContext.set(tenant);
try {
chain.doFilter(req, res);
} finally {
TenantContext.clear(); // never skip this
}
}
}Note: Never trust a plain header in production: take the tenant from the authenticated user's token and check the user belongs to it (see the Spring Security material).
Schema per Tenant
@Component
public class SchemaPerTenantConnectionProvider
implements MultiTenantConnectionProvider<String>, HibernatePropertiesCustomizer {
private final DataSource dataSource;
public SchemaPerTenantConnectionProvider(DataSource dataSource) { this.dataSource = dataSource; }
@Override public Connection getAnyConnection() throws SQLException { return dataSource.getConnection(); }
@Override public void releaseAnyConnection(Connection c) throws SQLException { c.close(); }
@Override
public Connection getConnection(String tenant) throws SQLException {
Connection c = getAnyConnection();
c.setSchema(tenant); // PostgreSQL search_path / MySQL database
return c;
}
@Override
public void releaseConnection(String tenant, Connection c) throws SQLException {
c.setSchema("public"); // reset before returning to the pool
c.close();
}
@Override public boolean supportsAggressiveRelease() { return false; }
@Override public boolean isUnwrappableAs(Class<?> t) { return false; }
@Override public <T> T unwrap(Class<T> t) { throw new UnsupportedOperationException(); }
@Override
public void customize(Map<String, Object> props) {
props.put(AvailableSettings.MULTI_TENANT_CONNECTION_PROVIDER, this);
}
}Tip: With schema or database per tenant, run Flyway once per tenant at startup (loop over tenants with Flyway.configure().schemas(tenant)...migrate()), and add a new tenant by creating its schema and migrating it.