# Make monorepo package boundaries enforceable

> Define public package surfaces and dependency direction, then enforce them through manifests, visibility rules, graph checks, ownership, and consumer tests.

Canonical URL: https://www.devobs.io/articles/monorepo-boundaries-that-hold/
By: Lucas Vale
Published: 2024-12-14T20:07:54.687Z
Updated: 2026-09-05
Section: Architecture

A monorepo boundary holds only when tools can reject an invalid dependency. Define packages around ownership and change responsibility, expose deliberate public entry points, and encode allowed dependency direction in manifests, build visibility, lint rules, and graph checks.

## Give every package a contract

Each package needs an owner, purpose, public API, supported consumers, and dependency class. Keep internals under paths that consumers cannot import. In JavaScript, use package exports to expose stable entry points and block deep imports such as package/src/internal/cache.

A workspace is a distribution mechanism, not permission to depend on everything. The [pnpm workspace documentation](https://pnpm.io/workspaces) explains workspace package selection and the workspace protocol, which forces a dependency to resolve to a local workspace package. Explicit manifest dependencies make the graph visible; they do not by themselves define which graph edges are architecturally allowed.

Classify packages into layers appropriate to the product: applications, domain capabilities, adapters, and foundations. State allowed direction, such as applications may depend on domains and adapters, while foundations cannot depend on applications. Avoid a “shared” package with no ownership rule; it becomes a cycle magnet.

## Enforce visibility in more than reviews

Add a graph check to CI that reads package manifests and fails on forbidden edges and cycles. Add language-level lint rules for imports that bypass public entry points. Ensure the build system sees declared inputs so a local undeclared import cannot pass only because all files share a checkout.

Bazel’s [build concepts documentation](https://bazel.build/concepts/build-ref) defines repositories, packages, targets, rule inputs, and visibility-oriented package groups. Even without Bazel, the model is useful: a buildable target owns its outputs, declares inputs, and exposes access intentionally.

Run checks on changed packages and affected dependents for fast feedback, plus a full graph validation periodically. Generate a dependency view engineers can inspect, but keep the policy in versioned code rather than a diagram alone.

## Make sharing deliberate

Promote code to a shared foundation only when several owners agree on its semantics and maintenance. Copying five lines can be cheaper than creating a cross-team contract. A shared package should have a coherent purpose, not merely functions that looked reusable.

Separate domain types from transport or database shapes. If every consumer imports the billing service’s ORM entity, schema changes become repository-wide changes. Export a smaller domain or API type whose compatibility is owned.

Version internal packages conceptually even when released together. Record breaking changes, migrate consumers in stages, and keep compatibility adapters when old and new applications coexist. Workspace protocols can ensure local resolution while ordinary semantic ranges communicate release expectations for publishable packages.

## Test from the consumer edge

Unit tests inside a package prove implementation behavior. Consumer tests prove that the public surface is sufficient and stable. Compile or run representative consumers using only exported APIs. For generated clients and schemas, verify generation is reproducible and that consumers do not import generator internals.

Ownership should follow the edge. The provider reviews public API changes; owners of high-risk consumers review breaking migrations. Automated affected-package analysis helps route reviews but should not replace explicit ownership.

Cycles reveal unclear responsibility. Do not hide them with dynamic imports or a bigger shared package. Identify the concept causing both directions, move it to an appropriate lower-level contract, or invert one dependency behind an interface.

## Plan exceptions to expire

Some migrations require a temporary forbidden edge. Record the exact importer and provider, reason, owner, removal date, and issue. A wildcard exception will become permanent architecture.

Track deep imports, cycles, package fan-in and fan-out, and ownership gaps. High fan-in signals a contract that needs careful compatibility; high fan-out can signal a package with too many responsibilities.

Start by exporting the current dependency graph and selecting one rule everyone agrees on, such as domain packages may not import applications. Enforce it in CI, repair existing violations or time-box exact exceptions, then add public entry-point checks. A small enforced rule changes architecture more reliably than a complete unenforced diagram.

## Source references

- <https://pnpm.io/workspaces>
- <https://bazel.build/concepts/build-ref>
