Um banco por cliente no EF Core sem misturar os dados de um com os do outro
Escolha a conexão no começo da requisição a partir da conta autenticada, crie o DbContext com ela, aplique migrações numa lista controlada e prove o isolamento com teste.
A Padaria Sol abre a tela de pedidos e vê o pedido 1042 da Oficina Lua. Mesmo sistema, um banco para cada cliente, e uma connection string que ficou guardada num serviço registrado como singleton desde a requisição anterior. A resposta curta: escolha o banco no começo de cada requisição a partir de um dado confiável (a conta autenticada, nunca um parâmetro da URL), crie o DbContext com essa conexão em escopo de requisição e nunca a guarde em nada que viva mais que a requisição.
Banco por cliente é uma decisão de isolamento, não um detalhe de string de conexão. O código pode ser um só; o caminho até o banco precisa ser decidido de novo a cada chamada, e de um jeito que um usuário não consiga influenciar.
Por que a conexão vaza de um cliente para outro
No ASP.NET Core, o DbContext registrado com AddDbContext vive por requisição. O problema aparece quando a escolha do banco fica em outro lugar com vida mais longa: um serviço singleton que guarda "o tenant atual" num campo, uma variável estática, um contexto criado uma vez e reutilizado. Com uma pessoa testando, tudo funciona. Com duas empresas usando ao mesmo tempo, a segunda requisição herda o estado da primeira.
O outro caminho de vazamento é a origem do dado. Se o banco é escolhido por um cabeçalho X-Tenant ou por um parâmetro na URL, basta trocar o valor para ler outro cliente. A escolha precisa vir de algo que o servidor emitiu e assinou: a claim da sessão ou do token, conferida na autenticação.
Este guia trata de bancos separados com o mesmo modelo. Juntar dois bancos diferentes numa mesma operação é outro problema, com outras armadilhas, descrito em dois bancos no mesmo contexto não viram uma transação só.
Como montar a escolha do banco por requisição
- Guarde a lista de clientes e suas connection strings fora do código, em configuração protegida ou num banco de catálogo.
- Na autenticação, inclua o identificador do cliente na sessão ou no token, como claim.
- Crie um serviço com escopo de requisição que lê essa claim e devolve a connection string correspondente. Cliente desconhecido é erro, não fallback.
- Registre o
DbContextpedindo esse serviço ao montar as opções. - Para tarefas fora de requisição, passe o cliente explicitamente.
A configuração dos bancos, com as connection strings vindas de variável de ambiente ou cofre de segredos, como em segredo fora da imagem:
public class TenantsOptions
{
public Dictionary<string, string> Bancos { get; set; } = new();
}
public interface ITenantAtual
{
string Id { get; }
string ConnectionString { get; }
}
public class TenantAtual : ITenantAtual
{
public TenantAtual(IHttpContextAccessor acesso, IOptions<TenantsOptions> opcoes)
{
var id = acesso.HttpContext?.User.FindFirstValue("tenant_id")
?? throw new InvalidOperationException("Requisição sem cliente autenticado.");
if (!opcoes.Value.Bancos.TryGetValue(id, out var cs))
throw new InvalidOperationException($"Cliente desconhecido: {id}");
Id = id;
ConnectionString = cs;
}
public string Id { get; }
public string ConnectionString { get; }
}
O registro amarra tudo ao escopo da requisição:
builder.Services.AddHttpContextAccessor();
builder.Services.Configure<TenantsOptions>(builder.Configuration.GetSection("Tenants"));
builder.Services.AddScoped<ITenantAtual, TenantAtual>();
builder.Services.AddDbContext<AppDb>((sp, opcoes) =>
opcoes.UseSqlServer(sp.GetRequiredService<ITenantAtual>().ConnectionString));
A cada requisição, o area de injeção cria um TenantAtual novo, que lê a claim daquela requisição, e um AppDb novo, conectado ao banco certo. Nada disso sobrevive ao fim da requisição. Repare que a falha é barulhenta: sem claim ou com cliente desconhecido, a requisição quebra em vez de cair num banco "padrão".
O que não pode ser singleton
Qualquer serviço que recebe AppDb ou ITenantAtual no construtor precisa ser scoped ou transient. Um singleton que recebe o contexto prende o primeiro banco que viu, para sempre. O ambiente de desenvolvimento do ASP.NET Core valida escopos e costuma acusar esse erro na inicialização; não desligue essa validação para "fazer funcionar". Pool de contexto (AddDbContextPool) também reaproveita instâncias entre requisições, então só use com um mecanismo explícito para trocar a conexão a cada uso.
Tarefas em segundo plano e o cliente explícito
Um job que roda à noite não tem HttpContext, então o TenantAtual acima lança exceção. Isso é proposital. Tarefas de fundo devem receber o cliente como parâmetro e criar o contexto de forma explícita, um cliente por vez:
public static AppDb CriarContexto(string connectionString) =>
new AppDb(new DbContextOptionsBuilder<AppDb>()
.UseSqlServer(connectionString)
.Options);
Com isso, o código do job diz em voz alta de qual cliente está tratando, e um erro de laço não mistura dados de dois bancos no mesmo contexto.
Migrações em todos os bancos, numa lista que você controla
Cada banco precisa receber a mesma migração. "Aplicar em todos" só funciona se "todos" for uma lista conhecida, a mesma usada pela aplicação. Um pequeno programa de deploy resolve:
foreach (var (id, cs) in tenants.Bancos)
{
Console.WriteLine($"Migrando {id}...");
await using var db = CriarContexto(cs);
await db.Database.MigrateAsync();
}
Rode as migrações antes de subir a versão nova da aplicação, e pare no primeiro erro para não deixar metade dos clientes num esquema e metade em outro. Se a mudança remove ou renomeia coluna, o cuidado extra de migração em dois passos vale em dobro: com dez bancos, a janela de inconsistência é dez vezes maior. Cliente novo entra na lista e recebe todas as migrações desde a primeira na criação do banco.
Provar o isolamento com teste
O teste que importa é simples: dois bancos, um registro diferente em cada, e uma tentativa de ler o registro alheio. No teste de integração da Padaria Sol e da Oficina Lua, o pedido 1042 existe só no banco da Oficina:
- Faça login como usuário da Padaria Sol.
- Peça
GET /pedidos/1042. - O resultado tem de ser 404, não o pedido da Oficina.
- Faça duas requisições intercaladas, uma de cada cliente, e confira que cada uma vê só os próprios dados.
Se a leitura cruza, o isolamento falhou, e é melhor descobrir no teste do que pelo telefone do cliente. Inclua também no log o identificador do cliente em cada requisição, sem a connection string, para que um incidente possa ser rastreado.
Erros comuns
- Escolher o banco por cabeçalho ou parâmetro da URL: qualquer usuário troca o valor e lê outro cliente.
- Guardar o cliente atual em singleton ou campo estático: requisições simultâneas de clientes diferentes usam o banco errado.
- Cair num banco padrão quando o cliente não é reconhecido: o erro de configuração vira gravação de dados no lugar errado.
- Trocar a connection string de um contexto já usado: parte da requisição lê de um banco e parte do outro.
- Migrar "todos os bancos" a partir de uma lista diferente da usada pela aplicação: o cliente novo fica sem a coluna e o primeiro acesso dele quebra.
- Usar pool de contexto sem tratar a troca de conexão: uma instância devolvida ao pool volta para outro cliente com o banco anterior.
Checklist
- O cliente vem de uma claim emitida pelo servidor, não de entrada do usuário.
- O serviço que resolve o cliente é scoped.
- Cliente desconhecido gera erro, nunca um banco padrão.
- Nenhum singleton recebe
DbContextno construtor. - Jobs recebem o cliente explicitamente.
- Migrações rodam sobre a mesma lista de bancos que a aplicação usa.
- Um teste automatizado tenta ler o registro de outro cliente e recebe 404.
Procure no seu projeto o ponto exato em que a connection string de cada cliente é escolhida. De onde vem o valor que decide o banco, e um usuário consegue alterá-lo pela requisição?
Referência: https://stackoverflow.com/questions/52157196/ef-core-and-multiple-databases