Skip to content

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 past exports, at build and at runtime.
  • No tsconfig paths alias a workspace package. Resolution goes through pnpm’s symlink and this exports field, 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

  • domain has 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.
  • contracts depends on @app/domain only (and effect), so it stays a leaf above the domain.
  • scripts/workspace-graph.ts reads the package.json files and checks all three. It also checks one manifest rule owned by monorepo tooling: a dependency with a catalog entry is referenced as catalog:. Crossing a boundary means editing a package.json, a visible and reviewable act.

❌ Incorrect — acyclic, and still leaky:

@app/domain -> @app/integrations ✗ domain depends on infrastructure
apps/web -> apps/backend ✗ an app used as a package

✅ Correct — domain at the bottom, apps on top:

@app/domain -> (nothing)
@app/contracts -> @app/domain
apps/server -> @app/domain @app/contracts @app/db

Source: notes/01-code-organization/workspace-boundaries.md · Decision 3 (amended)

  • An explicit exports allowlist 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.