On this page 4 sections
Every few months someone on our team proposes reorganising the monorepo. I understand the impulse. I have also watched two reorganisations cost a month each and leave us roughly where we started, so this post is partly a record of what we keep, and why.
The repo holds 4 deployable apps, 31 shared packages and 5 internal tools, all TypeScript, managed with pnpm workspaces. CI runs in about six minutes for a typical pull request. Here is the layout and the handful of rules that keep it that way.
Three folders, and what goes in each
apps/holds things that get deployed: the web app, the public API, the worker and the admin console. Apps may depend on packages. Nothing depends on an app.packages/holds libraries shared by more than one app: the database client, the design system, auth, the event schemas. Packages may depend on other packages.tools/holds scripts and CLIs that run on developer machines or in CI: code generators, the migration runner, the release script. Apps never import from tools.
That is the whole taxonomy. We tried a libs/ versus packages/ split once, and a features/ folder grouped by product area. Both created long arguments about where new code belonged and no measurable benefit. Three folders with a dependency direction is enough, and the whole of pnpm-workspace.yaml is three globs:
packages:
- "apps/*"
- "packages/*"
- "tools/*"One rule we enforce in CI
The dependency direction is the only structural rule, and we check it rather than trusting reviews. A short script reads every package.json, builds the graph, and fails if an app is depended on, if a package imports from apps/ or tools/, or if there is a cycle. It runs in under a second and has caught 23 violations, every one of them accidental.
We also require each package to declare its dependencies explicitly. pnpm’s strict node_modules layout already refuses to let a package import something it did not list, which is one of the main reasons we chose it.
Only build and test what changed
A monorepo is only pleasant if CI does not rebuild the world on every commit. pnpm filters do most of the work without an extra build tool:
pnpm --filter "...[origin/main]" test # changed since main, plus their dependents
pnpm --filter "@quarry/api..." build # the API and every package it depends on
pnpm --filter @quarry/db run migrate # one script in one package, from the rootThe first command is the important one. The [origin/main] part selects packages with changed files, and the leading ... adds every package that depends on them. A change to the database client tests the client, the API and the worker. A change to the admin console tests only the admin console.
We cache build outputs in CI keyed on the lockfile and the package’s source hash. That got a cold six minute run down to about two when nothing relevant changed.
What we stopped doing
A few habits did not survive contact with a growing team:
- A root
utilspackage. It grew to 90 unrelated functions and every app depended on it, so every change to it tested everything. We split it into small packages named for what they do. - Path aliases instead of packages. Importing across folders through a tsconfig alias felt quicker, but it hid the dependency graph from pnpm and from our CI check.
- One TypeScript config to rule them all. Each package now extends a shared base and sets its own
include. Editors are faster and errors point at the right package.
None of this is clever. That is the point: a new engineer can understand the whole layout from the folder names, and nobody has proposed a reorganisation in almost a year.