← Voltar pros projetos

ERP multi-tenant

Um ERP do zero (financeiro, contabilidade, contas a pagar e RH) construído pra aguentar mais de um cliente no mesmo banco sem vazar linha de um pro outro.

funcionalFunciona; uso próprio ou demo. Ainda sem dado real de cliente.

O problema

Empresa pequena que compra ERP pronto paga por módulo que não usa e não consegue mudar nada. Empresa que manda fazer sob medida paga caro e fica presa em quem fez. Eu quis entender o que existe no meio: um sistema onde cada cliente é um tenant no mesmo banco, com os mesmos módulos, e a diferença entre eles é configuração, não código.

O interessante não é escrever as telas. É que a partir do momento em que dois clientes dividem um banco, todo erro de programação vira vazamento de dado de um cliente pro outro. É esse o problema real do projeto, e é sobre ele que são as decisões abaixo.

Decisões técnicas

Filtro global de tenant que falha fechado, não aberto

Contra o quê: repetir WHERE tenant_id = @x em cada consulta, que era o desenho anterior, e um filtro global que, sem tenant resolvido, liberava todas as linhas de todos os tenants.

Hoje o DbContext aplica um filtro automático em toda entidade que implementa ITenantScoped. Sem tenant no contexto, a consulta não devolve nada: em vez de devolver tudo.

O que custou: quem precisa ler cross-tenant de propósito agora tem que escrever IgnoreQueryFilters() explicitamente. Mais atrito no caminho raro, em troca de tornar o erro comum impossível. A fábrica de design-time roda sem contexto e portanto filtra tudo; é inofensivo porque design-time só roda migration, mas é o tipo de canto que precisa estar documentado no próprio código pra ninguém “consertar” errado depois.

Um atributo por controller, que declara e aplica a permissão

Contra o quê: cada action reescrevendo a própria checagem de acesso.

A disciplina falhou na prática: houve um commit que vazou justamente porque confiou na checagem existente, não porque alguém a desligou. Então a autorização virou um atributo único no topo do controller, [TenantModule("HR.Employees")], que declara qual recurso aquele controller expõe e faz o gate na mesma linha.

O que custou: o atributo resolve a permissão contra o tenant do token, enquanto os controllers de módulo leem os dados pelo tenant da rota. Se os dois discordarem, a resposta tem que ser 403, porque autorizar contra um tenant e ler de outro é pior do que não autorizar. Isso significa que rota e token passaram a ser acoplados, e um cliente que monte a URL “errada” recebe um erro em vez do dado.

Um teste de arquitetura que quebra o build quando alguém esquece

Contra o quê: confiar em revisão de código pra lembrar do atributo.

Existe um teste que varre os controllers e falha se algum não declarou o gate. Endpoint novo escrito sem cuidado não passa.

O que custou: e essa é a parte que quase ninguém escreve: o teste pega o esquecimento, não o engano. Um controller que declare [TenantModule] com o código de recurso errado passa no teste e continua errado. A rede pega quem esqueceu, não quem escreveu bobagem. E saber exatamente onde a rede tem buraco é a diferença entre ter proteção e achar que tem.

O que ficou de fora

Cache. Existe um módulo de Redis escrito, funcionando e não conectado a nada: a API tinha um ICacheService que ninguém nunca injetou. Preferi deixar registrado como não usado a fingir que faz parte do sistema. Entra quando o cache justificar o próprio custo.

O sistema ainda não tem cliente com dado real. Por isso o estado aqui é funcional, não no ar. E só muda no dia em que alguém carregar dado de verdade.


  • .NET
  • PostgreSQL
  • Angular
  • Terraform
  • Azure
  • Docker