Multi-tenant em Spring Boot: schema-per-tenant com Hibernate, Flyway e routing
Por John Pires · 16 de março de 2026

Multi-tenant em Spring Boot: schema-per-tenant com Hibernate, Flyway e routing
1) Motivacao e trade-offs
Multi-tenant é exigência comum em SaaS: várias organizações (tenants) usam a mesma aplicação. Implementar isolamento de dados pode seguir patterns diferentes: database-per-tenant, schema-per-tenant ou row-per-tenant. Cada escolha tem trade-offs claros.
Database-per-tenant oferece isolamento máximo — backup e recuperação por cliente, limite de recursos por DB —, mas é caro em gerenciamento e conexões. Row-per-tenant é simples para escala horizontal e deploy, mas exige filtros consistentes e pode ser arriscado para compliance. Schema-per-tenant é um meio-termo: compartilha a mesma instância de banco, mantém separação lógica via schemas, facilita backups por tenant e é compatível com migrations por schema.
Este artigo parte do pressuposto que você escolheu schema-per-tenant. Vamos explorar como mapear isso no Hibernate usando o modo MULTI_TENANT_SCHEMA, como rotearmos o tenant em runtime, e como aplicar migrations com Flyway por schema. Também abordamos armadilhas práticas — especialmente relacionadas a ThreadLocal e virtual threads.
Quando escolher schema-per-tenant
Escolha schema-per-tenant quando: você precisa de isolamento lógico, quer menos overhead operacional que múltiplos bancos, e seu SGBD suporta schemas (Postgres, Oracle, SQL Server). Não escolhe quando tenants têm SLAs radicalmente diferentes ou quando o número de tenants explode (milhares é mais fácil com row-per-tenant ou sharding).
Trade-offs operacionais
Com schema-per-tenant você gerencia migrations por schema e precisa de processos para criar schema e rodar migrações na criação de tenant. Também precisa de uma estratégia para conexões: reposicionar schema na conexão ou manter DataSources separados por tenant.
2) Fundamentos tecnicos
Os pontos técnicos centrais são três: como a aplicação sabe qual tenant servir (routing), como o Hibernate ajusta o schema em cada conexão, e como Flyway aplica migrations por schema. Complementos: como armazenar metadados de tenants e como autenticar/autorizar de forma consistente.
Frameworks e componentes relevantes:
- Spring Boot + Spring Data JPA
- Hibernate Multi Tenancy API (MULTI_TENANT_SCHEMA)
- Flyway programático para rodar migrations por schema
- Servlet Filter / WebFlux WebFilter para extrair tenant id
Como Hibernate lida com multitenancy
Hibernate suporta 3 estratégias: DATABASE, SCHEMA e DISCRIMINATOR. Para schema-per-tenant usamos SCHEMA e implementamos duas interfaces: MultiTenantConnectionProvider e CurrentTenantIdentifierResolver. O primeiro devolve conexões e pode ajustar schema na conexão; o segundo resolve o identificador do tenant por requisição.
Onde armazenar tenant id
Opções típicas: subdomínio (tenant.example.com), header HTTP (X-Tenant-ID) ou path (/t/{tenant}/...). Subdomínio é mais transparente mas exige resolver no servidor e TLS; header é simples para APIs internas. Independentemente, você precisa propagar o tenant em chamadas internas e em threads assíncronas.
3) Exemplo 1: caso simples
Este exemplo mostra um pipeline mínimo: um Filter que lê o header X-Tenant-ID, armazena em um TenantContext (ThreadLocal), e configura Hibernate via MultiTenantConnectionProvider que chama connection.setSchema(schema) (Postgres). Também mostramos como rodar Flyway ao criar um tenant.
Contexto do código
O próximo bloco implementa o TenantContext — um ThreadLocal simples que guarda o id do tenant para a requisição atual. Importante: limpar sempre no finally.
package com.example.multitenant;
public final class TenantContext {
private static final ThreadLocal<String> CURRENT = new ThreadLocal<>();
private TenantContext() {}
public static void setCurrentTenant(String tenantId) {
CURRENT.set(tenantId);
}
public static String getCurrentTenant() {
return CURRENT.get();
}
public static void clear() {
CURRENT.remove();
}
}
O que faz: provê um ThreadLocal para armazenar o tenant id. O que acontece: se você esquecer de limpar, memória e vazamento de contexto podem ocorrer — especialmente com thread pools.
Filtro que extrai tenant
Exemplo de Filter Spring que lê header e popula TenantContext. Note o finally que garante limpeza.
package com.example.multitenant.web;
import com.example.multitenant.TenantContext;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
public class TenantFilter implements Filter {
public static final String HEADER = "X-Tenant-ID";
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
String tenant = req.getHeader(HEADER);
if (tenant == null) {
tenant = "public"; // fallback
}
try {
TenantContext.setCurrentTenant(tenant);
chain.doFilter(request, response);
} finally {
TenantContext.clear();
}
}
}
O que faz: popula TenantContext por requisição. O que acontece: se você usar virtual threads ou executar trabalho assíncrono, o ThreadLocal não é propagado automaticamente — veja seção de armadilhas.
MultiTenantConnectionProvider simples
Implementação que reusa um DataSource e chama setSchema na conexão. Válido para Postgres. Para outros DBs a API pode diferir.
package com.example.multitenant.hibernate;
import com.example.multitenant.TenantContext;
import org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
@Component
public class SchemaPerTenantConnectionProvider implements MultiTenantConnectionProvider {
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 connection) throws SQLException {
connection.close();
}
@Override
public Connection getConnection(String tenantIdentifier) throws SQLException {
Connection connection = getAnyConnection();
// Postgres-specific
connection.setSchema(tenantIdentifier);
return connection;
}
@Override
public void releaseConnection(String tenantIdentifier, Connection connection) throws SQLException {
// optional: reset schema to default
connection.setSchema("public");
connection.close();
}
// demais métodos obrigatórios com implementações simples omitted for brevity
}
O que faz: configura o schema do Connection antes de usá-lo. O que acontece: todas as operações JPA na sessão usam o schema correto. Atenção ao reset do schema antes de devolver a conexão ao pool.
4) Exemplo 2: caso avancado do mundo real
Em produção você terá requisitos adicionais: criação dinâmica de tenants, aplicação de migrations por schema, cache e propagação de tenant em jobs assíncronos. Abaixo um fluxo realista e código para aplicar Flyway por schema.
Armazenando metadados dos tenants
Normalmente existe uma base central (tenant registry) que guarda mapping tenantId ↔ schema ↔ plano ↔ env. Esse registro pode ser em outro schema chamado admin ou um banco separado. Mantê-lo separado evita que buscas de tenants dependam do tenant atual.
Quando um novo tenant é criado, o fluxo é:
- Inserir metadados no tenant registry
- Criar schema no banco
- Executar migrations Flyway apontando para o schema recém-criado
- Inicializar dados (se necessário)
Flyway programático por tenant
O bloco a seguir demonstra como executar Flyway para um schema específico. É programático — útil para criar tenant on-demand. Requer flyway-core no classpath.
package com.example.multitenant.flyway;
import org.flywaydb.core.Flyway;
import javax.sql.DataSource;
public class FlywayService {
private final DataSource dataSource;
public FlywayService(DataSource dataSource) {
this.dataSource = dataSource;
}
public void migrateTenant(String schema, String locations) {
Flyway flyway = Flyway.configure()
.dataSource(dataSource)
.schemas(schema)
.locations(locations)
.baselineOnMigrate(true)
.load();
flyway.migrate();
}
}
O que faz: roda migrations apenas contra o schema indicado. O que acontece: Flyway cria sua tabela de histórico dentro do schema, isolando versions por tenant.
Propagacao de tenant em tasks e virtual threads
Problema real: work assíncrono. Se você publica um evento e processa em thread pool, o TenantContext (ThreadLocal) não é copiado. Solução: usar wrappers que capturem e reproduzam o tenant id, ou usar bibliotecas de contexto como TransmittableThreadLocal. Com virtual threads, ThreadLocal funciona diferentemente: não há uma cópia automática ao criar virtual threads; portanto, sempre capture explicitamente.
Runnable wrapWithTenant(Runnable task) {
String tenant = TenantContext.getCurrentTenant();
return () -> {
try {
TenantContext.setCurrentTenant(tenant);
task.run();
} finally {
TenantContext.clear();
}
};
}
O que faz: cria um Runnable que propaga o tenant. O que acontece: evita leituras em branco do tenant e vazamento entre tasks.
5) Debugging e troubleshooting
Aqui se reúnem erros comuns e técnicas práticas para diagnóstico. Listo armadilhas que já vi em produção e como corrigi-las.
Erros comuns
1) TenantContext vazando: esquecer de limpar o ThreadLocal provoca comportamento errático. Sintoma: requisições assumem tenant anterior. Diagnóstico: logging do TenantContext no inicio e fim; ferramenta: add MDC com tenant também.
2) Schema não aplicado: conexões retornadas ao pool podem manter schema anterior se não resetar. Sintoma: dados aparecem de outro tenant. Solução: no releaseConnection faça connection.setSchema(default) antes do close.
3) Flyway escrevendo em schema errado: se Flyway usa o schema default em vez do indicado, verifique se a DataSource inicial está usando um search_path. Forçar schemas via Flyway.configure().schemas(...) resolve.
Diagnóstico prático
- Log SQL com
show_sqlehibernate.format_sql. - Habilite um interceptor/session listener para logar o schema atual por conexão.
- Monitorar tabelas de metadados do pool (HikariCP) e ver quando conexões são emprestadas e devolvidas.
Use logs determinísticos: registre tenant id, request id e thread id em cada entrada de log para rastrear problemas de contexto.
Checklist ao depurar
- Confirme que o header/subdomínio chega corretamente ao Filter.
- Garanta que TenantContext é definido antes de qualquer bean que dependa do tenant ser chamado.
- Valide a implementação do MultiTenantConnectionProvider: getConnection deve setar schema; releaseConnection deve resetar.
- Verifique Flyway: a tabela flyway_schema_history aparece no schema esperado.
- Testes end-to-end: crie dois tenants, insira dados em um, confirme isolamento.
6) Conclusao e recomendações práticas
Schema-per-tenant equilibra isolamento e custo operacional. Para uma implementação robusta, siga estas recomendações:
- Centralize metadados de tenants em um registro separado.
- Use MultiTenantConnectionProvider + CurrentTenantIdentifierResolver para integrar com Hibernate.
- Proteja contra vazamento de ThreadLocal: sempre limpar e propagar manualmente em tasks assíncronas.
- Executar Flyway programaticamente por schema durante criação de tenant e em pipelines de CI para testes de integração.
- Instrumente logs com tenant id e request id para rastreabilidade.
Implementar multitenancy é mais engenharia de runtime e operações do que pure código. Planeje processos de provisionamento, backup e migração e escreva testes que simulem isolamento entre tenants. Com configurações bem definidas e práticas de propagação de contexto, schema-per-tenant com Hibernate e Flyway entrega um bom balanço entre isolamento e facilidade de operação.
Resumo prático: evite soluções mágicas. Trate tenant id como primeiro-citizen no fluxo de requisição, garanta que cada conexão seja corretamente associada ao schema, e automatize migrations por tenant. Esses três pontos resolvem a maior parte dos problemas de produção.
Referências rápidas: Hibernate MultiTenancy docs, Flyway Java API, Postgres JDBC setSchema behavior, HikariCP docs (connection reset).
Assuntos: spring-boot, hibernate, flyway, multitenancy, Java
Leia também
Outbox transacional com Spring Boot, JPA e Kafka — implementação prática
Implementação prática do padrão Outbox com Spring Boot, JPA e Kafka: gravação atômica de eventos, poller confiável, idempotência do consumidor e técnicas para…
Paginação por cursor (keyset) com Spring Boot + JPA: evitar OFFSET passo a passo
Aprenda passo a passo como substituir OFFSET por paginação por cursor (keyset) em Spring Boot + JPA. Exemplos práticos, entidade, repositório nativo…
Processando grandes consultas JPA sem OOM: Stream, fetchSize e batches
Técnicas práticas para processar grandes consultas JPA sem OOM: use stream, ajuste fetchSize e processe em batches com limpeza do persistence context para…
Idempotência em APIs Spring Boot: chaves, Redis e exemplo passo a passo
Implementação prática de idempotência em APIs Spring Boot usando chaves e Redis. Tutorial passo a passo com configuração, interceptor, controller e dicas para…
Rate limiting por usuário em Spring Boot com Redis (Token Bucket) — passo a passo
Aprenda a implementar rate limiting por usuário em Spring Boot usando Redis e Token Bucket. Mostro a abordagem errada (in-memory), a solução correta com…
Virtual Threads no Spring Boot: crie uma API concorrente passo a passo
Aprenda a usar virtual threads com Spring Boot para criar APIs concorrentes sem reescrever tudo. Veja code-review: o jeito errado, o certo, configuração…