Monorepos com Turborepo: Estrutura, Cache e Deploy
O problema que monorepo resolve (e o que ele cria)
Dois repositórios compartilham tipos TypeScript. Alguém atualiza a interface User no repo A e esquece de propagar para o repo B. O deploy do repo B quebra às 17h de sexta. Esse cenário se repete com validações Zod, componentes de UI, configurações de ESLint e funções utilitárias. Monorepo elimina essa classe inteira de bugs: código compartilhado vive num lugar só, e qualquer mudança é visível para todos os consumidores no mesmo commit.
O custo? Builds ficam lentos, CI fica caro e deploys precisam de orquestração. Turborepo existe para atacar exatamente esses três problemas.
Estrutura de pastas que escala
A convenção do Turborepo separa apps/ (coisas que fazem deploy) de packages/ (coisas que são importadas). Parece simples, mas a decisão de o que vira pacote e o que fica dentro de uma app define a manutenibilidade do repositório inteiro.
# Estrutura base de um monorepo com Turborepo
monorepo/
├── apps/
│ ├── web/ # Next.js, faz deploy na Vercel
│ │ ├── package.json
│ │ └── next.config.js
│ └── api/ # Express/Fastify, faz deploy no Fly.io
│ ├── package.json
│ └── src/
├── packages/
│ ├── ui/ # Componentes React compartilhados
│ │ ├── package.json
│ │ └── src/
│ ├── shared-types/ # Tipos TypeScript, zero runtime
│ │ ├── package.json
│ │ └── src/
│ ├── validation/ # Schemas Zod usados por web e api
│ │ ├── package.json
│ │ └── src/
│ └── eslint-config/ # Configuração ESLint centralizada
│ └── package.json
├── turbo.json
├── package.json # Workspace root
└── pnpm-workspace.yaml
O pnpm-workspace.yaml declara onde estão os pacotes:
# pnpm-workspace.yaml
# Turborepo funciona com npm, yarn e pnpm.
# pnpm é a escolha mais comum porque workspaces
# são cidadãos de primeira classe e o hoisting
# é mais previsível com node_modules isolados.
packages:
- "apps/*"
- "packages/*"
O package.json raiz não tem dependências de produção. Ele serve como ponto de entrada para scripts e define o gerenciador de pacotes:
{
"name": "monorepo",
"private": true,
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"test": "turbo run test",
"clean": "turbo run clean"
},
"devDependencies": {
"turbo": "^2.5.0"
},
"packageManager": "pnpm@9.15.0"
}
Configuração do pipeline no turbo.json
O turbo.json é onde você declara o grafo de dependências entre tasks. Turborepo lê isso para saber o que pode rodar em paralelo e o que precisa esperar.
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": [
"**/.env.*local"
],
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "tsconfig.json", "package.json"],
"outputs": ["dist/**", ".next/**"],
"env": ["NODE_ENV", "DATABASE_URL"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"],
"inputs": ["src/**", ".eslintrc.*", "tsconfig.json"]
},
"test": {
"dependsOn": ["^build"],
"inputs": ["src/**", "tests/**", "vitest.config.*"]
},
"clean": {
"cache": false
}
}
}
Três detalhes que passam despercebidos:
-
O
^buildnodependsOnsignifica "build dos pacotes que eu importo, não o meu próprio build". Sem o^, Turborepo tentaria rodar o build do próprio pacote como dependência de si mesmo. -
inputsrestringe quais arquivos invalidam o cache. Se você não declarainputs, qualquer mudança em qualquer arquivo do pacote invalida tudo. Declararinputsé a diferença entre cache hit de 95% e cache hit de 40%. -
envlista variáveis de ambiente que afetam o output. SeDATABASE_URLmuda, o build precisa rodar de novo. Esquecer uma variável aqui causa bugs silenciosos: o cache serve um build antigo com a URL antiga.
Leia o artigo completo em https://www.vivodecodigo.com.br/infra/monorepos-turborepo-estrutura-cache-deploy