Skip to main content

Multi-Tenancy Patterns

Architectural patterns and best practices for building secure, scalable multi-tenant applications.

Overview

Multi-tenancy is an architectural pattern where a single instance of an application serves multiple customers (tenants). Each tenant's data is isolated and invisible to other tenants, even though they share the same infrastructure, application code, and potentially the same database. This approach maximizes resource utilization and reduces operational costs compared to deploying separate instances per customer.

Multi-tenancy is fundamental to modern SaaS applications, enabling efficient scaling while maintaining data isolation and security. However, it introduces complexity in data partitioning, tenant identification, security boundaries, and performance isolation.


Core Principles

  • Tenant Isolation: Ensure complete data and security isolation between tenants at all levels
  • Performance Isolation: Prevent one tenant's workload from impacting others (noisy neighbor problem)
  • Cost Efficiency: Share infrastructure resources to reduce per-tenant costs
  • Tenant-Specific Customization: Support tenant-specific configuration without code changes
  • Scalability: Design for horizontal scaling as tenant count grows
  • Security Boundaries: Treat tenant boundaries as security boundaries with defense in depth
  • Operational Simplicity: Balance multi-tenancy complexity with operational manageability

Multi-Tenancy Models

The choice of multi-tenancy model has profound implications for isolation, scalability, cost, and operational complexity. Each model represents different trade-offs between shared infrastructure (cost efficiency) and isolation (security, performance, customization).

1. Shared Database, Shared Schema

All tenants share the same database and schema. Every table includes a tenant_id column to partition data. This is the most cost-efficient model but requires careful implementation to prevent data leakage.

Architecture:

Example Schema:

-- Every table includes tenant_id as part of primary key
CREATE TABLE users (
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
email VARCHAR(255) NOT NULL,
name VARCHAR(200) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,

PRIMARY KEY (tenant_id, user_id),

-- Ensure email is unique within tenant
CONSTRAINT uk_tenant_email UNIQUE (tenant_id, email)
);

CREATE TABLE orders (
tenant_id UUID NOT NULL,
order_id UUID NOT NULL,
user_id UUID NOT NULL,
total_amount DECIMAL(19,4) NOT NULL,
status VARCHAR(20) NOT NULL,

PRIMARY KEY (tenant_id, order_id),

-- Foreign key includes tenant_id to maintain isolation
CONSTRAINT fk_user FOREIGN KEY (tenant_id, user_id)
REFERENCES users(tenant_id, user_id)
);

-- Critical: Index on tenant_id for query performance
CREATE INDEX idx_users_tenant ON users(tenant_id);
CREATE INDEX idx_orders_tenant ON orders(tenant_id);

Row-Level Security (RLS) for Additional Protection:

PostgreSQL's Row-Level Security provides database-enforced tenant isolation, creating a safety net if application code fails to filter properly:

-- Enable RLS on table
ALTER TABLE users ENABLE ROW LEVEL SECURITY;

-- Create policy that filters based on current tenant context
CREATE POLICY tenant_isolation_policy ON users
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- Set tenant context in application
-- Application must set this at the start of each request
SET LOCAL app.current_tenant_id = 'tenant-uuid-here';

RLS is enforced at the database level, meaning even a buggy SQL query without a WHERE clause will only return rows for the current tenant. This defense-in-depth approach prevents catastrophic data leakage bugs. For implementation details with Spring Boot, see Spring Boot Data Access.

Advantages:

  • [GOOD] Maximum resource sharing: Lowest cost per tenant
  • [GOOD] Operational simplicity: Single database to manage, backup, and monitor
  • [GOOD] Efficient for large tenant count: Supports thousands of small tenants
  • [GOOD] Easy cross-tenant analytics: Can query across all tenants for platform-wide insights

Disadvantages:

  • [BAD] Complex data isolation: Must filter every query by tenant_id
  • [BAD] Data leakage risk: Bugs can expose tenant A's data to tenant B
  • [BAD] Limited customization: Difficult to customize schema per tenant
  • [BAD] Performance coupling: One tenant's large queries affect others (noisy neighbor)
  • [BAD] Compliance challenges: Some regulations require physical data separation

