Programação Por Equipe DevBras

Capa do artigo: Um banco por cliente no EF Core sem misturar os dados de um com os do outro

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

  1. Guarde a lista de clientes e suas connection strings fora do código, em configuração protegida ou num banco de catálogo.
  2. Na autenticação, inclua o identificador do cliente na sessão ou no token, como claim.
  3. 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.
  4. Registre o DbContext pedindo esse serviço ao montar as opções.
  5. 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:

  1. Faça login como usuário da Padaria Sol.
  2. Peça GET /pedidos/1042.
  3. O resultado tem de ser 404, não o pedido da Oficina.
  4. 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 DbContext no 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?

Avalie este artigo

Referência: https://stackoverflow.com/questions/52157196/ef-core-and-multiple-databases