Monorepo NestJS com pnpm workspaces: como estruturei o dev experience para múltiplos serviços
Sumário
Quando o Sovereign Architect começou a ter mais de um microsserviço, a pergunta foi direta: onde ficam os tipos compartilhados? Os contratos de evento? Os helpers de SQS e SNS?
Publicar packages no npm para uso interno é burocracia desnecessária. Manter cópias em cada serviço é receita para divergência. A resposta óbvia é monorepo, e a pergunta que realmente importa é qual tooling usar.
Por que pnpm workspaces e não Nx ou Turborepo
Nx e Turborepo resolvem problemas de escala: cache distribuído de build, task graph inteligente, dependency boundaries. Para um projeto com oito serviços e três packages internos, esse overhead não faz sentido.
O pnpm workspaces resolve o que importa: resolução de dependências locais via symlink, --filter para rodar scripts em workspaces específicos e hoisting de dependências externas. A configuração inteira é um arquivo com três linhas:
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
Cada serviço declara os packages internos com workspace:*:
{
"dependencies": {
"@sovereign/shared-utils": "workspace:*",
"@sovereign/shared-events": "workspace:*"
}
}
O pnpm resolve isso como symlink. Mudanças em packages/shared-utils/src/ ficam imediatamente disponíveis para quem depende do package — sem publicar, sem npm link.
O detalhe que merece atenção no setup de dev
Em produção, cada package aponta para seu build compilado:
// packages/shared-utils/package.json
{ "main": "./dist/index.js" }
Isso é o comportamento correto. O problema é que em dev, quando o serviço sobe com ts-node e node --watch, o Node carrega o dist/index.js. O --watch monitora os arquivos carregados, e o que foi carregado é o .js compilado. Editar o .ts source não dispara reload.
A solução é manter o package.json dos packages intocado e criar um tsconfig.dev.json em cada serviço que redireciona os imports para o source diretamente:
// apps/auth-service/tsconfig.dev.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"rootDir": "../../",
"paths": {
"@sovereign/shared-utils": ["../../packages/shared-utils/src/index.ts"],
"@sovereign/shared-events": ["../../packages/shared-events/src/index.ts"],
"@sovereign/shared-types": ["../../packages/shared-types/src/index.ts"]
}
},
"include": ["src/**/*", "../../packages/*/src/**/*"]
}
O script dev usa -r tsconfig-paths/register para aplicar esses redirects em runtime, e --watch-path nos diretórios dos packages para o Node monitorar as mudanças:
TS_NODE_PROJECT=tsconfig.dev.json node \
--watch \
--watch-path=./src \
--watch-path=../../packages/shared-utils/src \
--watch-path=../../packages/shared-events/src \
-r ts-node/register \
-r tsconfig-paths/register \
src/main.ts
Em produção, tsconfig.dev.json não existe no processo. O build usa tsconfig.json normal, compila os packages para dist/ e os serviços apontam para lá. Os dois contextos ficam completamente separados.
Por que ts-node e não tsx
O tsx usa esbuild internamente, que é mais rápido. Mas o NestJS depende de emitDecoratorMetadata: true para que os decorators (@Injectable, @Controller, @Get) funcionem corretamente. Essa feature é específica do compilador TypeScript e não existe no esbuild. ts-node com o compilador real é a única opção compatível com NestJS.
Regra de import nos packages internos
Com ts-node carregando os .ts source diretamente, imports com extensão .js quebram:
// ✅ funciona em dev e em produção
export { SqsConsumer } from "./sqs.consumer";
// ❌ quebra em dev — o arquivo real é .ts, não .js
export { SqsConsumer } from "./sqs.consumer.js";
Imports internos dentro de packages/ nunca usam extensão. O Node CommonJS resolve para .js em produção, o ts-node resolve para .ts em dev.
Ordem de build
A dependência entre packages define a ordem de compilação:
# 1. shared-types (sem dependências internas)
# 2. shared-events (depende de shared-types)
# 3. shared-utils (depende de shared-events)
# 4. apps/* (dependem dos três)
pnpm --recursive --filter './packages/**' build
pnpm --recursive --filter './apps/**' build
O script build na raiz já faz isso na ordem correta. Em CI, é o primeiro comando antes de qualquer teste.
Se você se interessou pela parte de decisões de arquitetura, vale ler também Construindo uma Arquitetura Robusta — cobre os pilares de configurabilidade e extensibilidade que guiam essas escolhas.
Este post faz parte da série Sovereign Architect, onde documento a construção de um SaaS completo usando AWS, NestJS e TypeScript.