When to Use:

  • SaaS applications with many small to medium tenants
  • Low per-tenant customization requirements
  • Cost efficiency is primary concern
  • Strong engineering discipline to prevent data leakage

2. Shared Database, Schema Per Tenant

Each tenant has a dedicated schema within the same database. PostgreSQL schemas provide logical separation while sharing the physical database.

Architecture:

Implementation:

-- Create schema for each tenant
CREATE SCHEMA tenant_alpha;
CREATE SCHEMA tenant_beta;

-- Each schema has identical structure
CREATE TABLE tenant_alpha.users (
user_id UUID PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL
);

CREATE TABLE tenant_beta.users (
user_id UUID PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL
);

-- Application sets schema path based on tenant
SET search_path TO tenant_alpha, public;
-- Now all queries automatically use tenant_alpha schema
SELECT * FROM users; -- Queries tenant_alpha.users

Spring Boot Configuration:

// Set schema dynamically per request
@Component
public class TenantSchemaInterceptor implements HandlerInterceptor {

@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
String tenantId = extractTenantId(request);
TenantContext.setCurrentTenant(tenantId);
return true;
}

private String extractTenantId(HttpServletRequest request) {
// Extract from subdomain: alpha.example.com -> tenant_alpha
// Or from header: X-Tenant-ID
// Or from JWT claim
return request.getServerName().split("\\.")[0];
}
}

// JPA configuration to use tenant schema
@Configuration
public class MultiTenantJpaConfig {

@Bean
public LocalContainerEntityManagerFactoryBean entityManagerFactory(
DataSource dataSource) {
LocalContainerEntityManagerFactoryBean em =
new LocalContainerEntityManagerFactoryBean();
em.setDataSource(dataSource);

// Hibernate multi-tenancy strategy
Map<String, Object> properties = new HashMap<>();
properties.put("hibernate.multiTenancy", "SCHEMA");
properties.put("hibernate.tenant_identifier_resolver",
tenantIdentifierResolver());
properties.put("hibernate.multi_tenant_connection_provider",
multiTenantConnectionProvider());
em.setJpaPropertyMap(properties);

return em;
}
}

Advantages:

  • Good isolation: Logical separation of data per tenant
  • No tenant_id filtering: Queries don't need WHERE tenant_id clauses
  • [GOOD] Tenant-specific customization: Can modify schema for individual tenants
  • [GOOD] Easier backup/restore: Can backup single tenant schema
  • [GOOD] Better than shared schema: Reduced data leakage risk

Disadvantages:

  • [BAD] Connection management complexity: Must set schema path per request
  • [BAD] Limited scalability: Database limits on number of schemas
  • [BAD] Schema migration complexity: Must run migrations across all tenant schemas
  • [BAD] Cross-tenant queries difficult: Joining data across schemas is complex

When to Use:

  • Moderate number of tenants (hundreds, not thousands)
  • Tenants require schema customization
  • Need strong isolation without separate databases
  • Per-tenant backup/restore is important

3. Database Per Tenant

Each tenant has a dedicated database. This provides the strongest isolation within a shared infrastructure model.

Architecture:

Implementation:

// Spring Boot multi-datasource configuration
@Configuration
public class MultiTenantDataSourceConfig {

@Bean
public DataSource dataSource() {
return new TenantRoutingDataSource();
}
}

public class TenantRoutingDataSource extends AbstractRoutingDataSource {

@Override
protected Object determineCurrentLookupKey() {
// Return current tenant's database identifier
return TenantContext.getCurrentTenant();
}
}

@Configuration
public class TenantDataSourceProvider {

// Tenant registry with connection details
private final Map<String, DataSource> tenantDataSources = new ConcurrentHashMap<>();

public void registerTenant(String tenantId, String jdbcUrl) {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(jdbcUrl);
config.setUsername("app_user");
config.setPassword(getPassword(tenantId));
config.setMaximumPoolSize(10);

HikariDataSource dataSource = new HikariDataSource(config);
tenantDataSources.put(tenantId, dataSource);
}

public DataSource getDataSource(String tenantId) {
return tenantDataSources.get(tenantId);
}
}

