Monorepo Package Boundaries

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.

Why boundaries break in monorepos

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.

Three layers of enforcement

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.

  • TypeScript project references give you compile-time isolation. A package literally cannot see another package’s internals.
  • ESLint import rules give you in-editor red squiggles. That’s where engineers actually feel the boundary.
  • Dependency-cruiser gives you a graph you can gate CI on. It’s the only tool that lets you write “no app may import another app” as a rule.

Use one alone and the team finds a hole within a quarter. Use all three and the rule sticks.

TypeScript project references

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.

ESLint import boundaries

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.

Dependency-cruiser for the graph

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.

Rolling it out without a revolt

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.

Barrel file pain

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.

Takeaways

  • Project references are the compile-time wall. Pin rootDir and don’t skip composite.
  • ESLint boundary rules are where engineers feel the rule. Put effort into the messages.
  • Dependency-cruiser is the only tool that can gate CI on “no app may import another app”.
  • Roll enforcement out warn-first, error-second, block-at-merge third. Big-bang gets reverted.
  • A shared package’s exports are a public API. Changing the shape is a schema migration.
  • Default to subpath exports. Barrel files at the package root are convenience debt.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.