ADR 0002: Monorepo Tooling — pnpm Workspaces + Turborepo
| Field | Value |
|---|---|
| Status | Accepted |
| Date | Pre-alpha |
| Deciders | Project maintainers |
| Supersedes | — |
| Superseded by | — |
| Related ADRs | 0001 (Runtime + language), 0004 (Distribution) |
Summary
aidokit uses pnpm workspaces for package management and Turborepo for build orchestration. The repository is a single monorepo containing ~10 packages at v0.1 growing to ~15 packages by v1.0. Both tools are pinned via the packageManager field. No other monorepo solution (Nx, Moon, Bazel, Lerna, Bun workspaces, raw npm workspaces) was deemed appropriate.
Context
aidokit ships as a family of packages with strong interdependencies:
@aidokit/cli ─┐
@aidokit/core ─┼─ tightly coupled; cross-package type imports
@aidokit/shared-docs ─┘
@aidokit/adapter-claude-code ┐
@aidokit/adapter-codex ┴─ each depends on @aidokit/core
@aidokit/stack-pack-node-ts ┐
@aidokit/stack-pack-python ┼─ each depends on @aidokit/core
@aidokit/stack-pack-react ┘
@aidokit/stack-detect ┐
@aidokit/prereq-check ┼─ utility packages
@aidokit/mcp-catalog ┘
The monorepo needs to:
- Link packages locally so a change in
@aidokit/coreis immediately visible to@aidokit/cliwithout publishing - Order builds topologically —
@aidokit/corebuilds before@aidokit/cli - Cache build outputs — re-building unchanged packages wastes CI time and developer time
- Parallelize where safe — independent packages (e.g., two stack packs) can build concurrently
- Manage one lockfile — a single source of truth for every dependency version
- Catch phantom dependencies — code in
@aidokit/cliaccidentally importing from@aidokit/core's transitive deps should fail at install, not at runtime in production - Stay simple enough for solo or small-team development; not require a dedicated build engineer
- Publish individual packages to npm with proper semver and tags
Beyond technical needs, the team must be able to onboard a contributor in under an hour. Heavy build systems (Bazel, Nx with all plugins) work against this.
Decision
Package manager: pnpm 9.x or later
pnpmis the package manager for the entire monorepo- Workspaces declared in
pnpm-workspace.yaml(packages: ['packages/*']) - Cross-package dependencies use the
workspace:*protocol - Strict
node_modules(pnpm's default) — phantom dependencies fail at install packageManagerfield in rootpackage.jsonpins the version
Build orchestrator: Turborepo 2.x or later
turbo.jsondefines the task pipeline (build,test,lint,typecheck,dev)- Local cache enabled by default (
.turbo/gitignored) - Remote cache opt-in only (no auto-enrollment; respects the "no telemetry" principle in ADR 0011)
- Tasks declare their inputs and outputs explicitly
- Topological ordering and parallelism handled automatically
Repository layout
aidokit/
├── package.json ← workspace root, devDeps + scripts only
├── pnpm-workspace.yaml ← packages declared here
├── turbo.json ← pipeline definition
├── tsconfig.base.json ← shared TS config extended by packages
├── .npmrc ← pnpm-specific config
├── .changeset/ ← versioning + changelog tool (Changesets)
├── packages/
│ ├── cli/
│ ├── core/
│ ├── shared-docs/
│ ├── adapter-claude-code/
│ ├── adapter-codex/
│ ├── stack-detect/
│ ├── prereq-check/
│ ├── mcp-catalog/
│ ├── stack-pack-node-ts/
│ ├── stack-pack-python/
│ └── stack-pack-react/
├── examples/ ← example bootstrapped projects (used in CI)
└── scripts/ ← repo automation
Versioning and publishing
- Changesets (
@changesets/cli) manages package versioning and changelogs - Each package independently semvered (see ADR 0010)
pnpm publish -r --filter=...publishes only changed packages- CI publishes on
mainbranch tag creation
Consequences
Positive
- Disk-efficient installs: pnpm's content-addressable store means
node_modulesfor all packages is ~5–10x smaller than npm's flat install. Material for contributors with limited disk space. - Strict dependency hygiene: pnpm's symlinked
node_modulesmeans a package can only import what it declares inpackage.json. Phantom dependencies become impossible — a class of bugs eliminated entirely. - Fast install: pnpm's parallel network + hardlink-based install is consistently 2–3x faster than npm.
- Cache hits are dramatic: Turborepo's local cache makes "I just changed one package" rebuilds near-instant. CI cache hits make full PRs go from 8 minutes to 30 seconds.
- Workspace protocol means no version drift:
"@aidokit/core": "workspace:*"is automatically rewritten to a real semver during publish. Developers never deal with cross-package version pins. - Topological ordering is free: Turborepo figures out the build order from
dependsOndeclarations. Manual orchestration scripts become unnecessary. - Parallel test/lint/typecheck: Turbo runs these across packages concurrently, scaling with CPU.
packageManagerfield locks the version: Corepack picks up the exact pnpm version. New contributors don't accidentally use a different pnpm version.- Mature ecosystem: both tools are well-documented, widely adopted, and unlikely to disappear. pnpm has been the default choice for new monorepos since ~2022; Turborepo has Vercel backing.
- Cheap to swap if needed: if Turborepo ever falls behind, swapping to Nx or Moon is mostly a
turbo.jsonrewrite — the package structure stays the same.
Negative
- Two tools to learn instead of one: Nx bundles package management, build orchestration, and code generation into one CLI. We're using two specialized tools. Slight mental overhead.
- pnpm's strict node_modules can confuse legacy tools: a few older tools assume flat
node_modulesand fail with pnpm's symlinks. Mitigated by.npmrcwithnode-linker=isolated(default) ornode-linker=hoisted(rare escape hatch). - Turbo cache invalidation is heuristic: based on input file globs and env vars. Rarely wrong, but when wrong, debugging requires
--forceor--no-cache. - Remote cache requires Vercel account (or self-host): the free remote cache is Vercel-hosted. Self-hosting via
@turbo/remote-cacheworks but adds infra. For pre-1.0 we'll skip remote cache; local cache covers single-developer cases. - Changesets is a third tool to learn: versioning is non-trivial; Changesets handles it well but adds a workflow step (
pnpm changesetto record changes).
Neutral
- Setup boilerplate:
package.json,pnpm-workspace.yaml,turbo.json,tsconfig.base.json,.npmrc,.changeset/config.json— ~6 root-level files to maintain. One-time cost. - CI complexity: GitHub Actions needs to install pnpm via Corepack, run
pnpm install --frozen-lockfile, thenpnpm turbo run …. Standard pattern; not unique to us. - Lockfile size:
pnpm-lock.yamlgrows with package count. ~500 KB typical. Not a concern.
Alternatives considered
Alternative 1: npm workspaces (no build orchestrator)
Pros
- Zero extra tooling — npm is universal
- Familiar to anyone who's used Node
Cons
- No build caching —
npm run build --workspacesrebuilds everything every time - No phantom-dependency enforcement
- Slower installs (npm's algorithm is meaningfully slower than pnpm's)
- Cross-package linking via
npm install ../coreis fragile
Reason rejected: missing build caching alone makes this untenable at our scale. CI times would balloon.
Alternative 2: Yarn workspaces (Classic 1.x)
Pros
- Mature and well-known
- Workspaces work
Cons
- Effectively deprecated — Yarn 1.x is in maintenance mode
- Same caching gap as npm workspaces
- Slower than pnpm on installs
Reason rejected: deprecated; no migration path forward.
Alternative 3: Yarn Berry (Yarn 2+)
Pros
- Modern, fast
- Built-in PnP (Plug'n'Play) eliminates
node_modules
Cons
- PnP breaks compatibility with many tools that expect
node_modules - Smaller adoption than pnpm
- Configuration is more complex than pnpm
- No clear advantage over pnpm
Reason rejected: pnpm offers the same benefits with fewer compatibility surprises.
Alternative 4: Nx
Pros
- One-tool solution (package management, build, codegen, lint, test)
- Excellent caching, including remote
- Powerful plugin ecosystem for React, Node, etc.
- Affected-graph commands (
nx affected:test)
Cons
- Heavy and opinionated — Nx wants to own how you structure code
- Steep learning curve for contributors
- Plugin churn — staying current requires effort
- Overkill for ~15 packages
- "Nx workspace" patterns differ enough from "plain npm" that contributors need Nx-specific knowledge
Reason rejected: too much ceremony for a small open-source CLI project. We want to optimize for "any TypeScript developer can contribute without learning Nx first." Turborepo's simpler model wins here.
Alternative 5: Moon
Pros
- Modern, language-agnostic
- Excellent task orchestration
- Designed for polyglot monorepos
Cons
- Smaller community than Turborepo
- Less mature ecosystem
- Polyglot strength is wasted for a TS-only monorepo
Reason rejected: Turborepo solves the same problem with more community resources. If aidokit ever adds Python or Go packages alongside TS, revisit Moon.
Alternative 6: Bazel
Pros
- Industrial-scale build system
- Extremely accurate cache invalidation
- Used by Google, Spotify, others at massive scale
Cons
- Enormous complexity
- Bazel's
BUILDfiles are a separate DSL to learn - Designed for thousands of packages, not fifteen
- Steep contributor barrier — most JS/TS developers have never touched Bazel
Reason rejected: complete overkill. The complexity tax is prohibitive at our scale.
Alternative 7: Lerna
Pros
- The historical default for JS monorepos
- Familiar
Cons
- Maintenance status uncertain since 2022 (taken over by Nx)
- Now essentially a wrapper around Nx for new users
- No advantage over choosing pnpm + Turborepo directly
Reason rejected: effectively merged into Nx; no reason to use it standalone.
Alternative 8: Bun workspaces
Pros
- Very fast install and run
- Built-in test runner, bundler, etc.
Cons
- Locks us into Bun as the runtime — ADR 0001 rejected Bun as primary runtime for v1.0
- Smaller ecosystem
- Even fewer monorepos use Bun workspaces than pnpm
- Build orchestration story (
bun --filter) is newer and less battle-tested than Turborepo
Reason rejected for v1.0: ADR 0001 puts Bun on the post-1.0 revisit list; the monorepo tooling choice should follow the runtime choice.
Alternative 9: Custom scripts (no monorepo tool)
Pros
- Zero dependencies beyond Node + TS
- Total control
Cons
- No caching (or you reimplement it badly)
- No topological build ordering (or you reimplement it badly)
- Doesn't scale beyond 3–4 packages
- Becomes a maintenance burden
Reason rejected: reinventing what Turborepo gives us for free.
Implementation notes
Root package.json
{
"name": "aidokit",
"private": true,
"version": "0.0.0",
"type": "module",
"packageManager": "pnpm@9.x.x",
"engines": {
"node": ">=20.0.0",
"pnpm": ">=9.0.0"
},
"scripts": {
"build": "turbo run build",
"test": "turbo run test",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck",
"dev": "turbo run dev",
"clean": "turbo run clean && rm -rf node_modules .turbo",
"changeset": "changeset",
"changeset:version": "changeset version",
"changeset:publish": "pnpm build && changeset publish"
},
"devDependencies": {
"@changesets/cli": "^2.x",
"turbo": "^2.x",
"typescript": "^5.4",
"vitest": "^1.x"
}
}
pnpm-workspace.yaml
packages:
- 'packages/*'
turbo.json
{
"$schema": "https://turbo.build/schema.json",
"ui": "tui",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "tsconfig.json", "package.json"],
"outputs": ["dist/**"]
},
"test": {
"dependsOn": ["build"],
"inputs": ["src/**", "test/**", "tsconfig.json"],
"outputs": []
},
"lint": {
"inputs": ["src/**", "test/**", ".eslintrc.*"],
"outputs": []
},
"typecheck": {
"dependsOn": ["^build"],
"inputs": ["src/**", "tsconfig.json"],
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
},
"clean": {
"cache": false
}
}
}
.npmrc
# Default: isolated (pnpm's strict mode)
node-linker=isolated
# Use pnpm's lockfile only
package-lock=false
# Don't auto-install peer deps; surface them so we catch issues
auto-install-peers=false
# Strict resolution
strict-peer-dependencies=true
# Save exact versions (no carets)
save-exact=true
Package package.json template
{
"name": "@aidokit/<package>",
"version": "0.1.0",
"type": "module",
"engines": { "node": ">=20.0.0" },
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "README.md", "CHANGELOG.md"],
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"lint": "eslint src test",
"typecheck": "tsc --noEmit",
"dev": "tsc -p tsconfig.json --watch",
"clean": "rm -rf dist .turbo"
},
"dependencies": {
"@aidokit/core": "workspace:*"
}
}
Cross-package dependencies
Always use workspace:* for internal packages:
"dependencies": {
"@aidokit/core": "workspace:*",
"@aidokit/shared-docs": "workspace:*"
}
On publish, Changesets/pnpm rewrites workspace:* to the actual semver of the published package version.
Gitignore additions
node_modules/
.turbo/
dist/
*.tsbuildinfo
.changeset/*.md
!.changeset/README.md
CI workflow snippet (GitHub Actions)
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with: { version: 9 }
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run lint typecheck test build
Validation
This decision is validated when:
- [ ]
pnpm installcompletes in under 60 seconds from a clean clone on a typical developer machine - [ ]
pnpm turbo run buildcompletes in under 30 seconds on cache hit, under 90 seconds cold - [ ] CI runs all of (
lint,typecheck,test,build) in under 5 minutes for a PR touching a single package (due to cache) - [ ] A new contributor can clone, install, build, and run tests in under 10 minutes from the README instructions
- [ ] First successful
pnpm publish -r --filter=@aidokit/core(Phase 9)
References
- pnpm workspaces: https://pnpm.io/workspaces
- Turborepo: https://turbo.build/repo/docs
- Changesets: https://github.com/changesets/changesets
- Corepack (Node.js native): https://nodejs.org/api/corepack.html
- ADR 0001: Runtime and language (Node.js + TypeScript)
- ADR 0004: Distribution strategy (npm + npx)
- ADR 0010: Independent semver per package
ARCHITECTURE.md§3 — Repository layout