Destino Dev

Blog

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 é:

  1. Inserir metadados no tenant registry
  2. Criar schema no banco
  3. Executar migrations Flyway apontando para o schema recém-criado
  4. 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_sql e hibernate.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

  1. Confirme que o header/subdomínio chega corretamente ao Filter.
  2. Garanta que TenantContext é definido antes de qualquer bean que dependa do tenant ser chamado.
  3. Valide a implementação do MultiTenantConnectionProvider: getConnection deve setar schema; releaseConnection deve resetar.
  4. Verifique Flyway: a tabela flyway_schema_history aparece no schema esperado.
  5. 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