TypeScript all-in-one: Monorepo com suas dores e ganhos
Neste artigo, não vou comparar monorepo e polyrepo, pois é tudo uma questão de filosofia. Em vez disso, vou me concentrar na experiência de construção e evolução e supor que você esteja familiarizado com o ecossistema JS/TS.
Introdução
Eu sempre tive o sonho de um monorepo.
Vi a abordagem monorepo enquanto trabalhava para o Airbnb, mas era apenas para o frontend. Com um amor profundo pelo ecossistema JavaScript e a “feliz” experiência de desenvolvimento TypeScript, comecei a alinhar o código frontend e backend na mesma linguagem há cerca de três anos. Foi ótimo (para contratação) mas não tão ótimo para desenvolver já que nossos projetos ainda estavam dispersos em vários repos.
Como se diz, “a melhor maneira de refatorar um projeto é começar um novo”. Então, quando comecei minha startup cerca de um ano atrás, decidi usar uma estratégia total de monorepo: coloquei projetos frontend e backend, até os esquemas de banco de dados, em um único repo.
Neste artigo, não vou comparar monorepo e polyrepo, pois isso é uma questão de filosofia. Em vez disso, vou me concentrar na experiência de construção e evolução e supor que você esteja familiarizado com o ecossistema JS/TS.
O resultado final está disponível no GitHub.
Por que TypeScript?
Falando francamente, sou um fã de JavaScript e TypeScript. Eu amo a compatibilidade de sua flexibilidade e rigorosidade: você pode recorrer a unknown
ou any
(embora tenhamos banido qualquer forma de any
em nosso código), ou usar um conjunto de regras de lint super rigoroso para alinhar o estilo de código em toda a equipe.
Quando estávamos falando sobre o conceito de “fullstack” antes, normalmente imaginávamos pelo menos dois ecossistemas e linguagens de programação: um para frontend e um para backend.
Um dia, percebi que poderia ser mais simples: Node.js é rápido o suficiente (acredite em mim, na maioria dos casos, a qualidade do código é mais importante do que a velocidade de execução), TypeScript é maduro o suficiente (funciona bem em grandes projetos frontend), e o conceito de monorepo tem sido praticado por várias equipes famosas (React, Babel, etc.) - então por que não combinamos todo o código, do frontend ao backend? Isso pode fazer os engenheiros fazerem seus trabalhos sem mudança de contexto em um repositório e implementar um recurso completo em (quase) uma linguagem.
Escolhendo o gerenciador de pacotes
Como desenvolvedor, e como de costume, não via a hora de começar a codificar. Mas desta vez, as coisas foram diferentes.
A escolha do gerenciador de pacotes é crítica para a experiência de desenvolvimento em um monorepo.
A dor da inércia
Era julho de 2021. Comecei com [email protected]
pois o estava usando há muito tempo. Yarn era rápido, mas logo encontrei vários problemas com os Espaços de Trabalho Yarn. Por exemplo, não içar dependências corretamente, e toneladas de problemas são marcados com “corrigido no modern”, que me direciona para a v2 (berry).
“Ok, tudo bem, estou atualizando agora.” Parei de lutar com a v1 e comecei a migrar. Mas o longo guia de migração de berry me assustou, e desisti depois de várias tentativas fracassadas.
Simplesmente funciona
Então a pesquisa sobre gerenciadores de pacotes começou. Fiquei absorvido pelo pnpm
depois de um teste: rápido como o yarn, suporte nativo a monorepo, comandos semelhantes aos do npm
, hard links, etc. Mais importante, ele simplesmente funciona. Como desenvolvedor que quer começar com um produto, mas NÃO desenvolver um gerenciador de pacotes, eu queria apenas adicionar algumas dependências e iniciar o projeto sem saber como um gerenciador de pacotes funciona ou quaisquer outros conceitos sofisticados.
Com base na mesma ideia, escolhemos um velho amigo lerna
para executar comandos entre os pacotes e publicar pacotes de espaço de trabalho.
Definindo escopos de pacotes
É difícil determinar claramente o escopo final de cada pacote no início. Apenas comece com a sua melhor tentativa de acordo com o status quo, e lembre-se de que você sempre pode refatorar durante o desenvolvimento.
Nossa estrutura inicial continha quatro pacotes:
core
: o serviço monolito de backend.frases
: i18n chave → recursos de frases.esquemas
: o banco de dados e os esquemas TypeScript compartilhados.ui
: uma SPA da web que interage com ocore
.
Pilha tecnológica para fullstack
Como estamos abraçando o ecossistema JavaScript e usando TypeScript como nossa principal linguagem de programação, muitas opções são diretas (com base na minha preferência 😊):
koajs
para o serviço de backend (core): tive uma experiência difícil usandoasync/await
emexpress
, então decidi usar algo com suporte nativo.i18next/react-i18next
para i18n (frases/ui): gosto da simplicidade de suas APIs e do bom suporte TypeScript.react
para SPA (ui): apenas preferência pessoal.
E sobre esquemas?
Ainda falta algo aqui: sistema de banco de dados e mapeamento de esquema <-> definição TypeScript.
Geral v.s. operado
Naquele ponto, tentei duas abordagens populares:
- Use ORM com muitos decoradores.
- Use um construtor de consultas como Knex.js.
Mas ambos produziram uma sensação estranha durante o desenvolvimento anterior:
- Para ORM: não sou fã de decoradores, e outra camada abstrata do banco de dados causa mais esforço de aprendizado e incerteza para a equipe.
- Para construtor de consulta: é como escrever SQL com algumas restrições (de uma boa maneira), mas não é SQL real. Assim, precisamos usar
.raw()
para consultas em bruto em muitos cenários.
Então eu vi este artigo: “Pare de usar Knex.js: usar o construtor de consulta SQL é um anti-padrão”. O título parece agressivo, mas o conteúdo é ótimo. Ele fortemente me lembra que “SQL é uma linguagem de programação”, e eu percebi que podia escrever SQL diretamente (assim como CSS, como pude perder isso!) para aproveitar o idioma nativo e os recursos do banco de dados em vez de adicionar outra camada e reduzir o poder.
Em conclusão, decidi ficar com Postgres e Slonik (um cliente Postgres de código aberto), como o artigo afirma:
…o benefício de permitir ao usuário escolher entre os diferentes dialetos de banco de dados é marginal e o overhead de desenvolver para vários bancos de dados de uma vez é significativo.
SQL <-> TypeScript
Outra vantagem de escrever SQL é que podemos facilmente usá-lo como a única fonte de verdade das definições TypeScript. Escrevi um gerador de código para transpilar esquemas SQL para código TypeScript que usaremos em nosso backend, e o resultado não parece ruim:
Podemos até conectar jsonb
com um tipo TypeScript e processar a validação de tipo no serviço backend, se necessário.
Resultado
A estrutura de dependência final fica assim:
Você pode notar que é um diagrama unidirecional, o que nos ajudou muito a manter uma arquitetura clara e a capacidade de expansão à medida que o projeto cresce. Além disso, o código está (basicamente) todo em TypeScript.
Experiência de desenvolvimento
Compartilhamento de pacotes e configurações
Dependências internas
pnpm
e lerna
fazem um excelente trabalho nas dependências internas do espaço de trabalho. Usamos o comando abaixo na raiz do projeto para adicionar pacotes irmãos:
Isso adicionará @logto/schemas
como uma dependência para @logto/core
. Ao manter a versão semântica em package.json
de suas dependências internas, pnpm
também pode ligá-las corretamente em pnpm-lock.yaml
. O resultado ficará assim:
Compartilhamento de configurações
Tratamos cada pacote no monorepo como “independente”. Assim, podemos usar a abordagem padrão para compartilhamento de configurações, que cobre tsconfig
, eslintConfig
, prettier
, stlyelint
e jest-config
. Veja este projeto para um exemplo.
Código, lint e commit
Uso o VSCode para desenvolvimento diário, e, em resumo, nada é diferente quando o projeto é configurado corretamente:
- ESLint e Stylelint funcionam normalmente.
- Se você estiver usando o plugin VSCode ESLint, adicione as configurações do VSCode abaixo para fazer com que ele respeite a configuração ESLint por pacote (substitua o valor de
pattern
pelo seu próprio):
- Se você estiver usando o plugin VSCode ESLint, adicione as configurações do VSCode abaixo para fazer com que ele respeite a configuração ESLint por pacote (substitua o valor de
- husky, commitlint, e lint-staged funcionam como esperado.
Compiler e proxy
Estamos usando diferentes compiladores para frontend e backend: parceljs
para UI (React) e tsc
para todos os outros pacotes TypeScript puros. Recomendo fortemente que você experimente parceljs
se ainda não o fez. É um compilador “zero-config” real que lida graciosamente com diferentes tipos de arquivos.
Parcel hospeda seu próprio servidor de desenvolvimento frontend, e a produção de saída é apenas arquivos estáticos. Como gostaríamos de montar APIs e SPAs sob a mesma origem para evitar problemas de CORS, a estratégia abaixo funciona:
- No ambiente de desenvolvimento, use um simples proxy HTTP para redirecionar o tráfego para o servidor de desenvolvimento Parcel.
- No ambiente de produção, sirva arquivos estáticos diretamente.
Você pode encontrar a implementação da função de middleware frontend aqui.
Modo Watch
Temos um script dev
em package.json
para cada pacote que observa as alterações de arquivo e recompila quando necessário. Graças ao lerna
, as coisas ficam fáceis usando lerna exec
para executar scripts de pacotes em paralelo. O script raiz ficará assim:
Resumo
Idealmente, apenas dois passos para um novo engenheiro/contribuidor começar:
- Clone o repo
pnpm i && pnpm dev
Notas finais
Nossa equipe tem desenvolvido seguindo essa abordagem por um ano, e estamos muito felizes com ela. Visite nosso repo do GitHub para ver a forma mais recente do projeto. Para resumir:
Dores
- Precisa estar familiarizado com o ecossistema JS/TS
- Precisa escolher o gerenciador de pacotes certo
- Requer uma configuração adicional única
Ganhos
- Desenvolver e manter o projeto inteiro em um repositório
- Simplificou os requisitos de habilidades de codificação
- Compartilhou estilos de código, esquemas, frases e utilitários
- Melhorou a eficiência da comunicação
- Chega de perguntas como: Qual é a definição da API?
- Todos os engenheiros estão falando na mesma linguagem de programação
- CI/CD com facilidade
- Use a mesma cadeia de ferramentas para construção, testes e publicação
Este artigo deixa vários tópicos sem cobertura: Configurando o repo do zero, adicionando um novo pacote, aproveitando as Ações do GitHub para CI/CD, etc. Seria muito longo para este artigo se eu expandisse cada um deles. Sinta-se à vontade para comentar e me deixar saber qual tópico você gostaria de ver no futuro.