Multi-tenant SaaS apps must isolate customer data completely. Learn three approaches — database-per-tenant, schema-per-tenant, and row-level discrimination — with Spring Boot and Hibernate.
JOptimize Team
A SaaS application serves multiple customers (tenants) from the same codebase. The critical requirement: Tenant A must never see Tenant B's data. The architectural decision — how to physically separate tenant data — has massive implications for cost, isolation, complexity, and scale.
| Approach | Isolation | Cost | Complexity | Scale |
|---|---|---|---|---|
| Database per tenant | Strongest | High | High | Medium |
| Schema per tenant | Strong | Medium | Medium | Good |
| Row-level discriminator | Weakest | Low | Low | Excellent |
All tenants share the same tables. Every table has a tenant_id column. Application code filters by tenant.
@Entity @FilterDef( name = "tenantFilter", parameters = @ParamDef(name = "tenantId", type = String.class) ) @Filter(name = "tenantFilter", condition = "tenant_id = :tenantId") public class Order { @Id private Long id; @Column(name = "tenant_id", nullable = false) private String tenantId; private BigDecimal total; // ... }
@Component @RequiredArgsConstructor public class TenantFilterInterceptor implements HandlerInterceptor { private final EntityManager em; @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) { String tenantId = req.getHeader("X-Tenant-ID"); if (tenantId == null) { res.setStatus(400); return false; } TenantContext.set(tenantId); Session session = em.unwrap(Session.class); session.enableFilter("tenantFilter") .setParameter("tenantId", tenantId); return true; } @Override public void afterCompletion(HttpServletRequest req, HttpServletResponse res, Object handler, Exception ex) { TenantContext.clear(); } }
Pros: simple, cheap, excellent scale. Cons: one SQL injection or filter bug exposes all tenants; no physical isolation for compliance.
Each tenant gets their own PostgreSQL schema within the same database. Hibernate's MultiTenancyStrategy.SCHEMA routes queries to the correct schema.
@Component public class TenantSchemaResolver implements CurrentTenantIdentifierResolver<String> { @Override public String resolveCurrentTenantIdentifier() { String tenantId = TenantContext.get(); return tenantId != null ? tenantId : "public"; // Fallback to public schema } @Override public boolean validateExistingCurrentSessions() { return true; } } @Component @RequiredArgsConstructor public class SchemaConnectionProvider implements MultiTenantConnectionProvider<String> { private final DataSource dataSource; @Override public Connection getAnyConnection() throws SQLException { return dataSource.getConnection(); } @Override public Connection getConnection(String tenantId) throws SQLException { Connection connection = dataSource.getConnection(); // Set search_path to tenant schema connection.createStatement() .execute("SET search_path TO " + sanitize(tenantId) + ", public"); return connection; } @Override public void releaseConnection(String tenantId, Connection connection) throws SQLException { connection.createStatement().execute("SET search_path TO public"); connection.close(); } private String sanitize(String tenantId) { // CRITICAL: prevent SQL injection in schema name if (!tenantId.matches("[a-z0-9_]+")) { throw new IllegalArgumentException("Invalid tenant ID: " + tenantId); } return tenantId; } }
# application.properties spring.jpa.properties.hibernate.multiTenancy=SCHEMA spring.jpa.properties.hibernate.multi_tenant_connection_provider= com.myapp.multitenancy.SchemaConnectionProvider spring.jpa.properties.hibernate.tenant_identifier_resolver= com.myapp.multitenancy.TenantSchemaResolver
@Service @RequiredArgsConstructor public class TenantProvisioningService { private final JdbcTemplate jdbc; private final Flyway flyway; @Transactional public void provisionTenant(String tenantId) { // Sanitize before using in SQL if (!tenantId.matches("[a-z0-9_]+")) { throw new IllegalArgumentException("Invalid tenant ID"); } // Create schema jdbc.execute("CREATE SCHEMA IF NOT EXISTS " + tenantId); // Run Flyway migrations in tenant schema Flyway tenantFlyway = Flyway.configure() .dataSource(jdbc.getDataSource()) .schemas(tenantId) .locations("classpath:db/migration/tenant") .load(); tenantFlyway.migrate(); log.info("Provisioned tenant: {}", tenantId); } }
Each new tenant gets their own schema with all tables pre-created via Flyway migrations. Schema migrations for all tenants:
@Scheduled(cron = "0 0 3 * * *") // 3 AM — migrate all tenant schemas public void migrateAllTenants() { tenantRepository.findAllActive().forEach(tenant -> { Flyway tenantFlyway = Flyway.configure() .dataSource(dataSource) .schemas(tenant.getId()) .locations("classpath:db/migration/tenant") .load(); tenantFlyway.migrate(); }); }
For maximum isolation (financial, healthcare, compliance requirements):
@Component @RequiredArgsConstructor public class TenantDatasourceRouter extends AbstractRoutingDataSource { private final TenantDataSourceRegistry registry; @Override protected Object determineCurrentLookupKey() { return TenantContext.get(); } @PostConstruct public void initialize() { Map<Object, Object> dataSources = new HashMap<>(); registry.findAll().forEach(tenant -> dataSources.put(tenant.getId(), buildDataSource(tenant))); setTargetDataSources(dataSources); afterPropertiesSet(); } private DataSource buildDataSource(Tenant tenant) { HikariConfig config = new HikariConfig(); config.setJdbcUrl(tenant.getDatabaseUrl()); config.setUsername(tenant.getDbUser()); config.setPassword(tenant.getDbPassword()); config.setMaximumPoolSize(5); // Small pool per tenant return new HikariDataSource(config); } }
Each tenant has their own connection pool pointing to their own database. Data is completely isolated at the infrastructure level.
SET search_path (schema approach) or connection strings (database approach); always validate against a strict allowlist patternpublic schema are shared; make sure each tenant schema has its own sequences or use UUIDsFor most SaaS applications, schema-per-tenant is the right balance: strong isolation via SET search_path, shared database infrastructure costs, and straightforward Flyway migration management. Row-level discrimination is simpler but provides weak isolation — one query bug exposes all tenants. Database-per-tenant is for strict compliance requirements. In all cases, the tenant identifier must be rigorously validated to prevent SQL injection.
JOptimize detects missing tenant filters, potential cross-tenant data leaks, and ThreadLocal cleanup issues in Spring Boot multi-tenant applications.
Build secure multi-tenant SaaS on Spring Boot — free architecture scan.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.