Workspace boundaries
A modular monolith has no deploy boundary to keep modules apart. This page answers what earns a package and what actually enforces the line between packages.
Give each package a one-line purpose, not a size limit
Section titled “Give each package a one-line purpose, not a size limit”Impact: MEDIUM stops one package becoming “everything shared”
- A package may be large or small. What it must have is a purpose you can state in one line without the word “and”, and a place in an acyclic graph.
- A file-count threshold predicts nothing: the median package is 13–17 files in clean and leaky repos alike. A 5-file package and a 33-file package can both be right.
- Acyclic is necessary and nowhere near sufficient. The real work is done by the graph invariants below.
❌ Incorrect — a size rule, and a package whose purpose needs “and”:
- Split a package when it passes 30 files.- `packages/common` — shared types and helpers and integrations.✅ Correct — one line, one purpose:
- `packages/domain` — the domain model. Depends on nothing internal.- `packages/contracts` — the wire contract. Depends on `@app/domain` only.Source: notes/01-code-organization/workspace-boundaries.md · Decision 1
Export through a wildcard on the package name
Section titled “Export through a wildcard on the package name”Impact: HIGH the resolver enforces the module boundary
- Every package is
"type": "module"and exposes its files through one wildcard. An import written with the package name cannot reach pastexports, at build and at runtime. - No tsconfig
pathsalias a workspace package. Resolution goes through pnpm’s symlink and thisexportsfield, the same path Node takes at runtime, so the declared dependency list stays the graph. - A curated allowlist taxes every new shared file. Left unmaintained, it grows into dozens of subpaths that are a wildcard plus upkeep, with nothing actually private.
- Accepted cost: nothing inside a package is private. Switching one package to an allowlist is a per-package decision.
❌ Incorrect — an allowlist that stopped being one:
"exports": { ".": "./src/index.ts", "./principal": "./src/principal.ts", "./labeling/rule-set": "./src/labeling/rule-set.ts", "./labeling/rule-id": "./src/labeling/rule-id.ts" // … one entry per file, forever}✅ Correct — the package name is the boundary:
"type": "module","exports": { ".": "./src/index.ts", "./*": "./src/*.ts", "./package.json": "./package.json"}Source: notes/01-code-organization/workspace-boundaries.md · Decision 2 (amended)
Cross a package boundary only by package name
Section titled “Cross a package boundary only by package name”Impact: HIGH closes the one hole exports leaves
- A relative path that climbs out of its package never goes through
exports, so it still resolves. It is a lint error (no-restricted-imports) everywhere. - The package boundary applies between packages. Between features inside
apps/server, the boundary is lint too. See file structure and coupling & cohesion.
❌ Incorrect — a relative path that escapes the package and skips exports:
import * as Principal from "../../../packages/domain/src/principal.ts"✅ Correct — go through the package name:
import * as Principal from "@app/domain/principal"Source: notes/01-code-organization/workspace-boundaries.md · Decision 2 (amended)
Keep three graph invariants, checked by a script
Section titled “Keep three graph invariants, checked by a script”Impact: HIGH acyclic alone does not keep a graph clean
domainhas zero internal dependencies. If it seems to need one, that thing is part of the domain.- No app is ever a dependency. Apps sit on top and nobody depends on them.
contractsdepends on@app/domainonly (andeffect), so it stays a leaf above the domain.scripts/workspace-graph.tsreads thepackage.jsonfiles and checks all three. It also checks one manifest rule owned by monorepo tooling: a dependency with a catalog entry is referenced ascatalog:. Crossing a boundary means editing apackage.json, a visible and reviewable act.
❌ Incorrect — acyclic, and still leaky:
@app/domain -> @app/integrations ✗ domain depends on infrastructureapps/web -> apps/backend ✗ an app used as a package✅ Correct — domain at the bottom, apps on top:
@app/domain -> (nothing)@app/contracts -> @app/domainapps/server -> @app/domain @app/contracts @app/dbSource: notes/01-code-organization/workspace-boundaries.md · Decision 3 (amended)
Deferred
Section titled “Deferred”- An explicit
exportsallowlist for one package — trigger: a second package imports another’s internal path. - A package for code two apps share — trigger: a second app needs the same code.