pnpm workspace boundaries

why

a workspace without an owned boundary becomes a path dependency by habit: callers import its internal source and test helpers directly, and the owner can no longer refactor or move a file without tracing hidden cross-package uses.

rule

use pnpm workspaces to make package ownership explicit. a workspace exists only when it has an independently understandable runtime, build, contract, or tooling responsibility — and owns a versioned public export.

a convenience wrapper promoted to a workspace without a clear boundary invites callers to import its src and test helpers as a direct path dependency. keep one-off helpers inside the consuming package; inline only when truly necessary.

workflow

  1. add the package pattern to pnpm-workspace.yaml.
  2. give the package a scoped name and a narrow package.json.
  3. reference sibling packages with workspace:*.
  4. run its lifecycle through pnpm --filter <package> <task>.

workspace admission test

a workspace is a durable package boundary, not a folder that happens to have a package.json. admit one only when it owns a public contract, independently deployable runtime, generated surface, shared tooling boundary, or build output that another package can consume without importing its source.

candidatedecisionreason
shared domain schemas/query-key factoriesworkspace packageseveral runtimes consume one canonical source.
generator-owned client outputworkspace packagegeneration and public exports need one owner.
deployable applicationworkspace packageits runtime, environment, build, and deploy command are distinct.
one local helper used by one packagestay in ownera package would add indirection without a public boundary.
copied contract used by two applicationsextract canonical workspaceduplicate types drift even when each app typechecks.

dependency decision

use workspace:* only for a sibling's declared public API. a relative path is for nearby files inside one package; it is not a way to consume a sibling's src, test helpers, or generated internals. the package export map and build surface define what a consumer may know.

verification

from the consumer package, resolve and typecheck only the sibling's public import. then remove or rename an internal source path in a disposable change: no consumer should rely on it. run the owner lifecycle with pnpm --filter and the root graph to confirm both use the same package boundary.

do not

  • create a workspace for a one-file convenience wrapper.
  • reach into another package's source through a relative path.
  • duplicate a contract instead of owning it in the shared package.

good and bad

bad

apps/
web/
src/
schemas/
InvoiceSchema.ts // copied from app-admin
utils/
getInvoiceTotal.ts
pages/
invoices.tsx
admin/
src/
schemas/
InvoiceSchema.ts // copied into web, now diverges
utils/
getInvoiceTotal.ts
pages/
invoicing.tsx
mobile/
src/
api/
client.ts
// imports('../../admin/src/utils/getInvoiceTotal') - relative path escape

web copies the invoice schema but adds a new field. admin refactors InvoiceSchema but web's copy never updates. mobile reaches into admin's internal utilities; when admin moves files, mobile breaks.

good

packages/
base/
src/
index.ts
export { InvoiceSchema, getInvoiceTotal } from './finance.js';
package.json
apps/
web/
package.json
"dependencies": { "@scope/base": "workspace:*" }
src/
pages/
invoices.tsx
import { InvoiceSchema } from '@scope/base';
admin/
package.json
"dependencies": { "@scope/base": "workspace:*" }
src/
pages/
invoicing.tsx
import { InvoiceSchema } from '@scope/base';
mobile/
package.json
"dependencies": { "@scope/base": "workspace:*" }
src/
api/
client.ts
import { getInvoiceTotal } from '@scope/base';

one @scope/base workspace exports the canonical schema and shared helpers. each application imports through the declared public entrypoint. the base owner can refactor internals freely because no consumer reaches into src/; a breaking change to the public export fails every consumer's typecheck in one edit.

search pages

go to any page