Skip to content

Monorepo tooling

A monorepo needs a package manager, and it is tempting to stack a task runner and a build cache on top. This page answers which package manager and how it is configured, whether anything sits on top of it, and how shared versions and the workspace graph are kept honest.

Impact: HIGH under isolation the declared package.json list is the graph

  • All three runtime profiles use pnpm. On the Bun profile, Bun runs bin.ts and pnpm still installs. The Worker profile runs alchemy deploy through pnpm run.
  • No hoist or nodeLinker override, ever. Under pnpm an undeclared workspace import does not resolve, which the workspace-graph script relies on.
  • Pin pnpm with devEngines.packageManager; CI installs with --frozen-lockfile and caches the store keyed on pnpm-lock.yaml.
  • Accepted cost: installs are slower than bun’s, and a Bun-profile project has two tools.

❌ Incorrect — hoisting turns an undeclared import into a silent success:

# .npmrc / pnpm-workspace.yaml
nodeLinker: hoisted
shamefullyHoist: true

✅ Correct — isolation by default, the manager pinned:

pnpm-workspace.yaml
packages:
- apps/*
- packages/*
allowBuilds:
workerd: true
esbuild: true
"devEngines": { "packageManager": { "name": "pnpm", "version": "12.x.y", "onFail": "download" } }

Source: notes/11-repo-operations/monorepo-tooling.md · Decision 1

Impact: MEDIUM with source exports there is nothing to order and nothing to cache

  • Root scripts only. A per-package task is pnpm --filter <name> run <script>; a repo-wide one is pnpm -r run <script>.
  • No turbo.json, no pnpm tasks: block, no Vite+ run config. Runners exist to order packages that export dist/, and workspace packages here export source (see workspace boundaries).
  • No build cache: the setup action caches the pnpm store and nothing else. No affected-only runs either (see CI pipelines).
  • Accepted cost: the root tsc --noEmit grows with the code, and check re-runs everything.

❌ Incorrect — a runner and cache for a graph with no build edges:

turbo.json
{ "tasks": { "check": { "dependsOn": ["^build"], "outputs": [] } } }

✅ Correct — plain root scripts:

"scripts": {
"prepare": "effect-tsgo patch && git config core.hooksPath .githooks",
"check": "node scripts/workspace-graph.ts && node scripts/circular.ts && oxfmt --check && oxlint && tsc --noEmit",
"test": "TZ=America/New_York vitest run",
"setup": "node scripts/setup.ts"
}

Source: notes/11-repo-operations/monorepo-tooling.md · Decision 2, amended

Impact: HIGH an effect bump is one hunk in one file

  • One default catalog, no named catalogs. effect and its @effect/* siblings are in from day 1. Any other third-party dependency enters when a second workspace package declares it.
  • Once an entry exists, every reference is catalog:, including overrides and the root package.json. The graph script enforces it; catalogMode: prefer only shapes pnpm add.
  • Workspace packages reference each other as workspace:*, never through the catalog.

❌ Incorrect — the version repeated per package, so a missed file installs a second copy:

packages/domain/package.json
"dependencies": { "effect": "^4.0.3" }

✅ Correct — one catalog, referenced everywhere:

pnpm-workspace.yaml
catalogMode: prefer
catalog:
effect: 4.0.3
"@effect/platform-node": 4.0.3
"@effect/vitest": 4.0.3
"@effect/sql-pg": 4.0.3
packages/domain/package.json
"dependencies": { "effect": "catalog:" }

Source: notes/11-repo-operations/monorepo-tooling.md · Decision 3

Run the graph and cycle checks first inside check

Section titled “Run the graph and cycle checks first inside check”

Impact: HIGH an agent runs check, so check must be what CI runs

  • scripts/workspace-graph.ts checks four invariants: @app/domain depends on no workspace package; nothing depends on an app; @app/contracts depends only on @app/domain and effect; a catalogued dependency is referenced as catalog:.
  • scripts/circular.ts runs madge over apps/*/src and packages/*/src, skipping type imports.
  • scripts/ is a plain root folder, not a workspace package. Each script is run by node directly (Node 24 strips types; no tsx, no build).
  • Both take milliseconds and run first, so a graph violation fails before the typecheck. CI runs pnpm run check as one step, not separate ci:* steps.

❌ Incorrect — CI checks the graph, but the local check does not:

- run: pnpm run check
- run: pnpm run ci:workspace-graph
- run: pnpm run ci:circular

✅ Correct — one command, the same everywhere:

// scripts/workspace-graph.ts (excerpt)
for (const m of [read("package.json"), ...apps, ...packages]) {
for (const [dep, spec] of Object.entries(deps(m))) {
if (m.name === "@app/domain" && internal.has(dep)) errors.push(`1: @app/domain depends on ${dep}`)
if (appNames.has(dep)) errors.push(`2: ${m.name} depends on the app ${dep}`)
if (m.name === "@app/contracts" && !["@app/domain", "effect"].includes(dep)) errors.push(`3: @app/contracts depends on ${dep}`)
if (dep in catalog && spec !== "catalog:") errors.push(`4: ${m.name} pins ${dep}@${spec}; the catalog has it`)
}
}

Source: notes/11-repo-operations/monorepo-tooling.md · Decision 4

  • A task runner, starting with pnpm’s own tasks: graph before any new tool — trigger: the first workspace package whose exports point at build output another workspace package reads.
  • A build cache in the CI setup action, local or remote — trigger: the CI check job’s p50 exceeds 5 minutes over the last 20 runs on main. A shared cache must hash every environment variable a build inlines.