5 SpringBoot Multi‑Tenant Architecture Patterns You Must Know
This article explains multi‑tenancy as a software architecture pattern, describes why it reduces operational costs and improves resource utilization, and presents five concrete SpringBoot designs—including separate databases, shared databases with separate schemas, shared tables, shared‑table with tenant ID, and a hybrid model—along with implementation steps, code samples, pros and cons, and suitable scenarios.
What Is Multi‑Tenancy?
Multi‑tenancy is a software architecture pattern that allows a single application instance to serve multiple customers (tenants) while keeping each tenant's data isolated and secure.
Proper multi‑tenant design can significantly reduce operational costs, improve resource utilization, and enable more efficient service delivery.
Solution 1: Separate Database per Tenant
Principle and Features
Each tenant gets a completely independent database instance, providing the highest isolation level. Databases can even be deployed on different servers.
Implementation Steps
Create multiple data source configurations : configure an independent data source for each tenant.
<code>@Configuration
public class MultiTenantDatabaseConfig {
@Autowired
private TenantDataSourceProperties properties;
@Bean
public DataSource dataSource() {
AbstractRoutingDataSource multiTenantDataSource = new TenantAwareRoutingDataSource();
Map<Object, Object> targetDataSources = new HashMap<>();
for (TenantDataSourceProperties.TenantProperties tenant : properties.getTenants()) {
DataSource tenantDataSource = createDataSource(tenant);
targetDataSources.put(tenant.getTenantId(), tenantDataSource);
}
multiTenantDataSource.setTargetDataSources(targetDataSources);
return multiTenantDataSource;
}
private DataSource createDataSource(TenantDataSourceProperties.TenantProperties tenant) {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(tenant.getUrl());
dataSource.setUsername(tenant.getUsername());
dataSource.setPassword(tenant.getPassword());
dataSource.setDriverClassName(tenant.getDriverClassName());
return dataSource;
}
}
</code>Implement tenant‑aware routing data source :
<code>public class TenantAwareRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TenantContextHolder.getTenantId();
}
}
</code>Tenant context management :
<code>public class TenantContextHolder {
private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();
public static void setTenantId(String tenantId) { CONTEXT.set(tenantId); }
public static String getTenantId() { return CONTEXT.get(); }
public static void clear() { CONTEXT.remove(); }
}
</code>Add tenant identification interceptor :
<code>@Component
public class TenantIdentificationInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String tenantId = extractTenantId(request);
if (tenantId != null) {
TenantContextHolder.setTenantId(tenantId);
return true;
}
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return false;
}
private String extractTenantId(HttpServletRequest request) {
String tenantId = request.getHeader("X-TenantID");
if (tenantId == null) {
String host = request.getServerName();
if (host.contains(".")) {
tenantId = host.split("\\.")[0];
}
}
return tenantId;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
TenantContextHolder.clear();
}
}
</code>Configure interceptor :
<code>@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private TenantIdentificationInterceptor tenantInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tenantInterceptor).addPathPatterns("/api/**");
}
}
</code>Pros and Cons
Highest data isolation and security.
Each tenant can use different database versions or types.
Easy to apply tenant‑specific database optimizations.
Fault isolation: a problem in one tenant's database does not affect others.
Independent backup, restore, and migration.
Lower resource utilization and higher cost.
Higher operational complexity due to managing many database instances.
Difficult cross‑tenant queries.
Each new tenant requires a new database instance.
Complex connection‑pool management.
Applicable Scenarios
Enterprise‑grade SaaS applications with strict isolation requirements.
Scenarios with relatively few tenants but large data volumes.
Tenants willing to pay more for better isolation.
Solution 2: Shared Database, Separate Schema
Principle and Features
All tenants share one database instance, but each tenant has its own schema (PostgreSQL) or database (MySQL), balancing resource sharing and data isolation.
Implementation Steps
Create tenant schema configuration :
<code>@Configuration
public class MultiTenantSchemaConfig {
@Autowired
private DataSource dataSource;
@Autowired
private TenantRepository tenantRepository;
@PostConstruct
public void initializeSchemas() {
for (Tenant tenant : tenantRepository.findByActive(true)) {
createSchemaIfNotExists(tenant.getSchemaName());
}
}
private void createSchemaIfNotExists(String schema) {
try (Connection connection = dataSource.getConnection()) {
String sql = "CREATE SCHEMA IF NOT EXISTS " + schema;
try (Statement stmt = connection.createStatement()) {
stmt.execute(sql);
}
} catch (SQLException e) {
throw new RuntimeException("Failed to create schema: " + schema, e);
}
}
}
</code>Tenant entity and repository :
<code>@Entity
@Table(name = "tenant")
public class Tenant {
@Id
private String id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String schemaName;
@Column
private boolean active = true;
// getters and setters
}
@Repository
public interface TenantRepository extends JpaRepository<Tenant, String> {
List<Tenant> findByActive(boolean active);
Optional<Tenant> findBySchemaName(String schemaName);
}
</code>Configure Hibernate multi‑tenant support :
<code>@Configuration
@EnableJpaRepositories(basePackages = "com.example.repository")
@EntityScan(basePackages = "com.example.entity")
public class JpaConfig {
@Autowired
private DataSource dataSource;
@Bean
public LocalContainerEntityManagerFactoryBean entityManagerFactory(EntityManagerFactoryBuilder builder) {
Map<String, Object> properties = new HashMap<>();
properties.put(org.hibernate.cfg.Environment.MULTI_TENANT, MultiTenancyStrategy.SCHEMA);
properties.put(org.hibernate.cfg.Environment.MULTI_TENANT_CONNECTION_PROVIDER, multiTenantConnectionProvider());
properties.put(org.hibernate.cfg.Environment.MULTI_TENANT_IDENTIFIER_RESOLVER, currentTenantIdentifierResolver());
return builder.dataSource(dataSource).packages("com.example.entity").properties(properties).build();
}
@Bean
public MultiTenantConnectionProvider multiTenantConnectionProvider() {
return new SchemaBasedMultiTenantConnectionProvider();
}
@Bean
public CurrentTenantIdentifierResolver currentTenantIdentifierResolver() {
return new TenantSchemaIdentifierResolver();
}
}
</code>Implement schema‑based connection provider :
<code>public class SchemaBasedMultiTenantConnectionProvider implements MultiTenantConnectionProvider {
@Autowired
private DataSource dataSource;
@Override
public Connection getAnyConnection() throws SQLException { return dataSource.getConnection(); }
@Override
public void releaseAnyConnection(Connection connection) throws SQLException { connection.close(); }
@Override
public Connection getConnection(String tenantIdentifier) throws SQLException {
Connection connection = getAnyConnection();
connection.createStatement().execute(String.format("SET SCHEMA '%s'", tenantIdentifier));
return connection;
}
@Override
public void releaseConnection(String tenantIdentifier, Connection connection) throws SQLException {
connection.createStatement().execute("SET SCHEMA 'public'");
connection.close();
}
@Override public boolean supportsAggressiveRelease() { return false; }
@Override public boolean isUnwrappableAs(Class unwrapType) { return false; }
@Override public <T> T unwrap(Class<T> unwrapType) { return null; }
}
</code>Tenant identifier resolver :
<code>public class TenantSchemaIdentifierResolver implements CurrentTenantIdentifierResolver {
private static final String DEFAULT_TENANT = "public";
@Override
public String resolveCurrentTenantIdentifier() {
String tenantId = TenantContextHolder.getTenantId();
return tenantId != null ? tenantId : DEFAULT_TENANT;
}
@Override public boolean validateExistingCurrentSessions() { return true; }
}
</code>Pros and Cons
Higher resource utilization than separate databases.
Good data isolation.
Lower operational complexity than separate databases.
Easy to apply tenant‑specific table structures.
Database‑level permission control.
Increased database management complexity.
Potential limit on number of schemas.
Cross‑tenant queries remain difficult.
Cannot use different database types per tenant.
All tenants share database resources, possible contention.
Applicable Scenarios
Medium‑size SaaS applications.
Scenarios with a moderate number of tenants that grow quickly.
Applications needing good isolation while being cost‑sensitive.
Environments with PostgreSQL or MySQL supporting schemas.
Solution 3: Shared Database, Shared Schema, Separate Tables
Principle and Features
All tenants share the same database and schema, but each tenant has its own set of tables distinguished by a tenant‑specific prefix or suffix.
Implementation Steps
Implement tenant‑aware table naming strategy :
<code>@Component
public class TenantTableNameStrategy extends PhysicalNamingStrategyStandardImpl {
@Override
public Identifier toPhysicalTableName(Identifier name, JdbcEnvironment context) {
String tenantId = TenantContextHolder.getTenantId();
if (tenantId != null && !tenantId.isEmpty()) {
String tablePrefix = tenantId + "_";
return new Identifier(tablePrefix + name.getText(), name.isQuoted());
}
return super.toPhysicalTableName(name, context);
}
}
</code>Configure Hibernate naming strategy :
<code>@Configuration
@EnableJpaRepositories(basePackages = "com.example.repository")
@EntityScan(basePackages = "com.example.entity")
public class JpaConfig {
@Autowired
private TenantTableNameStrategy tableNameStrategy;
@Bean
public LocalContainerEntityManagerFactoryBean entityManagerFactory(EntityManagerFactoryBuilder builder, DataSource dataSource) {
Map<String, Object> properties = new HashMap<>();
properties.put("hibernate.physical_naming_strategy", tableNameStrategy);
return builder.dataSource(dataSource).packages("com.example.entity").properties(properties).build();
}
}
</code>Tenant entity and repository :
<code>@Entity
@Table(name = "tenant_info")
public class Tenant {
@Id
private String id;
@Column(nullable = false)
private String name;
@Column
private boolean active = true;
// getters and setters
}
@Repository
public interface TenantRepository extends JpaRepository<Tenant, String> {
List<Tenant> findByActive(boolean active);
}
</code>Tenant table manager (creates and drops tables per tenant):
<code>@Component
public class TenantTableManager {
@Autowired
private EntityManagerFactory entityManagerFactory;
@Autowired
private TenantRepository tenantRepository;
@PersistenceContext
private EntityManager entityManager;
public void initializeTenantTables(String tenantId) {
String previousTenant = TenantContextHolder.getTenantId();
try {
TenantContextHolder.setTenantId(tenantId);
Session session = entityManager.unwrap(Session.class);
session.doWork(connection -> {
String sql = "CREATE TABLE IF NOT EXISTS " + tenantId + "_users (" +
"id BIGINT NOT NULL AUTO_INCREMENT, " +
"username VARCHAR(255) NOT NULL, " +
"email VARCHAR(255) NOT NULL, " +
"created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, " +
"PRIMARY KEY (id))";
try (Statement stmt = connection.createStatement()) { stmt.execute(sql); }
// create other tables as needed
});
} finally {
if (previousTenant != null) {
TenantContextHolder.setTenantId(previousTenant);
} else {
TenantContextHolder.clear();
}
}
}
public void dropTenantTables(String tenantId) {
try (Connection connection = entityManager.unwrap(SessionImplementor.class).connection()) {
DatabaseMetaData metaData = connection.getMetaData();
String tablePrefix = tenantId + "_";
try (ResultSet tables = metaData.getTables(connection.getCatalog(), connection.getSchema(), tablePrefix + "%", new String[]{"TABLE"})) {
List<String> tablesToDrop = new ArrayList<>();
while (tables.next()) { tablesToDrop.add(tables.getString("TABLE_NAME")); }
for (String tableName : tablesToDrop) {
try (Statement stmt = connection.createStatement()) { stmt.execute("DROP TABLE " + tableName); }
}
}
} catch (SQLException e) { throw new RuntimeException("Failed to drop tenant tables", e); }
}
}
</code>Tenant management service :
<code>@Service
public class TenantTableManagementService {
@Autowired
private TenantRepository tenantRepository;
@Autowired
private TenantTableManager tableManager;
@PostConstruct
public void initializeAllTenants() {
for (Tenant tenant : tenantRepository.findByActive(true)) {
tableManager.initializeTenantTables(tenant.getId());
}
}
@Transactional
public void createTenant(Tenant tenant) {
tenantRepository.save(tenant);
tableManager.initializeTenantTables(tenant.getId());
}
@Transactional
public void deleteTenant(String tenantId) {
tableManager.dropTenantTables(tenantId);
tenantRepository.deleteById(tenantId);
}
}
</code>Tenant management API :
<code>@RestController
@RequestMapping("/admin/tenants")
public class TenantTableController {
@Autowired
private TenantTableManagementService tenantService;
@Autowired
private TenantRepository tenantRepository;
@GetMapping
public List<Tenant> getAllTenants() { return tenantRepository.findAll(); }
@PostMapping
public ResponseEntity<Tenant> createTenant(@RequestBody Tenant tenant) {
tenantService.createTenant(tenant);
return ResponseEntity.status(HttpStatus.CREATED).body(tenant);
}
@DeleteMapping("/{tenantId}")
public ResponseEntity<Void> deleteTenant(@PathVariable String tenantId) {
tenantService.deleteTenant(tenantId);
return ResponseEntity.noContent().build();
}
}
</code>Pros and Cons
Simple to implement, especially for existing single‑tenant applications.
High resource utilization.
Cross‑tenant queries are relatively easy.
Low maintenance cost.
Different tenants can have different table structures.
Lower data isolation.
Table count grows rapidly with tenant count.
Database object limits may be reached.
Backup and restore of a single tenant is complex.
Potential table‑name length issues.
Applicable Scenarios
Medium‑size SaaS with moderate tenant count and simple schemas.
Applications needing different table structures per tenant.
Rapid prototyping or MVP stage.
Systems transitioning from single‑tenant to multi‑tenant.
Solution 4: Shared Database, Shared Schema, Shared Tables
Principle and Features
This is the lowest isolation level but offers the highest resource efficiency. All tenants share the same database, schema, and tables, with a tenant‑ID column distinguishing data.
Implementation Steps
Create tenant‑aware base entity :
<code>@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Data
public abstract class TenantAwareEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "tenant_id", nullable = false)
private String tenantId;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@PrePersist
public void onPrePersist() { tenantId = TenantContextHolder.getTenantId(); }
}
</code>Tenant entity and repository :
<code>@Entity
@Table(name = "tenants")
public class Tenant {
@Id
private String id;
@Column(nullable = false)
private String name;
@Column
private boolean active = true;
// getters and setters
}
@Repository
public interface TenantRepository extends JpaRepository<Tenant, String> {
List<Tenant> findByActive(boolean active);
}
</code>Tenant filter interceptor (enables Hibernate filter):
<code>@Component
public class TenantFilterInterceptor implements HandlerInterceptor {
@Autowired
private EntityManager entityManager;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String tenantId = TenantContextHolder.getTenantId();
if (tenantId != null) {
Session session = entityManager.unwrap(Session.class);
Filter filter = session.enableFilter("tenantFilter");
filter.setParameter("tenantId", tenantId);
return true;
}
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return false;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
Session session = entityManager.unwrap(Session.class);
session.disableFilter("tenantFilter");
}
}
</code>Add filter annotation to entities :
<code>@Entity
@Table(name = "users")
@FilterDef(name = "tenantFilter", parameters = {@ParamDef(name = "tenantId", type = "string")})
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class User extends TenantAwareEntity {
@Column(name = "username", nullable = false)
private String username;
@Column(name = "email", nullable = false)
private String email;
// other fields and methods
}
</code>Tenant management service :
<code>@Service
public class SharedTableTenantService {
@Autowired
private TenantRepository tenantRepository;
@Autowired
private EntityManager entityManager;
@Transactional
public void createTenant(Tenant tenant) {
tenantRepository.save(tenant);
initializeTenantData(tenant.getId());
}
@Transactional
public void deleteTenant(String tenantId) {
deleteAllTenantData(tenantId);
tenantRepository.deleteById(tenantId);
}
private void initializeTenantData(String tenantId) {
String previous = TenantContextHolder.getTenantId();
try {
TenantContextHolder.setTenantId(tenantId);
// insert default data, e.g., admin user
} finally {
if (previous != null) TenantContextHolder.setTenantId(previous); else TenantContextHolder.clear();
}
}
private void deleteAllTenantData(String tenantId) {
List<String> tables = getTablesWithTenantIdColumn();
for (String table : tables) {
entityManager.createNativeQuery("DELETE FROM " + table + " WHERE tenant_id = :tenantId")
.setParameter("tenantId", tenantId)
.executeUpdate();
}
}
private List<String> getTablesWithTenantIdColumn() {
List<String> tables = new ArrayList<>();
try (Connection conn = entityManager.unwrap(SessionImplementor.class).connection()) {
DatabaseMetaData meta = conn.getMetaData();
try (ResultSet rs = meta.getTables(conn.getCatalog(), conn.getSchema(), "%", new String[]{"TABLE"})) {
while (rs.next()) {
String tableName = rs.getString("TABLE_NAME");
try (ResultSet cols = meta.getColumns(conn.getCatalog(), conn.getSchema(), tableName, "tenant_id")) {
if (cols.next()) tables.add(tableName);
}
}
}
} catch (SQLException e) { throw new RuntimeException(e); }
return tables;
}
}
</code>Tenant management API :
<code>@RestController
@RequestMapping("/admin/tenants")
public class SharedTableTenantController {
@Autowired
private SharedTableTenantService tenantService;
@Autowired
private TenantRepository tenantRepository;
@GetMapping
public List<Tenant> getAllTenants() { return tenantRepository.findAll(); }
@PostMapping
public ResponseEntity<Tenant> createTenant(@RequestBody Tenant tenant) {
tenantService.createTenant(tenant);
return ResponseEntity.status(HttpStatus.CREATED).body(tenant);
}
@DeleteMapping("/{tenantId}")
public ResponseEntity<Void> deleteTenant(@PathVariable String tenantId) {
tenantService.deleteTenant(tenantId);
return ResponseEntity.noContent().build();
}
}
</code>Pros and Cons
Highest resource utilization.
Lowest maintenance cost.
Simple to implement and easy to adapt existing single‑tenant code.
Cross‑tenant queries are straightforward.
Saves storage space, especially for small data volumes.
Lowest data isolation; a bug can expose data across tenants.
Higher security risk.
All tenants share the same table structure.
Requires strict enforcement of tenant filters on every query.
Applicable Scenarios
Large numbers of small tenants.
Cost‑sensitive applications.
Prototype or MVP phases.
Solution 5: Hybrid Multi‑Tenant Model
Principle and Features
The hybrid model combines multiple isolation strategies, assigning different tenants to different levels (dedicated database, dedicated schema, dedicated tables, or shared tables) based on their tier, requirements, or willingness to pay.
Implementation Steps
Tenant type and storage definition :
<code>@Entity
@Table(name = "tenants")
public class Tenant {
@Id
private String id;
@Column(nullable = false)
private String name;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private TenantType type;
@Column
private String databaseUrl;
@Column
private String username;
@Column
private String password;
@Column
private String driverClassName;
@Column
private String schemaName;
@Column
private boolean active = true;
public enum TenantType { DEDICATED_DATABASE, DEDICATED_SCHEMA, DEDICATED_TABLE, SHARED_TABLE }
// getters and setters
}
@Repository
public interface TenantRepository extends JpaRepository<Tenant, String> {
List<Tenant> findByActive(boolean active);
List<Tenant> findByType(Tenant.TenantType type);
}
</code>Create tenant isolation strategy cache :
<code>@Component
public class TenantIsolationStrategy {
@Autowired
private TenantRepository tenantRepository;
private final Map<String, Tenant> tenantCache = new ConcurrentHashMap<>();
@PostConstruct
public void loadTenants() { tenantRepository.findByActive(true).forEach(t -> tenantCache.put(t.getId(), t)); }
public Tenant.TenantType getIsolationTypeForTenant(String tenantId) {
Tenant tenant = tenantCache.computeIfAbsent(tenantId, id -> tenantRepository.findById(id).orElseThrow(() -> new RuntimeException("Tenant not found: " + id)));
return tenant.getType();
}
public Tenant getTenant(String tenantId) {
return tenantCache.computeIfAbsent(tenantId, id -> tenantRepository.findById(id).orElseThrow(() -> new RuntimeException("Tenant not found: " + id)));
}
public void evictFromCache(String tenantId) { tenantCache.remove(tenantId); }
}
</code>Hybrid data‑source router (creates dedicated DataSource when needed):
<code>@Component
public class HybridTenantRouter {
@Autowired
private TenantIsolationStrategy isolationStrategy;
private final Map<String, DataSource> dedicatedDataSources = new ConcurrentHashMap<>();
@Autowired
private DataSource sharedDataSource;
public DataSource getDataSourceForTenant(String tenantId) {
Tenant.TenantType type = isolationStrategy.getIsolationTypeForTenant(tenantId);
if (type == Tenant.TenantType.DEDICATED_DATABASE) {
return dedicatedDataSources.computeIfAbsent(tenantId, this::createDedicatedDataSource);
}
return sharedDataSource;
}
private DataSource createDedicatedDataSource(String tenantId) {
Tenant tenant = isolationStrategy.getTenant(tenantId);
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(tenant.getDatabaseUrl());
ds.setUsername(tenant.getUsername());
ds.setPassword(tenant.getPassword());
ds.setDriverClassName(tenant.getDriverClassName());
return ds;
}
public void removeDedicatedDataSource(String tenantId) {
DataSource ds = dedicatedDataSources.remove(tenantId);
if (ds instanceof HikariDataSource) ((HikariDataSource) ds).close();
}
}
</code>Hybrid routing data source extending AbstractRoutingDataSource :
<code>public class HybridRoutingDataSource extends AbstractRoutingDataSource {
@Autowired
private HybridTenantRouter tenantRouter;
@Autowired
private TenantIsolationStrategy isolationStrategy;
@Override
protected Object determineCurrentLookupKey() {
String tenantId = TenantContextHolder.getTenantId();
if (tenantId == null) return "default";
Tenant.TenantType type = isolationStrategy.getIsolationTypeForTenant(tenantId);
if (type == Tenant.TenantType.DEDICATED_DATABASE) return tenantId;
return "shared";
}
@Override
protected DataSource determineTargetDataSource() {
String tenantId = TenantContextHolder.getTenantId();
if (tenantId == null) return super.determineTargetDataSource();
return tenantRouter.getDataSourceForTenant(tenantId);
}
}
</code>Hybrid tenant interceptor (applies different strategies per tenant type):
<code>@Component
public class HybridTenantInterceptor implements HandlerInterceptor {
@Autowired
private TenantIsolationStrategy isolationStrategy;
@Autowired
private EntityManager entityManager;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String tenantId = extractTenantId(request);
if (tenantId != null) {
TenantContextHolder.setTenantId(tenantId);
Tenant.TenantType type = isolationStrategy.getIsolationTypeForTenant(tenantId);
switch (type) {
case DEDICATED_DATABASE:
// routing datasource handles it
break;
case DEDICATED_SCHEMA:
setSchema(isolationStrategy.getTenant(tenantId).getSchemaName());
break;
case DEDICATED_TABLE:
// naming strategy already handles table prefix
break;
case SHARED_TABLE:
enableTenantFilter(tenantId);
break;
}
return true;
}
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return false;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
String tenantId = TenantContextHolder.getTenantId();
if (tenantId != null && isolationStrategy.getIsolationTypeForTenant(tenantId) == Tenant.TenantType.SHARED_TABLE) {
disableTenantFilter();
}
TenantContextHolder.clear();
}
private void setSchema(String schema) {
try { entityManager.createNativeQuery("SET SCHEMA '" + schema + "'").executeUpdate(); }
catch (Exception ignored) {}
}
private void enableTenantFilter(String tenantId) {
Session session = entityManager.unwrap(Session.class);
Filter filter = session.enableFilter("tenantFilter");
filter.setParameter("tenantId", tenantId);
}
private void disableTenantFilter() {
Session session = entityManager.unwrap(Session.class);
session.disableFilter("tenantFilter");
}
private String extractTenantId(HttpServletRequest request) { return request.getHeader("X-TenantID"); }
}
</code>Comprehensive tenant management service (initializes resources based on tenant type):
<code>@Service
public class HybridTenantManagementService {
@Autowired
private TenantRepository tenantRepository;
@Autowired
private TenantIsolationStrategy isolationStrategy;
@Autowired
private HybridTenantRouter tenantRouter;
@Autowired
private EntityManager entityManager;
@Autowired
private DataSource dataSource;
private final Map<Tenant.TenantType, TenantInitializer> initializers = new HashMap<>();
@PostConstruct
public void init() {
initializers.put(Tenant.TenantType.DEDICATED_DATABASE, this::initializeDedicatedDatabase);
initializers.put(Tenant.TenantType.DEDICATED_SCHEMA, this::initializeDedicatedSchema);
initializers.put(Tenant.TenantType.DEDICATED_TABLE, this::initializeDedicatedTables);
initializers.put(Tenant.TenantType.SHARED_TABLE, this::initializeSharedTables);
}
@Transactional
public void createTenant(Tenant tenant) {
tenantRepository.save(tenant);
TenantInitializer init = initializers.get(tenant.getType());
if (init != null) init.initialize(tenant);
isolationStrategy.evictFromCache(tenant.getId());
}
@Transactional
public void deleteTenant(String tenantId) {
Tenant tenant = tenantRepository.findById(tenantId).orElseThrow(() -> new RuntimeException("Tenant not found: " + tenantId));
switch (tenant.getType()) {
case DEDICATED_DATABASE: cleanupDedicatedDatabase(tenant); break;
case DEDICATED_SCHEMA: cleanupDedicatedSchema(tenant); break;
case DEDICATED_TABLE: cleanupDedicatedTables(tenant); break;
case SHARED_TABLE: cleanupSharedTables(tenant); break;
}
tenantRepository.delete(tenant);
isolationStrategy.evictFromCache(tenantId);
}
// ----- Initializers -----
private void initializeDedicatedDatabase(Tenant tenant) {
DataSource ds = tenantRouter.getDataSourceForTenant(tenant.getId());
try (Connection conn = ds.getConnection()) {
// execute DDL scripts for the new database
} catch (SQLException e) { throw new RuntimeException(e); }
}
private void initializeDedicatedSchema(Tenant tenant) {
try (Connection conn = dataSource.getConnection(); Statement stmt = conn.createStatement()) {
stmt.execute("CREATE SCHEMA IF NOT EXISTS " + tenant.getSchemaName());
conn.setSchema(tenant.getSchemaName());
// create tables
} catch (SQLException e) { throw new RuntimeException(e); }
}
private void initializeDedicatedTables(Tenant tenant) {
String previous = TenantContextHolder.getTenantId();
try {
TenantContextHolder.setTenantId(tenant.getId());
// create tables with tenant prefix
} finally {
if (previous != null) TenantContextHolder.setTenantId(previous); else TenantContextHolder.clear();
}
}
private void initializeSharedTables(Tenant tenant) {
String previous = TenantContextHolder.getTenantId();
try {
TenantContextHolder.setTenantId(tenant.getId());
// insert default data for shared tables
} finally {
if (previous != null) TenantContextHolder.setTenantId(previous); else TenantContextHolder.clear();
}
}
// ----- Clean‑up -----
private void cleanupDedicatedDatabase(Tenant tenant) { tenantRouter.removeDedicatedDataSource(tenant.getId()); }
private void cleanupDedicatedSchema(Tenant tenant) {
try (Connection conn = dataSource.getConnection(); Statement stmt = conn.createStatement()) {
stmt.execute("DROP SCHEMA IF EXISTS " + tenant.getSchemaName() + " CASCADE");
} catch (SQLException e) { throw new RuntimeException(e); }
}
private void cleanupDedicatedTables(Tenant tenant) {
try (Connection conn = dataSource.getConnection()) {
DatabaseMetaData meta = conn.getMetaData();
String prefix = tenant.getId() + "_";
try (ResultSet rs = meta.getTables(conn.getCatalog(), conn.getSchema(), prefix + "%", new String[]{"TABLE"})) {
while (rs.next()) {
String table = rs.getString("TABLE_NAME");
try (Statement stmt = conn.createStatement()) { stmt.execute("DROP TABLE " + table); }
}
}
} catch (SQLException e) { throw new RuntimeException(e); }
}
private void cleanupSharedTables(Tenant tenant) {
entityManager.createNativeQuery(
"SELECT table_name FROM information_schema.columns WHERE column_name = 'tenant_id'")
.getResultList()
.forEach(tn -> entityManager.createNativeQuery("DELETE FROM " + tn + " WHERE tenant_id = :tid")
.setParameter("tid", tenant.getId())
.executeUpdate());
}
@FunctionalInterface
private interface TenantInitializer { void initialize(Tenant tenant); }
}
</code>Hybrid tenant management API :
<code>@RestController
@RequestMapping("/admin/tenants")
public class HybridTenantController {
@Autowired
private HybridTenantManagementService tenantService;
@Autowired
private TenantRepository tenantRepository;
@GetMapping
public List<Tenant> getAllTenants() { return tenantRepository.findAll(); }
@PostMapping
public ResponseEntity<Tenant> createTenant(@RequestBody Tenant tenant) {
tenantService.createTenant(tenant);
return ResponseEntity.status(HttpStatus.CREATED).body(tenant);
}
@PutMapping("/{tenantId}")
public ResponseEntity<Tenant> updateTenant(@PathVariable String tenantId, @RequestBody Tenant tenant) {
tenant.setId(tenantId);
tenantService.createTenant(tenant); // for simplicity, reuse create logic
return ResponseEntity.ok(tenant);
}
@DeleteMapping("/{tenantId}")
public ResponseEntity<Void> deleteTenant(@PathVariable String tenantId) {
tenantService.deleteTenant(tenantId);
return ResponseEntity.noContent().build();
}
@GetMapping("/types")
public ResponseEntity<List<Tenant.TenantType>> getTenantTypes() {
return ResponseEntity.ok(Arrays.asList(Tenant.TenantType.values()));
}
}
</code>Pros and Cons
Maximum flexibility – can assign isolation level per tenant.
Enables resource‑cost balancing.
Supports tiered pricing and different security/performance needs.
Highest implementation complexity.
Increased maintenance and testing effort.
Requires handling multiple data‑access patterns.
Potentially inconsistent user experience across tiers.
Error handling becomes more intricate.
Applicable Scenarios
Applications offering flexible pricing models.
Environments with tenants having vastly different resource or security requirements.
Comparison of Multi‑Tenant Strategies
Isolation Mode | Data Isolation Level | Resource Utilization | Cost | Complexity | Suitable Scenarios
-----------------------------------------------------------------------------------------------------------------------------------
Separate Database | Highest | Low | High | Medium | Enterprise SaaS, finance/healthcare
Separate Schema | High | Medium | Medium | Medium | Mid‑size SaaS, high security needs
Separate Tables | Medium | Medium‑High | Medium‑Low | Low | Small‑to‑mid apps, rapid prototyping
Shared Tables | Low | Highest | Low | Low | Large number of small tenants, cost‑sensitive
Hybrid Mode | Variable | Variable | Variable | High | Multi‑tier services, complex business needs
-----------------------------------------------------------------------------------------------------------------------------------Conclusion
Multi‑tenant architecture is a key technology for building modern SaaS applications. Selecting the appropriate multi‑tenant pattern requires balancing data isolation, resource utilization, cost, and complexity. By thoroughly understanding these patterns and their trade‑offs, architects can choose the most suitable design to build scalable, secure, and cost‑effective enterprise applications.
Code Ape Tech Column
Former Ant Group P8 engineer, pure technologist, sharing full‑stack Java, job interview and career advice through a column. Site: java-family.cn
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.