Tenant Onboarding:

@Service
public class TenantProvisioningService {

private final JdbcTemplate adminJdbcTemplate;
private final TenantDataSourceProvider dataSourceProvider;

public void provisionNewTenant(String tenantId, String tenantName) {
// Create new database
String dbName = "tenant_" + tenantId;
adminJdbcTemplate.execute("CREATE DATABASE " + dbName);

// Run migrations on new database
String jdbcUrl = "jdbc:postgresql://localhost:5432/" + dbName;
Flyway flyway = Flyway.configure()
.dataSource(jdbcUrl, "app_user", "password")
.locations("classpath:db/migration")
.load();
flyway.migrate();

// Register tenant in routing
dataSourceProvider.registerTenant(tenantId, jdbcUrl);

// Store tenant metadata
saveTenantMetadata(tenantId, tenantName, jdbcUrl);
}
}

Advantages:

  • [GOOD] Strong isolation: Complete database-level separation
  • [GOOD] Independent backups: Easy to backup, restore, or migrate single tenant
  • [GOOD] Tenant-specific tuning: Can optimize database parameters per tenant
  • [GOOD] Compliance friendly: Physical data separation meets regulatory requirements
  • No noisy neighbor: One tenant's queries don't affect others
  • [GOOD] Easy to move tenants: Can relocate tenant to different database server

Disadvantages:

  • [BAD] Higher cost: More database instances means higher licensing and infrastructure costs
  • [BAD] Operational overhead: Managing hundreds of databases is complex
  • [BAD] Connection pool management: Each tenant needs a connection pool
  • [BAD] Cross-tenant analytics: Extremely difficult to query across all tenants
  • [BAD] Schema migrations: Must run migrations against all databases

When to Use:

  • Enterprise customers with strong isolation requirements
  • Compliance requirements mandate physical data separation
  • Small to medium number of large tenants
  • Per-tenant SLAs or performance guarantees
  • Tenants require custom database configuration

4. Hybrid Approaches

Real-world systems often combine multiple models based on tenant tiers or requirements:

Example Hybrid Strategy:

  • Enterprise tier: Database per tenant (highest isolation, dedicated resources)
  • Professional tier: Schema per tenant in shared database (good isolation, cost-effective)
  • Starter tier: Shared database and schema with row-level filtering (maximum density)

This approach balances cost efficiency for small tenants with isolation and performance guarantees for premium customers.


Tenant Identification and Routing

Every request must be associated with a tenant before accessing data. The tenant identification strategy must be consistent, secure, and performant.

Tenant Identification Strategies

1. Subdomain-Based Identification

Each tenant accesses the application via a unique subdomain:

alpha.example.com    -> Tenant: alpha
beta.example.com -> Tenant: beta
gamma.example.com -> Tenant: gamma

Implementation:

public String extractTenantFromSubdomain(HttpServletRequest request) {
String host = request.getServerName();
// Extract first segment: "alpha.example.com" -> "alpha"
String subdomain = host.split("\\.")[0];

// Validate subdomain maps to valid tenant
if (!tenantRegistry.exists(subdomain)) {
throw new TenantNotFoundException("Unknown tenant: " + subdomain);
}

return subdomain;
}

Advantages: User-friendly, visible in URL, easy to cache per domain Disadvantages: Requires wildcard SSL certificates, DNS management overhead

2. Header-Based Identification

Tenant ID passed in custom HTTP header:

GET /api/orders HTTP/1.1
Host: api.example.com
X-Tenant-ID: alpha
Authorization: Bearer jwt-token

Implementation:

public String extractTenantFromHeader(HttpServletRequest request) {
String tenantId = request.getHeader("X-Tenant-ID");

if (tenantId == null || tenantId.isEmpty()) {
throw new MissingTenantException("X-Tenant-ID header required");
}

// Security: Verify JWT token belongs to this tenant
String jwtTenantId = extractTenantFromJWT(request);
if (!tenantId.equals(jwtTenantId)) {
throw new TenantMismatchException(
"X-Tenant-ID does not match authenticated user's tenant"
);
}

return tenantId;
}

