Gabriel Caiana
imagem principal do site

Monorepo NestJS com pnpm workspaces: como estruturei o dev experience para múltiplos serviços


Sumário
  1. Por que pnpm workspaces e não Nx ou Turborepo
  2. O detalhe que merece atenção no setup de dev
  3. Por que ts-node e não tsx
  4. Regra de import nos packages internos
  5. Ordem de build

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.