A boring monorepo layout that has survived three years and 40 packages

Three top-level folders, one dependency rule and pnpm filters for CI. The monorepo structure we keep coming back to, and what we stopped doing.

$_$ test
Illustration for “A boring monorepo layout that has survived three years and 40 packages”.
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 root

The 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 utils package. 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.

Filed under
Portrait of Hana Sato (illustration)

Written by

Hana Sato

Developer tooling lead · Osaka, Japan

Hana owns CI, the monorepo and the test suite at a mid-sized SaaS team. She writes about fast builds, flaky tests, feature flags and the small internal tools that are cheaper than a vendor.

Live demo · Green Build Gazette— View template →