Advantages: Simple, no DNS changes, works with mobile apps Disadvantages: Clients must remember to send header, potential for mistakes

3. JWT Claim-Based Identification

Tenant embedded in authentication token:

{
"sub": "user-123",
"email": "[email protected]",
"tenant_id": "alpha",
"roles": ["USER"],
"exp": 1735689600
}

Implementation:

public String extractTenantFromJWT(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
String token = authHeader.substring(7); // Remove "Bearer " prefix

DecodedJWT jwt = JWT.decode(token);
String tenantId = jwt.getClaim("tenant_id").asString();

if (tenantId == null) {
throw new MissingTenantException("JWT missing tenant_id claim");
}

return tenantId;
}

Advantages: Secure, cannot be spoofed, no separate tenant identifier needed Disadvantages: Token must be obtained first, refresh required if tenant changes

Tenant Context Management

Once identified, the tenant context must be available throughout the request lifecycle:

// Thread-local storage for tenant context
public class TenantContext {

private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();

public static void setCurrentTenant(String tenantId) {
if (tenantId == null) {
throw new IllegalArgumentException("Tenant ID cannot be null");
}
CURRENT_TENANT.set(tenantId);
}

public static String getCurrentTenant() {
String tenantId = CURRENT_TENANT.get();
if (tenantId == null) {
throw new IllegalStateException("No tenant context set");
}
return tenantId;
}

public static void clear() {
CURRENT_TENANT.remove();
}
}

// Interceptor to set tenant context
@Component
public class TenantInterceptor implements HandlerInterceptor {

private final TenantResolver tenantResolver;

@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
String tenantId = tenantResolver.resolve(request);
TenantContext.setCurrentTenant(tenantId);
return true;
}

@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
// Critical: Always clear context to prevent leakage to next request
TenantContext.clear();
}
}

Security Critical: Always clear tenant context after request completes. Thread pools reuse threads, so leftover context from one request could leak into the next request, causing catastrophic data leakage. For Spring Boot integration, see Spring Boot Security.


Security Boundaries and Isolation

Tenant boundaries are security boundaries. A bug that exposes tenant A's data to tenant B is a critical security vulnerability. Defense in depth is essential.

Defense in Depth Strategies

1. Application-Level Filtering

Every query must filter by tenant_id:

// BAD: No tenant filtering - exposes all tenants' data!
@Query("SELECT o FROM Order o WHERE o.status = :status")
List<Order> findByStatus(@Param("status") String status);

// GOOD: Always includes tenant_id filter
@Query("SELECT o FROM Order o WHERE o.tenantId = :tenantId AND o.status = :status")
List<Order> findByStatus(@Param("tenantId") String tenantId,
@Param("status") String status);

// GOOD: BETTER: Use Spring Data query method conventions
public interface OrderRepository extends JpaRepository<Order, UUID> {
List<Order> findByTenantIdAndStatus(String tenantId, String status);
}

2. Entity-Level Tenant Filtering (Hibernate)

Use Hibernate filters to automatically add tenant_id to all queries:

@Entity
@Table(name = "orders")
@FilterDef(name = "tenantFilter", parameters = @ParamDef(name = "tenantId", type = "string"))
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class Order {

@Id
private UUID id;

@Column(name = "tenant_id", nullable = false, updatable = false)
private String tenantId;

// Other fields...
}

// Enable filter for all queries in this session
@Component
@Aspect
public class TenantFilterAspect {

@PersistenceContext
private EntityManager entityManager;

@Before("@annotation(org.springframework.web.bind.annotation.RequestMapping)")
public void enableTenantFilter() {
Session session = entityManager.unwrap(Session.class);
session.enableFilter("tenantFilter")
.setParameter("tenantId", TenantContext.getCurrentTenant());
}
}

Now all queries automatically include WHERE tenant_id = ? even if the developer forgets to add it explicitly. This significantly reduces the risk of data leakage bugs.

3. Database Row-Level Security

PostgreSQL RLS provides a final safety net (shown earlier in Shared Database model). Even if application code is buggy, the database enforces tenant isolation.

