Como funciona um monorepo em JavaScript

Como funciona um monorepo em JavaScript

Monorepo é uma solução para problemas de estrutura e manutenção de um projeto, normalmente quando o mesmo pode ser fragmentado em projetos menores por conta de tamanho e complexidade. Mas, também pode ser pela simples divisão em partes menores, chamadas de pacotes. Por exemplo, Babel é uma das libs populares que é estruturada em monorepo.
Este artigo explica o funcionamento de um projeto nessa estrutura. Se monorepo parece ser confuso até agora, então continue aqui comigo. Meu objetivo é descomplicar o assunto para que você consiga entender o tema sem precisar conhecer muito sobre JavaScript e também como isso é resolvido com Lerna e Yarn Workspaces.

Diferença de um repositório comum para um monorepo

Em um monorepo, as funcionalidades são mais divididas e não são apenas importadas de um arquivo para outro. Como obter esse encapsulamento varia entre cada linguagem.
No JavaScript isso pode ser obtido da seguinte maneira: fazer com que um arquivo importe outro como se ele fosse uma dependência publicado npm — mas, na verdade, ele está dentro do próprio repositório.
Para explicar o monorepo no JavaScript vamos usar um exemplo. Considere esse repositório com funções de multiplicar e potência; queremos convertê-lo para monorepo:
 
// index.js
function multiply(a, b) {
  return a * b;
}

function power(base, exponent) {
  var result = base;

  for (var i = 1; i < exponent; i++) {
    result = multiply(base, result);
  }

  return result;
}

multiply(2, 3) // 6
power(2, 3) // 8
index.js
 
// package.json
{
  "name": "monorepo"
}
package.json
 
$ tree
.
├── index.js
└── package.json
Terminal
 
Mas antes, vamos entender um conceito importante: node_modules.

Como é um pacote na node_modules?

O diretório node_modules em qualquer projeto front-end contém os pacotes de dependências. Eles são gerenciados pelo npm ou yarn (normalmente não fazemos alterações manuais nesse diretório).
Um pacote na node_modules precisa desses dois arquivos, pelo menos:
  • index.js, com o código JavaScript
  • package.json com o campo main apontando para o index.js (pode ser outro nome, mas por padrão é usado index.js)

Criando um pacote na node_modules

Para usar um pacote próprio, não é necessário publicar no npm e instalar com o comando npm install. Se criarmos manualmente na node_modules um diretório, a importação dessa dependência é igual:
 
var multiply = require('multiply')

function power(base, exponent) {
  var result = base;

  for (var i = 1; i < exponent; i++) {
    result = multiply(base, result);
  }

  return result;
}

multiply(2, 3) // 6
power(2, 3) // 8
index.js
 
function multiply(a, b) {
  return a * b;
}

module.exports = multiply;
node_modules/multiply/index.js
 
{
  "main": "index.js" 
}
node_modules/multiply/package.json
 
{
  "name": "monorepo"
}
package.json
 
$ tree
.
├── index.js
├── node_modules
│   └── multiply
│       ├── index.js
│       └── package.json
└── package.json
Terminal
 
Apesar de funcionar, não é viável fazer isso no projeto pois:
  • Normalmente ignoramos o diretório node_modules, não atualizando no git
  • Pouca visibilidade que existe algo do projeto lá
Há uma maneira de manter o código do pacote fora da node_modules, mas ainda como se estivesse lá, que é usando um link simbólico.

Link simbólico

Esse link é interpretado pelo sistema operacional. Ele aponta um diretório para outro (ou arquivo, também funciona), sendo que os dois têm o mesmo conteúdo no final.
A partir do resultado anterior, podemos mover o pacote para fora da node_modules e criar um link simbólico:
 
$ mv node_modules/multiply ./multiply
$ ln -s ../multiply node_modules/multiply
$ tree 
.
├── index.js
├── multiply
│   ├── multiply.js
│   └── package.json
├── node_modules
│   └── multiply -> ../multiply
└── package.json
Terminal
 
Ainda há problemas nessa abordagem:
  • O link deve ser criado em cada clone novo do repositório;
  • Não é visível que existe um pacote disponível para importar, como por exemplo as dependências registradas no package.json

Instalando um pacote local com npm

É possível atingir o mesmo resultado com o próprio npm. Porém, precisamos adicionar alguns campos no package.json, o name e version. Assim, o pacote local fica mais próximo do que seria a um pacote publicado no npm.
 
{
  "name": "multiply",
  "main": "multiply.js",
  "version": "1.0.0"
}
multiply/package.json
 
{
  "name": "monorepo",
  "dependencies": {
    "multiply": "file:multiply"
  }
}
package.json
 
$ npm install multiply
npm WARN monorepo@ No description
npm WARN monorepo@ No repository field.
npm WARN monorepo@ No license field.

+ multiply@1.0.0
added 1 package and audited 1 package in 0.494s
found 0 vulnerabilities
$ tree
.
├── index.js
├── multiply
│   ├── index.js
│   └── package.json
├── node_modules
│   └── multiply -> ../multiply
├── package-lock.json
└── package.json
Terminal
Note que ele adiciona o pacote como dependência no package.json.
Por que não é instalado um pacote público no lugar, ao invés deste que é local? Isso acontece porque ao rodar o comando do npm, ele vê que já existe um diretório com o mesmo nome. A presença do package.json lá dentro identifica como sendo um pacote. Então o npm adiciona essa dependência com link simbólico.
Isso é só para o npm, mas há um equivalente no yarn. Com yarn link conseguimos um resultado similar, com poucas diferenças. O diretório fica disponível para ser "linkado" em qualquer repositório. O package.json não é atualizado, pois não há instalação neste caso. Para a finalidade desejada, yarn link não é uma boa opção.
Logo, instalando pacotes locais com npm resolve os problemas de antes. Então por que usar Lerna ou Yarn Workspaces?

