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
- add the package pattern to
pnpm-workspace.yaml. - give the package a scoped name and a narrow
package.json. - reference sibling packages with
workspace:*. - 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.
| candidate | decision | reason |
|---|---|---|
| shared domain schemas/query-key factories | workspace package | several runtimes consume one canonical source. |
| generator-owned client output | workspace package | generation and public exports need one owner. |
| deployable application | workspace package | its runtime, environment, build, and deploy command are distinct. |
| one local helper used by one package | stay in owner | a package would add indirection without a public boundary. |
| copied contract used by two applications | extract canonical workspace | duplicate 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-adminutils/getInvoiceTotal.tspages/invoices.tsxadmin/src/schemas/InvoiceSchema.ts // copied into web, now divergesutils/getInvoiceTotal.tspages/invoicing.tsxmobile/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.tsexport { InvoiceSchema, getInvoiceTotal } from './finance.js';package.jsonapps/web/package.json"dependencies": { "@scope/base": "workspace:*" }src/pages/invoices.tsximport { InvoiceSchema } from '@scope/base';admin/package.json"dependencies": { "@scope/base": "workspace:*" }src/pages/invoicing.tsximport { InvoiceSchema } from '@scope/base';mobile/package.json"dependencies": { "@scope/base": "workspace:*" }src/api/client.tsimport { 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.