4. Authorization Checks

@Service
public class OrderService {

public Order getOrder(UUID orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new NotFoundException("Order not found"));

// Critical: Verify order belongs to current tenant
String currentTenant = TenantContext.getCurrentTenant();
if (!order.getTenantId().equals(currentTenant)) {
// Prevent tenant B from accessing tenant A's data via direct ID
throw new ForbiddenException("Access denied to this order");
}

return order;
}
}

This prevents "Insecure Direct Object Reference" (IDOR) attacks where a malicious user guesses order IDs from another tenant. For more on IDOR prevention, see Authorization.

5. Audit Logging

@Aspect
@Component
public class TenantAccessAuditAspect {

private final AuditLogRepository auditLogRepository;

@Around("@annotation(Audited)")
public Object auditAccess(ProceedingJoinPoint joinPoint) throws Throwable {
String tenantId = TenantContext.getCurrentTenant();
String methodName = joinPoint.getSignature().getName();
Object[] args = joinPoint.getArgs();

// Log access attempt
AuditLog log = new AuditLog();
log.setTenantId(tenantId);
log.setAction(methodName);
log.setTimestamp(Instant.now());
auditLogRepository.save(log);

return joinPoint.proceed();
}
}

Audit logs enable detection of suspicious cross-tenant access patterns and provide evidence for compliance audits. See Observability for comprehensive logging strategies.


Performance Considerations

Noisy Neighbor Problem

In shared infrastructure, one tenant's heavy workload can degrade performance for all other tenants. This is the "noisy neighbor" problem.

Mitigation Strategies

1. Per-Tenant Connection Pools

@Configuration
public class TenantConnectionPoolConfig {

public DataSource createTenantDataSource(String tenantId) {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(getTenantJdbcUrl(tenantId));
config.setMaximumPoolSize(10); // Limit per tenant
config.setMinimumIdle(2);
config.setConnectionTimeout(5000);
config.setIdleTimeout(300000);

return new HikariDataSource(config);
}
}

Each tenant gets a fixed pool size, preventing any single tenant from monopolizing database connections.

2. Rate Limiting Per Tenant

@Component
public class TenantRateLimiter {

// Separate rate limiter per tenant
private final Map<String, RateLimiter> limiters = new ConcurrentHashMap<>();

public boolean allowRequest(String tenantId) {
RateLimiter limiter = limiters.computeIfAbsent(
tenantId,
id -> RateLimiter.create(100.0) // 100 requests/second per tenant
);

return limiter.tryAcquire();
}
}

For comprehensive rate limiting patterns, see Rate Limiting.

3. Query Timeout Limits

@Transactional(timeout = 10) // 10-second timeout
public List<Order> searchOrders(SearchCriteria criteria) {
return orderRepository.search(criteria);
}

Prevents runaway queries from consuming resources indefinitely.

4. Resource Quotas

public class TenantResourceQuota {
private final long maxStorageBytes;
private final long maxApiCallsPerDay;
private final int maxConcurrentRequests;

public void enforceQuota(String tenantId) {
long currentStorage = storageService.getTenantStorageUsage(tenantId);
if (currentStorage > maxStorageBytes) {
throw new QuotaExceededException(
"Storage quota exceeded: " + currentStorage + " / " + maxStorageBytes
);
}
}
}

Tenant-Specific Performance Tuning

Caching Per Tenant:

@Service
public class TenantCacheService {

private final Cache<String, Object> cache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(Duration.ofMinutes(10))
.build();

public <T> T getOrCompute(String key, Supplier<T> supplier) {
String tenantId = TenantContext.getCurrentTenant();
String tenantKey = tenantId + ":" + key;

return (T) cache.get(tenantKey, k -> supplier.get());
}
}

Always include tenant_id in cache keys to prevent cache poisoning where tenant A's cached data is returned to tenant B. See Caching for comprehensive caching strategies.


Tenant-Specific Configuration

Feature Flags Per Tenant