Lerna

No exemplo citado, temos apenas um pacote no repositório. E se tivéssemos vários, como controlar tudo isso? E se eles forem publicados no npm, como fica a dependência entre eles? E se eu modificar um pacote, como eu sei quais outros pacotes dependem dele para atualizar também?
Essas e outras dificuldades surgem em projetos maiores. O objetivo do Lerna é otimizar o fluxo do gerenciamento do monorepo. Por exemplo:
  • Configuração de quais pacotes e onde estão localizados, de forma explícita;
  • Versionar cada pacote com versão diferente ou todos na mesma versão;
  • Rodar comandos em todos os pacotes, como instalação de dependências e build;
  • Publicar pacotes no npm e criar releases no git para apenas os pacotes modificados (com um comando só!).
Na prática, o Lerna usa exatamente a mesma solução de links simbólicos. A diferença é que essas, e algumas outras funcionalidades, facilitam o gerenciamento do monorepo.
Por que não usar Lerna?
Em minha experiência, encontrei apenas um motivo para não usar Lerna. Há uma limitação ao publicar pacotes pelo CI.
O comando lerna publish, além de outras funcionalidades, é responsável por:
  • Identificar os pacotes que precisam ser atualizados;
  • Permitir a seleção de quais pacotes serão publicados;
  • Atualizar seus dependentes — eles também são afetados.
Porém, isso é de forma interativa no comando (não roda em deploy automatizado). Não há como passar parâmetros para escolher os pacotes. Logo, cada pacote alterado é selecionado para receber uma atualização de versão igual: majorminor ou patch (ver Semantic Versioning). Isso não é um problema se:
  • A mudança for patch;
  • Todos os pacotes acompanham a mesma versão;
  • Ou o comando de publicar não roda em CI (sempre usa modo interativo).
Um caso em que isso seria um problema: apliquei uma correção em um pacote (patch), mas ainda não publiquei. Atualizei a API de outro pacote (major). Ao publicar, ambos devem ser atualizados na versão como major.
Para evitar isso, uma alternativa é implementar um script próprio de publish, mapeando as dependências assim como Lerna faz. Veja mais sobre essa issue no GitHub do Lerna.

Yarn Workspaces

Outra solução de monorepo, mas muito diferente da proposta do Lerna. O objetivo não é melhorar a facilidade do gerenciamento. Além de fazer links simbólicos dos pacotes, o Yarn Workspaces organiza melhor as dependências — ou seja, o benefício é instalar dependências mais rápido.
É normal que vários pacotes usem dependências como ESlint, TypeScript e Jest. O que o Yarn Workspaces faz é instalar as dependências comuns na node_modules raiz do projeto, e não em cada pacote, incluindo os próprios pacotes locais do projeto com link simbólico, resultando em um monorepo.
Isso funciona devido ao comportamento do Node. Quando um pacote é importado no código, ele não tenta buscar esse pacote apenas na node_modules. Caso não encontra, volta um diretório e tenta achar o pacote em outra node_modules. Se não achar, continua voltando até encontrar. Em um monorepo, voltando dois níveis existe uma node_modules da raiz do projeto. No Yarn Workspaces, lá se encontra os links simbólicos e pacotes em comum no projeto (veja aqui um post explicando mais sobre o seu funcionamento).
 
Por que não usar Yarn Workspaces?
Há algumas desvantagens. A primeira e mais óbvia é que o projeto precisa usar yarn, ao invés de npm. Outra é um risco de quebrar o projeto sem perceber.
Por exemplo, toda dependência na node_modules da raiz do projeto pode ser importada no código de um pacote, mesmo se ela não estiver listada em seu package.json. Isso leva ao problema em que, na sua instância local do repositório tudo funciona, mas no CI, ou outro clone do projeto, isso pode quebrar.
O problema é mais sutil ainda se a dependência estiver listada no package.json, mas for outra versão. Podendo ocorrer problemas complicados de debugar.

Lerna + Yarn Workspaces

Como eles têm propósitos diferentes, é possível usar as duas soluções ao mesmo tempo. O resultado é um monorepo mais organizado e rápido de se trabalhar. Na usabilidade, a diferença fica apenas no momento de instalação inicial das dependências.
Para ver mais sobre o uso do Yarn Workspaces, leia esta documentação.

Conclusão

Monorepo em projetos JavaScript são links simbólicos entre os pacotes. Isso permite serem importados como dependências, do mesmo jeito se fossem pacotes publicados no npm.
É muito comum usar Lerna em monorepo porque te dá mais facilidade e agilidade no gerenciamento do repositório.
Yarn Workspaces não vejo ser muito usado, se comparado com Lerna. Seu maior benefício está na velocidade de instalar dependências.
Se você tiver mais curiosidade de como funciona o monorepo, recomendo ver o site do Lerna: https://lerna.js.org/.