How I enforce monorepo package boundaries with TypeScript project references, ESLint import rules, and dependency-cruiser, and how to roll it out without breaking the team.
It was a Friday afternoon at the live-video creator startup I’d joined as Lead. Five PRs landed in one batch, all migrating older Vue components onto the new design system I’d been rolling out for a quarter. CI passed. Deploy ran. About thirty minutes later, support pinged the channel: creator profile pages were missing the entire bio section.
The bio was still in the DOM. A “global reset” the new design-system package was quietly bundling had nuked padding-top and margin on every <section> element across the app. The bio container collapsed to zero height. Twitter started noticing.
I tell that one a lot because it’s the cleanest example I have of why monorepo boundaries matter. A shared package leaked a side effect into a surface that hadn’t opted in. There was no enforcement to stop it. The boundary was a vibe.
This post is about how I do it now.
Three failure modes show up in every codebase I’ve worked on past about ten packages.
Barrel files. packages/ui/src/index.ts re-exports everything. Convenient, and also how one import drags in twenty modules, breaks tree-shaking, and makes tsc slow.
Transitive side effects. Someone adds a global CSS import, a polyfill.ts with top-level code, or a singleton store inside a shared package. The package looks pure from the outside. It isn’t. The story above is exactly this.
Social conventions instead of tooling. A README.md that says “please don’t import from packages/auth/src/internal/*” works for about six months. Then a new engineer joins, doesn’t read the README, and writes the import. PR gets merged because the diff looks fine in isolation.
The fix is not more code review. The fix is three layers of tooling that catch these things before review.
I run all three together on every monorepo I’ve owned, at the creator-economy platform I worked at and at the federation platform I co-founded the engineering org at. Each layer catches a class of mistake the others miss.
Use one alone and the team finds a hole within a quarter. Use all three and the rule sticks.
Project references are the cheapest layer to add and the easiest to get wrong. Each workspace package is its own tsc project with its own tsconfig.json, and the root config wires them together.
// tsconfig.base.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"composite": true,
"declaration": true,
"declarationMap": true,
"incremental": true,
"isolatedModules": true,
"skipLibCheck": true,
"resolveJsonModule": true
}
}
// packages/ui/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"tsBuildInfoFile": "dist/.tsbuildinfo"
},
"references": [
{ "path": "../tokens" },
{ "path": "../utils" }
],
"include": ["src/**/*"]
}
// tsconfig.json at the repo root
{
"files": [],
"references": [
{ "path": "apps/web" },
{ "path": "apps/admin" },
{ "path": "packages/ui" },
{ "path": "packages/tokens" },
{ "path": "packages/utils" },
{ "path": "packages/auth" }
]
}
Once this is in place, apps/web importing from packages/auth/src/internal/token fails the type-check because internal/token is not in the package’s emitted declaration surface. The build won’t lie to you.
The trap: composite: true requires rootDir. If you forget it, tsc will silently pull files from outside the package and you’ll get the “why is this random file in dist” bug. Pin rootDir.
Project references stop illegal imports at build time. ESLint stops them at type time, which means red squiggles in the editor before the engineer even runs the build. That’s the layer that actually changes behavior.
I use eslint-plugin-boundaries for layer rules and no-restricted-imports for surgical bans.
// eslint.config.js (flat config)
import boundaries from "eslint-plugin-boundaries";
export default [
{
plugins: { boundaries },
settings: {
"boundaries/elements": [
{ type: "app", pattern: "apps/*" },
{ type: "package", pattern: "packages/*" },
],
},
rules: {
"boundaries/no-private": ["error", { allowUncles: false }],
"boundaries/element-types": [
"error",
{
default: "disallow",
rules: [
{ from: "app", allow: ["package"] },
{ from: "package", allow: ["package"] },
],
},
],
"no-restricted-imports": [
"error",
{
patterns: [
{
group: ["**/packages/*/src/internal/**"],
message: "Reach for the package's public entry, not src/internal.",
},
{
group: ["**/apps/*/**"],
message: "Apps cannot import from other apps.",
},
],
},
],
},
},
];
Two rules carry most of the weight. boundaries/element-types says apps can import packages and packages can import packages, but apps cannot import other apps. no-restricted-imports blocks the src/internal/* pattern that creeps in when engineers reach past the public entry.
I’d also strongly recommend eslint-plugin-import with import/no-cycle turned on for any package with more than ten files. Circular imports inside a package are a slow leak.
ESLint runs per-file. It can’t tell you “there’s a cycle that goes through five packages”. Dependency-cruiser can.
// .dependency-cruiser.cjs
module.exports = {
forbidden: [
{
name: "no-circular",
severity: "error",
from: {},
to: { circular: true },
},
{
name: "no-orphans",
severity: "warn",
from: { orphan: true, pathNot: "(spec|test|stories)" },
to: {},
},
{
name: "apps-cannot-touch-each-other",
severity: "error",
from: { path: "^apps/([^/]+)/" },
to: { path: "^apps/(?!$1)([^/]+)/" },
},
{
name: "no-deep-package-imports",
severity: "error",
from: {},
to: { path: "^packages/[^/]+/src/internal/" },
},
],
options: {
tsConfig: { fileName: "tsconfig.json" },
doNotFollow: { path: "node_modules" },
},
};
# .github/workflows/ci.yml
- name: Dependency graph
run: pnpm depcruise --config .dependency-cruiser.cjs apps packages
The apps-cannot-touch-each-other rule is the one I get the most value out of. ESLint can do this with patterns but the regex is fiddly. Depcruise reads it naturally. Run it in CI as a hard gate.
Big-bang enforcement on a mature repo gets reverted within a week. I’ve seen it. I’ve done it.
The shape that works: warn first, error second, block at merge third.
Stage one is severity: "warn" on every rule, with a script that prints a summary on every CI run. Engineers see the warnings, get curious, ask questions in Slack. Two weeks of that.
Stage two flips severity: "warn" to "error" on the rules with zero current violations, while keeping the noisier rules on warn. Add a // boundary-ignore style escape hatch for genuinely-hard cases, but make it grep-able so you can track its usage.
Stage three removes the escape hatch and gates the merge button.
One last thing. Barrel files at the package root are the single biggest source of pain I’ve seen in any monorepo. They look helpful, and they’re not.
// packages/ui/src/index.ts -- the trap
export * from "./button";
export * from "./modal";
export * from "./form/index";
export * from "./layout/index";
export * from "./toast";
Three problems. Importing Button from @org/ui evaluates every other file the barrel touches, including their top-level side effects. Your bundler can sometimes recover (tree-shake), but TypeScript’s compile graph always sees the whole thing, so incremental builds get slow. And you can never say “this thing is private to the package” because the barrel re-exports everything anyway.
The fix is exports in package.json, with explicit subpath entries.
{
"name": "@org/ui",
"exports": {
"./button": "./dist/button.js",
"./modal": "./dist/modal.js",
"./form": "./dist/form/index.js",
"./layout": "./dist/layout/index.js",
"./toast": "./dist/toast.js"
}
}
Now consumers write import { Button } from "@org/ui/button". Internals stay internal. Tree-shaking gets simpler. tsc gets faster. ESLint’s no-restricted-imports can enforce “no deep imports beyond the declared subpaths” with one extra rule.
I’d rather pay the slightly-longer import-path tax than debug another zero-height bio section.
rootDir and don’t skip composite.exports. Barrel files at the package root are convenience debt.Thanks for reading. If you’ve got thoughts, send them my way.