@Service
public class TenantFeatureFlagService {

private final TenantConfigRepository configRepository;

public boolean isFeatureEnabled(String featureName) {
String tenantId = TenantContext.getCurrentTenant();
TenantConfig config = configRepository.findByTenantId(tenantId);

return config.getFeatureFlags()
.getOrDefault(featureName, false);
}
}

// Usage
@GetMapping("/api/advanced-analytics")
public AnalyticsData getAdvancedAnalytics() {
if (!featureFlagService.isFeatureEnabled("advanced_analytics")) {
throw new FeatureNotAvailableException(
"Advanced analytics not available for your plan"
);
}
return analyticsService.getAdvancedAnalytics();
}

For comprehensive feature flag patterns, see Feature Flags.

Tenant-Specific Branding

@Entity
@Table(name = "tenant_config")
public class TenantConfig {

@Id
private String tenantId;

private String companyName;
private String logoUrl;
private String primaryColor;
private String customDomain;

@Column(columnDefinition = "jsonb")
private Map<String, Object> customSettings;
}

@RestController
public class TenantConfigController {

@GetMapping("/api/tenant/config")
public TenantConfig getCurrentTenantConfig() {
String tenantId = TenantContext.getCurrentTenant();
return configRepository.findByTenantId(tenantId);
}
}

Frontend applications fetch tenant-specific configuration to customize branding, theme colors, and feature availability.


Tenant Lifecycle Management

Onboarding New Tenants

Provisioning Implementation:

@Service
public class TenantProvisioningService {

@Transactional
public Tenant provisionTenant(TenantRequest request) {
// Generate tenant ID
String tenantId = UUID.randomUUID().toString();

// Create tenant record
Tenant tenant = new Tenant();
tenant.setId(tenantId);
tenant.setName(request.getName());
tenant.setPlan(request.getPlan());
tenant.setStatus(TenantStatus.PROVISIONING);
tenant.setCreatedAt(Instant.now());
tenantRepository.save(tenant);

try {
// Provision infrastructure based on model
switch (multiTenancyModel) {
case DATABASE_PER_TENANT:
provisionDatabase(tenantId);
break;
case SCHEMA_PER_TENANT:
provisionSchema(tenantId);
break;
case SHARED_SCHEMA:
// No infrastructure provisioning needed
break;
}

// Create default data
seedTenantData(tenantId);

// Update status
tenant.setStatus(TenantStatus.ACTIVE);
tenantRepository.save(tenant);

return tenant;
} catch (Exception e) {
tenant.setStatus(TenantStatus.FAILED);
tenantRepository.save(tenant);
throw new TenantProvisioningException("Failed to provision tenant", e);
}
}
}

Offboarding Tenants

@Service
public class TenantOffboardingService {

@Transactional
public void offboardTenant(String tenantId) {
Tenant tenant = tenantRepository.findById(tenantId)
.orElseThrow(() -> new NotFoundException("Tenant not found"));

// Mark as deactivated (soft delete)
tenant.setStatus(TenantStatus.DEACTIVATED);
tenant.setDeactivatedAt(Instant.now());
tenantRepository.save(tenant);

// Schedule data deletion after retention period
scheduleDeletion(tenantId, Duration.ofDays(90));
}

@Async
public void permanentlyDeleteTenant(String tenantId) {
// Export audit logs before deletion
auditService.exportTenantAuditLogs(tenantId);

// Delete tenant data based on model
switch (multiTenancyModel) {
case DATABASE_PER_TENANT:
jdbcTemplate.execute("DROP DATABASE tenant_" + tenantId);
break;
case SCHEMA_PER_TENANT:
jdbcTemplate.execute("DROP SCHEMA tenant_" + tenantId + " CASCADE");
break;
case SHARED_SCHEMA:
// Delete all rows for this tenant
orderRepository.deleteByTenantId(tenantId);
userRepository.deleteByTenantId(tenantId);
// ... delete from all tables
break;
}

// Remove from tenant registry
tenantRepository.deleteById(tenantId);
}
}

Data Retention Compliance: Many regulations (GDPR, SOX) require specific data retention periods. Always export audit logs before deletion, and implement soft deletes with deferred permanent deletion. See Data Protection for compliance details.


