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 build e dotnet 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.