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/products

Example 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.