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.
Use pnpm, with its default isolation
Section titled “Use pnpm, with its default isolation”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.tsand pnpm still installs. The Worker profile runsalchemy deploythroughpnpm run. - No
hoistornodeLinkeroverride, 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-lockfileand caches the store keyed onpnpm-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.yamlnodeLinker: hoistedshamefullyHoist: true✅ Correct — isolation by default, the manager pinned:
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
Add no task runner and no build cache
Section titled “Add no task runner and no build cache”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 ispnpm -r run <script>. - No
turbo.json, no pnpmtasks:block, no Vite+runconfig. Runners exist to order packages that exportdist/, 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 --noEmitgrows with the code, andcheckre-runs everything.
❌ Incorrect — a runner and cache for a graph with no build edges:
{ "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
Keep shared versions in one catalog
Section titled “Keep shared versions in one catalog”Impact: HIGH an effect bump is one hunk in one file
- One default catalog, no named catalogs.
effectand 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:, includingoverridesand the rootpackage.json. The graph script enforces it;catalogMode: preferonly shapespnpm 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:
"dependencies": { "effect": "^4.0.3" }✅ Correct — one catalog, referenced everywhere:
catalogMode: prefercatalog: effect: 4.0.3 "@effect/platform-node": 4.0.3 "@effect/vitest": 4.0.3 "@effect/sql-pg": 4.0.3"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.tschecks four invariants:@app/domaindepends on no workspace package; nothing depends on an app;@app/contractsdepends only on@app/domainandeffect; a catalogued dependency is referenced ascatalog:.scripts/circular.tsruns madge overapps/*/srcandpackages/*/src, skipping type imports.scripts/is a plain root folder, not a workspace package. Each script is run bynodedirectly (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 checkas one step, not separateci:*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
Deferred
Section titled “Deferred”- A task runner, starting with pnpm’s own
tasks:graph before any new tool — trigger: the first workspace package whoseexportspoint at build output another workspace package reads. - A build cache in the CI setup action, local or remote — trigger: the CI
checkjob’s p50 exceeds 5 minutes over the last 20 runs onmain. A shared cache must hash every environment variable a build inlines.