NestJS monorepo with pnpm workspaces: how I structured the dev experience for multiple services
Table of contents
When Sovereign Architect started having more than one microservice, the question was straightforward: where are the shared types? The event contracts? SQS and SNS helpers?
Publishing packages to npm for internal use is unnecessary bureaucracy. Keeping copies on each service is a recipe for disagreement. The obvious answer is monorepo, and the question that really matters is which tooling to use.
Why pnpm workspaces and not Nx or Turborepo
Nx and Turborepo solve scaling problems: distributed build cache, intelligent task graph, dependency boundaries. For a project with eight services and three internal packages, this overhead does not make sense.
pnpm workspaces solves what matters: local dependency resolution via symlink, --filter to run scripts in specific workspaces and external dependency hoisting. The entire configuration is a file with three lines:
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
Each service declares internal packages with workspace:*:
{
"dependencies": {
"@sovereign/shared-utils": "workspace:*",
"@sovereign/shared-events": "workspace:*"
}
}
pnpm resolves this as symlink. Changes to packages/shared-utils/src/ are immediately available to anyone who depends on the package — no publishing, no npm link.
The detail that deserves attention in the dev setup
In production, each package points to its compiled build:
// packages/shared-utils/package.json
{ "main": "./dist/index.js" }
This is correct behavior. The problem is that in dev, when the service goes up with ts-node and node --watch, the Node loads dist/index.js. --watch monitors the loaded files, and what was loaded is the compiled .js. Editing the .ts source does not trigger a reload.
The solution is to keep the package.json of the packages untouched and create a tsconfig.dev.json in each service that redirects the imports to the source directly:
// 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/**/*"]
}
The dev script uses -r tsconfig-paths/register to apply these redirects at runtime, and --watch-path in the package directories for Node to monitor changes:
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
In production, tsconfig.dev.json does not exist in the process. The build uses normal tsconfig.json, compiles the packages to dist/ and the services point there. The two contexts are completely separate.
Why ts-node and not tsx
tsx uses esbuild internally, which is faster. But NestJS depends on emitDecoratorMetadata: true for decorators (@Injectable, @Controller, @Get) to work correctly. This feature is specific to the TypeScript compiler and does not exist in esbuild. ts-node with the real compiler is the only option supported by NestJS.
Import rule in internal packages
With ts-node loading the .ts sources directly, imports with the .js extension break:
// ✅ works in dev and production
export { SqsConsumer } from "./sqs.consumer";
// ❌ breaks in dev — the real file is .ts, not .js
export { SqsConsumer } from "./sqs.consumer.js";
Internal imports within packages/ never use extensions. Node CommonJS resolves to .js in production, ts-node resolves to .ts in dev.
Build order
The dependency between packages defines the compilation order:
# 1. shared-types (no internal dependencies)
# 2. shared-events (depends on shared-types)
# 3. shared-utils (depends on shared-events)
# 4. apps/* (depend on all three)
pnpm --recursive --filter './packages/**' build
pnpm --recursive --filter './apps/**' build
The build script in the root already does this in the correct order. In CI, it is the first command before any test.
If you are interested in the architectural decisions part, it is also worth reading Building a Robust Architecture — it covers the pillars of configurability and extensibility that guide these choices.
This post is part of the Sovereign Architect series, where I document the construction of a complete SaaS using AWS, NestJS and TypeScript.