Testing Multi-Tenant Applications

Unit Testing with Tenant Context

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

@Mock
private OrderRepository orderRepository;

@InjectMocks
private OrderService orderService;

@BeforeEach
void setUp() {
TenantContext.setCurrentTenant("test-tenant");
}

@AfterEach
void tearDown() {
TenantContext.clear();
}

@Test
void shouldReturnOrdersForCurrentTenant() {
// Given
List<Order> expectedOrders = Arrays.asList(
createOrder("test-tenant", "order-1"),
createOrder("test-tenant", "order-2")
);
when(orderRepository.findByTenantId("test-tenant"))
.thenReturn(expectedOrders);

// When
List<Order> orders = orderService.getAllOrders();

// Then
assertEquals(2, orders.size());
orders.forEach(order ->
assertEquals("test-tenant", order.getTenantId())
);
}
}

Integration Testing with TestContainers

@SpringBootTest
@Testcontainers
class MultiTenantIntegrationTest {

@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

@Autowired
private TenantProvisioningService provisioningService;

@Autowired
private OrderService orderService;

@Test
void shouldIsolateTenantData() {
// Provision two tenants
Tenant tenantA = provisioningService.provisionTenant(
new TenantRequest("Tenant A", "BASIC")
);
Tenant tenantB = provisioningService.provisionTenant(
new TenantRequest("Tenant B", "BASIC")
);

// Create order for tenant A
TenantContext.setCurrentTenant(tenantA.getId());
Order orderA = orderService.createOrder(new OrderRequest(100.0));
TenantContext.clear();

// Create order for tenant B
TenantContext.setCurrentTenant(tenantB.getId());
Order orderB = orderService.createOrder(new OrderRequest(200.0));
TenantContext.clear();

// Verify isolation: Tenant A should not see Tenant B's order
TenantContext.setCurrentTenant(tenantA.getId());
List<Order> ordersA = orderService.getAllOrders();
assertEquals(1, ordersA.size());
assertEquals(orderA.getId(), ordersA.get(0).getId());
TenantContext.clear();

// Verify isolation: Tenant B should not see Tenant A's order
TenantContext.setCurrentTenant(tenantB.getId());
List<Order> ordersB = orderService.getAllOrders();
assertEquals(1, ordersB.size());
assertEquals(orderB.getId(), ordersB.get(0).getId());
TenantContext.clear();
}
}

For comprehensive testing strategies, see Spring Boot Testing and Integration Testing.


Summary

Key Takeaways:

  1. Multi-tenancy models offer different trade-offs between cost efficiency and isolation

    • Shared schema: Maximum density, lowest cost, highest data leakage risk
    • Schema per tenant: Good balance of isolation and cost
    • Database per tenant: Strongest isolation, highest cost, compliance-friendly
  2. Tenant identification must be consistent and secure across all requests

    • Use subdomain, header, or JWT claim-based identification
    • Always validate tenant ownership with authorization checks
  3. Defense in depth prevents catastrophic data leakage

    • Application-level tenant filtering in every query
    • Hibernate/JPA filters for automatic tenant_id injection
    • PostgreSQL Row-Level Security as database-enforced safety net
    • Authorization checks to prevent IDOR attacks
    • Comprehensive audit logging for compliance and incident detection
  4. Noisy neighbor mitigation requires resource isolation

    • Per-tenant connection pools and rate limits
    • Query timeouts to prevent runaway queries
    • Resource quotas for storage and API usage
  5. Tenant context management must be thread-safe and request-scoped

    • Use ThreadLocal for tenant context storage
    • Always clear context after request to prevent leakage
    • Test tenant isolation thoroughly in integration tests
  6. Tenant-specific customization enables flexible SaaS offerings

    • Feature flags for per-tenant functionality
    • Custom branding and configuration
    • Tenant-specific performance tuning
  7. Tenant lifecycle includes provisioning, operation, and offboarding

    • Automated tenant provisioning with infrastructure-as-code
    • Soft deletes with deferred permanent deletion
    • Data retention compliance before deletion

Related Topics:


External Resources