Como estruturar um monorepo .NET + Next.js sem virar bagunça
Um monorepo poliglota funciona quando cada toolchain é dona da própria subárvore e nada atravessa a fronteira além de um contrato HTTP e um punhado de arquivos de configuração. Ele vira bagunça quando alguém tenta fazer .NET e Node compartilharem um sistema de build.
A estrutura
apps/
marketing-site/ Next.js: site público, SEO, preços
web-app/ React: área do cliente autenticada
admin-panel/ React: operação interna
backend/
api/
src/ Solução .NET: Api, Application, Domain, Infrastructure
tests/ xUnit
config/
branding.json product.json pricing.json features.json
docs/
scripts/
package.json npm workspaces, só na raiz
Duas toolchains, duas raízes. O npm nunca olha dentro de backend/; o dotnet nunca olha dentro de apps/. O diretório config/ é a única coisa que os dois lados leem.
Regra 1: um lockfile, na raiz
O npm workspaces coloca um único package-lock.json na raiz do repositório e nenhum nas apps individuais. Isso não é estética. Lockfiles por app produzem três resoluções diferentes da mesma dependência transitiva, três conjuntos de alertas de segurança para um único aviso, e uma atualização de dependência que precisa ser aplicada três vezes.
Coloque os lockfiles por app no .gitignore para que um npm install perdido dentro de apps/web-app não consiga commitar um por acidente. Se a sua plataforma de deploy builda uma app isolada, faça com que ela instale a partir do lockfile da raiz em vez de gerar o próprio.
Fixar versões também é assunto da raiz, via overrides no package.json raiz, para que uma versão forçada valha em todo lugar de uma vez.
Regra 2: a fronteira entre as stacks é HTTP, e nada mais
A tentação é um pacote de tipos compartilhados, gerado a partir dos records em C# e importado pelo TypeScript. Resista até sentir a dor que justifique isso, porque ele acopla os ciclos de deploy de duas coisas que são publicadas de forma independente.
O que funciona na prática: a API publica um documento OpenAPI, e cada frontend mantém um tipo de resposta pequeno, escrito à mão, perto do código que chama o endpoint. Duplica-se um punhado de declarações de interface. Em troca, os frontends não têm dependência de build com o backend, e uma refatoração no backend não consegue quebrar o build de um frontend.
O corolário é que nenhum frontend importa outro frontend. Três apps que compartilham a mesma linguagem visual vão querer compartilhar componentes, e no momento em que web-app importa de admin-panel, você não tem mais três apps. Tem uma app com três pontos de entrada e um raio de impacto compartilhado. Ou você duplica o componente, ou promove ele a um pacote de workspace de verdade, com o próprio package.json.
Regra 3: configuração é dado, num lugar só
Nome do produto, cores da marca, nomes dos planos e chaves de feature aparecem no site de marketing, na app do cliente, no painel administrativo, na API e na seed do banco. Cinco cópias são cinco lugares para esquecer.
Coloque tudo em config/*.json na raiz e faça todos os consumidores lerem de lá: os frontends importam o JSON direto, a API lê no startup, o script de seed gera a partir dele. Renomear o produto vira um diff de uma linha em vez de uma busca pelo repositório inteiro.
O teste de que isso está funcionando: rebrandear o produto inteiro deve tocar exatamente um arquivo.
Regra 4: a CI roda por projeto, não tudo ou nada
Um monorepo onde todo push roda a matriz completa, ou seja, três builds de frontend, dotnet test e a suíte end-to-end inteira, treina o time a ignorar a CI, porque um erro de digitação num título de marketing leva doze minutos para ser mergeado.
Separe por caminho:
- Mudanças em
apps/*→ lint, checagem de tipos e testes unitários daquela app - Mudanças em
backend/→dotnet buildedotnet test - Mudanças em
config/ou em qualquer coisa compartilhada → tudo - Testes end-to-end → na branch principal e sob demanda, não em cada pull request
A suíte end-to-end é a que precisa mesmo ser controlada. Ela exige banco, API rodando e frontends buildados; é a coisa mais lenta e mais instável que você tem. Rode onde uma falha mereça a atenção de uma pessoa, não em todo push de rascunho.
O que dá errado de verdade
Três padrões de falha respondem pela maior parte da dor de um monorepo poliglota.
O primeiro é um Dockerfile compartilhado. A API .NET precisa de uma imagem com SDK e de um publish multi-stage. Os frontends precisam de Node, e na maioria das plataformas não precisam de container nenhum. Um Dockerfile servindo aos dois faz cada mudança de backend reconstruir as camadas do frontend. Dê à API o próprio Dockerfile e deixe os frontends publicarem como saída estática ou serverless.
O segundo é o ambiente local que exige tudo. Se mexer no site de marketing significa subir Postgres, a API e outros dois frontends, as pessoas vão evitar o monorepo. Cada app deve rodar sozinha contra uma URL de API configurada, com um único docker compose up levantando o banco quando o backend for realmente necessário.
O terceiro é a CI por caminho que dispara de menos em silêncio. As regras acima só são seguras se o gatilho de "compartilhado" estiver de fato completo. Quando config/ muda e a CI roda só uma app, você publica um rename em dois de três frontends. Mantenha a lista de caminhos compartilhados curta o bastante para ser auditada e, na dúvida, rode tudo.
Por que monorepo, afinal
O ganho é a mudança atômica. Adicionar um campo na API, expor no painel administrativo e renderizar no site de marketing é um commit, um review, um deploy, um revert. Espalhado em quatro repositórios, são quatro pull requests numa ordem obrigatória e uma tarde ruim se precisar desfazer.
Você paga isso com disciplina de build. As quatro regras acima são o pagamento. Pule elas e você fica com o acoplamento de um monolito somado à complexidade de ferramental dos microsserviços.