Designing a Non‑Intrusive Spring Cloud SaaS Multi‑Tenant Component for Full‑Stack Data Isolation
The article presents a step‑by‑step, code‑driven design of a Spring Cloud SaaS multi‑tenant solution that balances resource sharing and strict data isolation by using a shared‑database, shared‑schema approach with tenant_id filtering, ThreadLocal context, MyBatis‑Plus interceptors, Redis key prefixing, Sa‑Token session segregation, and Spring Boot auto‑configuration.
Introduction
SaaS platforms must serve many tenants while keeping each tenant's data isolated; a breach could expose one company's financial data to another. The article explores a non‑intrusive, reusable solution built as the common-tenant-starter module.
Core Challenge
Three common isolation strategies are compared:
Independent databases (physical isolation) – highest security but highest cost.
Shared database with separate schemas – good balance of security and cost.
Shared database with shared schema and a tenant_id column – maximum resource utilization, but requires careful application‑level filtering.
The article adopts the third approach, which is the mainstream choice for most SaaS products.
Design Trade‑offs
Isolation level: moderate (application‑layer).
Data security: depends on correct tenant_id filtering.
Development cost: high because every SQL must be filtered.
Maintenance cost: low once the automation is in place.
Resource cost: low, as all tenants share the same tables.
Scalability: very good.
Implementation Steps
Step 1 – Add Dependency
<dependency>
<groupId>org.dromara</groupId>
<artifactId>common-tenant-starter</artifactId>
</dependency>Step 2 – Enable Tenant Feature
tenant:
enable: true
excludes:
- not_tenant_table
- not_tenant_columnStep 3 – Add tenant_id Column
ALTER TABLE your_business_table ADD COLUMN tenant_id VARCHAR(20) NOT NULL COMMENT '租户编号';TenantHelper and ThreadLocal
The TenantHelper class stores the current tenant ID in a ThreadLocal variable, guaranteeing that any code executed in the same request can retrieve the correct tenant context without interference from other threads.
public class TenantHelper {
private static final ThreadLocal<String> DYNAMIC_TENANT_ID = new ThreadLocal<>();
public static String getTenantId() {
if (!isEnable()) return null;
String tenantId = getDynamic();
if (StringUtils.isBlank(tenantId)) {
tenantId = LoginHelper.getTenantId();
}
return tenantId;
}
public static String getDynamic() { return DYNAMIC_TENANT_ID.get(); }
public static void setDynamic(String tenantId) { DYNAMIC_TENANT_ID.set(tenantId); }
public static void clearDynamic() { DYNAMIC_TENANT_ID.remove(); }
public static boolean isEnable() { return RuoYiConfig.getTenantEnable(); }
}MyBatis‑Plus Interceptor
The TenantLineInnerInterceptor intercepts SQL before execution, obtains the tenant ID from TenantHelper, and appends AND tenant_id = '...' to the WHERE clause when the table is not excluded.
Workflow:
Application calls a MyBatis mapper.
Interceptor runs before the SQL is sent.
It fetches the tenant ID via TenantHelper.getTenantId().
If the tenant ID exists and the table is not in the exclusion list, the condition AND tenant_id = '...' is added.
The modified SQL is executed.
Example:
SELECT * FROM sys_user WHERE tenant_id = '001';Redis Key Namespace
To prevent cross‑tenant cache leakage, the module registers a TenantKeyPrefixHandler via RedissonAutoConfigurationCustomizer. The handler prefixes every Redis key with the current tenant_id (e.g., tenant-a:user:1).
RedisUtils.setCacheObject("user:1", userInfo); // stores as tenant-a:user:1 when tenant A is activeSa‑Token Session Isolation
A custom TenantSaTokenDao extends SaTokenDaoOfRedis and overrides getKey to prepend the tenant ID to all Sa‑Token keys, mirroring the Redis key‑prefix strategy.
public class TenantSaTokenDao extends SaTokenDaoOfRedis {
@Override
public String getKey(String key) {
String tenantId = TenantHelper.getTenantId();
if (StringUtils.isNotBlank(tenantId)) {
return tenantId + ":" + super.getKey(key);
}
return super.getKey(key);
}
}Spring Boot Auto‑Configuration
The TenantConfiguration class wires all components together. It is activated only when tenant.enable=true via @ConditionalOnProperty, automatically registering the MyBatis‑Plus interceptor, the Redisson customizer, and the primary TenantSaTokenDao bean.
@EnableConfigurationProperties(TenantProperties.class)
@AutoConfiguration
@ConditionalOnProperty(value = "tenant.enable", havingValue = "true")
public class TenantConfiguration {
@Bean
public TenantLineInnerInterceptor tenantLineInnerInterceptor(TenantProperties props) {
return new TenantLineInnerInterceptor(new PlusTenantLineHandler(props));
}
@Bean
public RedissonAutoConfigurationCustomizer tenantRedissonCustomizer(...) { /* ... */ }
@Primary
@Bean
public SaTokenDao tenantSaTokenDao() { return new TenantSaTokenDao(); }
}Conclusion
By combining ThreadLocal tenant context, MyBatis‑Plus SQL interception, Redis key prefixing, Sa‑Token session segregation, and conditional Spring Boot auto‑configuration, the common-tenant-starter delivers a complete, non‑intrusive multi‑tenant data isolation framework that requires no changes to existing business code.
Tech Freedom Circle
Crazy Maker Circle (Tech Freedom Architecture Circle): a community of tech enthusiasts, experts, and high‑performance fans. Many top‑level masters, architects, and hobbyists have achieved tech freedom; another wave of go‑getters are hustling hard toward tech freedom.
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.
