No começo, a ideia de um monorepo é bastante atraente. Ter todo o código em um só lugar, dependências centralizadas e a possibilidade de alterar várias partes do sistema de uma vez simplifica muita coisa. Isso funciona bem enquanto o time é pequeno. Com o crescimento, porém, essa simplicidade começa a se perder. À medida que mais times e engenheiros passam a contribuir, a base de código vai ficando mais difícil de entender e de evoluir. Ao pensar na melhor estratégia para uma base de código em crescimento, essa complexidade precisa ser gerenciada. Uma mudança que deveria ser simples exige mexer em dez pacotes diferentes e, de repente, seu PR tem uma dúzia de revisores de times que você nunca conheceu. Você passa mais tempo coordenando mudanças do que escrevendo código.
Isso acontece quando um monorepo cresce sem regras claras. O que começa como uma base de código compartilhada acaba acumulando dependências difíceis de entender, sem donos bem definidos. Isso costuma levar a perguntas sobre quando quebrar um monólito. Builds ficam mais lentos, testes passam a falhar de forma intermitente, e cada merge passa a carregar mais risco de efeitos colaterais. A velocidade que parecia garantida no início simplesmente deixa de existir.
Os custos de um monorepo sem governança
Quando uma base de código cresce sem limites claros, dois problemas geralmente aparecem ao mesmo tempo: dependências fora de controle e a ilusão de responsabilidade compartilhada. Um é um problema técnico, o outro é um problema de pessoas, e eles se alimentam mutuamente.
Quando as dependências saem de controle
Sem regras aplicadas, desenvolvedores naturalmente vão importar código de onde for mais conveniente. Um utilitário criado em um serviço passa a ser usado por outro, depois por mais um. Em pouco tempo, as dependências se espalham sem limites claros. O resultado são acoplamentos difíceis de entender, mudanças que afetam áreas inesperadas e um sistema cada vez mais arriscado de evoluir.
- Dificuldade em entender as relações entre módulos. Quando você não consegue ver facilmente quem depende de quem, estimar o impacto de uma mudança vira um esforço enorme. Você acaba rodando a suíte inteira de testes para um ajuste de uma linha, só por precaução.
- Maior risco de efeitos colaterais não intencionais. Uma mudança aparentemente inofensiva em um módulo “compartilhado” pode quebrar uma parte completamente não relacionada do sistema. Esse tipo de bug costuma chegar à produção porque ninguém pensou em testar aquela interação específica.
- Ciclos de build e teste mais lentos. Ferramentas ajudam a compilar e testar apenas o que foi afetado por uma mudança, mas não fazem milagres. Se tudo depende de tudo, tudo precisa ser recompilado e retestado todas as vezes.
Por que “todo mundo é dono” significa que ninguém é
A ideia de que “todo mundo é dono do código” soa colaborativa, mas em um monorepo grande costuma ter o efeito oposto. Quando não existe ownership claro sobre um módulo ou domínio, ninguém se sente realmente responsável.
- Gargalos de review. Um PR que mexe em uma parte crítica do sistema, mas sem um responsável claro, costuma ficar parado por dias. Todo mundo reconhece que é importante, mas ninguém se sente à vontade para assumir a decisão e dar a aprovação final.
- Código obsoleto ou sem manutenção. Algumas partes da base de código acabam sendo deixadas de lado. Elas ainda funcionam, mas ninguém mantém ativamente, organiza ou planeja sua evolução. Acabam se tornando uma fonte de dívida técnica que todo mundo evita.
- Dificuldades de onboarding. Novos membros do time têm dificuldade para descobrir com quem falar sobre cada parte do código. Sem donos claros, não existem especialistas designados para tirar dúvidas, o que atrasa bastante o tempo de ramp-up.
Mudando a perspectiva: Módulos com responsabilidades bem definidas
Para escalar um monorepo sem perder o controle, é preciso introduzir estrutura e responsabilidades de forma deliberada. Isso vai além de organizar pastas e arquivos. Significa tratar cada parte lógica do sistema como um módulo bem definido, com limites claros e um contrato explícito sobre o que ele oferece e do que depende.
Um módulo não é apenas um diretório. É um componente com um propósito bem definido e, mais importante, uma interface pública clara. Todo o resto é detalhe de implementação interna que outros módulos não deveriam conseguir acessar.
Isso exige uma abordagem mais disciplinada:
- Interfaces públicas explicitamente definidas. Cada módulo deve expor uma API mínima e estável. Isso costuma ser feito por meio de um único arquivo de entrada, como um
index.ts, que exporta apenas o que é destinado ao consumo público. - Uma aplicação rigorosa da estrutura interna. Nada de acessar arquivos internos de um módulo diretamente. Essa regra precisa ser aplicada automaticamente no CI para evitar dependências acidentais de detalhes de implementação que provavelmente vão mudar.
- Estratégias claras de versionamento ou consumo. Mesmo dentro de um monorepo, é útil pensar em como os módulos são consumidos. Quando a API pública de um módulo muda, precisa existir um processo claro para que os consumidores downstream se adaptem.
Essa disciplina em torno dos módulos é a base. A próxima camada é atribuir ownership claro a cada um desses módulos. Ownership intencional é um pré-requisito para ganhar velocidade, porque cria linhas claras de comunicação e responsabilidade.
Desenhando um monorepo saudável: passos práticos
Estabelecer regras claras não precisa virar um processo pesado nem imposto de cima para baixo. Dá para começar pequeno, definindo alguns padrões de código e boas práticas e usando automação para garantir que eles sejam seguidos.
Definindo limites claros e contratos de API
Primeiro, é preciso identificar os domínios lógicos dentro da base de código. Eles podem corresponder a features, serviços ou bibliotecas compartilhadas. Depois de mapear esses domínios, o próximo passo é encapsulá-los em módulos bem definidos, com limites claros.
- Desenhe interfaces estáveis e mínimas. O objetivo da API pública de um módulo é ser o menor possível, sem deixar de ser útil. Quanto mais você expõe, mais precisa manter e mais difícil fica mudar no futuro.
- Documente responsabilidades e uso. Cada módulo deve ter um
README.mdque explique claramente o que ele faz, quem é o dono e como usar sua API pública. Esse é o contrato do módulo.
Como gerenciar dependências internas
Depois de definir os módulos, é preciso reforçar os limites entre eles. O objetivo é evitar que as dependências voltem a se espalhar sem controle.
- Aplique uma arquitetura em camadas. Um padrão comum é definir níveis, como “aplicação”, “domínio” e “plataforma”, e aplicar regras sobre quais camadas podem depender de quais. Por exemplo, código de aplicação pode depender de código de plataforma, mas não o contrário.
- Use automação para checar dependências proibidas. Isso não é negociável. Seu pipeline de CI deve incluir uma etapa que falhe o build se um PR introduzir uma dependência ilegal.
Estabelecendo e reforçando ownership de código
Com limites claros entre módulos, agora é possível atribuir donos de forma objetiva. Ownership deixa claro quem é responsável pela saúde de longo prazo de um pedaço do código.
- Escolha o modelo de ownership certo. O ownership pode ser atribuído a indivíduos ou a times. Para bibliotecas compartilhadas, um modelo baseado em componentes, com alguns mantenedores-chave, costuma funcionar bem. Para domínios de negócio, faz mais sentido que o time inteiro seja dono dos módulos relevantes.
- Aproveite a automação para governança. É aqui que entram ferramentas como arquivos
CODEOWNERS. Use-os para direcionar automaticamente PRs ao time certo para revisão. Isso garante que as pessoas com mais contexto estejam sempre envolvidas. Você também pode integrar ferramentas de lint para aplicar regras arquiteturais e automatizar métricas de saúde do código (como complexidade ou cobertura de testes) para cada módulo com dono definido.
Como manter a saúde de um monorepo
Trazer um monorepo de volta a um estado saudável, ou mantê-lo assim, se resume a um conjunto consistente de práticas. Se você está procurando por onde começar, foque nessas cinco regras.
- Defina limites explícitos para todos os módulos. Cada parte lógica do código deve viver em um módulo com uma API pública definida.
- Atribua donos claros a cada módulo. Cada arquivo do repositório deve ter um dono claro especificado em um arquivo
CODEOWNERS. Sem exceções. - Implemente checagens automatizadas de dependências. Use o CI para aplicar automaticamente suas regras arquiteturais e evitar imports ilegais entre módulos.
- Estabeleça uma estratégia consistente de evolução de módulos. Tenha um processo documentado para introduzir mudanças quebráveis na API pública de um módulo.
- Crie um processo para descontinuação de módulos. Tão importante quanto criar módulos é saber como aposentá-los. Documente como descontinuar e remover um módulo com segurança.
Um monorepo bem governado permite que os times trabalhem de forma autônoma, ao mesmo tempo em que se beneficiam de código e infraestrutura compartilhados. Não se trata de adicionar mais burocracia, mas de criar a estrutura necessária para escalar o desenvolvimento de um jeito eficiente.