Monorepos are everywhere. Google, Meta, Microsoft, Uber, Airbnb — they all use monorepos at massive scale. The JavaScript ecosystem has embraced them too, with tools like Nx, Turborepo, and pnpm workspaces making it easier than ever to manage multiple packages in a single repository.
The benefits are well-documented: code sharing without publishing packages, atomic commits across multiple projects, unified tooling and CI, and simplified dependency management for external packages. But there's a challenge that sneaks up on every monorepo team: internal dependency management.
External dependencies (the ones in package.json) are versioned, visible, and managed by your package manager. Internal dependencies — the import relationships between your own packages — are invisible, unversioned, and often unmanaged. Until they become a problem.
This article is about that problem, and what to do about it.
Why Internal Dependencies Get Messy
In a small monorepo with 3-5 packages, dependencies are obvious. Everyone knows that @app/ui depends on @app/shared, and @app/api depends on @app/db. You can hold the dependency graph in your head.
At 15-20 packages, you can't anymore. And that's when things start to go wrong.
The Gradual Entanglement
Consider a monorepo that starts with clean boundaries:
packages/
ui/ → depends on: shared
api/ → depends on: db, shared
db/ → depends on: shared
shared/ → depends on: nothing
auth/ → depends on: db, shared
email/ → depends on: shared
Six months later, a developer working on the email package needs a utility function from auth. Instead of extracting it to shared, they add a direct dependency:
// packages/email/src/templates.ts
import { formatUserName } from '@app/auth/utils';
This import is legal — pnpm resolves it, TypeScript compiles it, the tests pass. But it creates a hidden coupling: email now depends on auth, which depends on db. A change to db now potentially affects email sending.
A week later, someone in auth needs an email template. They import from email:
// packages/auth/src/password-reset.ts
import { resetPasswordTemplate } from '@app/email/templates';
Now you have a circular dependency: auth → email → auth. Both packages are entangled. You can't build, test, or deploy one without the other. The "package" boundaries are an illusion.
Multiply this across 20 packages and 12 months, and you have a dependency graph that nobody understands.
Common Anti-Patterns
Through analyzing hundreds of monorepos, certain anti-patterns appear repeatedly:
The God Package: One package that everything depends on. It starts as shared or common and grows to contain hundreds of utilities, types, helpers, and constants. Every change to this package triggers rebuilds of every other package. It becomes the bottleneck — the one package that nobody wants to touch because the blast radius is enormous.
Circular Dependencies Between Features: Feature packages that should be independent end up importing from each other. billing imports from projects to get project counts. projects imports from billing to check plan limits. notifications imports from both to know what to notify about. The result is a tightly coupled cluster that defeats the purpose of having separate packages.
Deep Dependency Chains: Package A depends on B, B depends on C, C depends on D, D depends on E. A change to E propagates through four levels. Build times increase because the build system can't parallelize these packages. And the developer changing E has no idea that they're affecting A.
Phantom Dependencies: Package A imports from Package C, but only declares a dependency on Package B (which happens to depend on C). This works because pnpm or Yarn hoists C to a location where A can find it. But it's fragile — a version change in B could remove C from A's resolution path.
Why Build Tools Don't Solve This
Monorepo build tools are excellent at what they do. Nx's computation caching, Turborepo's parallel execution, and pnpm's workspace protocol are all genuinely useful. But they operate at the task and build level, not the architecture level.
What Build Tools Show You
Nx provides a project graph that shows which packages depend on which:
npx nx graph
This shows package-level dependencies. If @app/api depends on @app/db, you see that connection. It's useful for understanding build order and affected projects.
Turborepo provides a similar task graph:
npx turbo run build --graph
This shows the execution order of tasks across packages. It answers "what needs to build before what?" — which is a build orchestration question.
What Build Tools Don't Show You
Neither tool shows you the actual import relationships within and between packages at the file or module level. They know that package A depends on package B because package.json says so. But they don't know:
- Which specific files in A import from B
- Whether the dependency is a thin connection (one import) or a thick connection (dozens of imports across many files)
- Whether there are implicit circular dependencies at the file level that don't appear at the package level
- How cohesive each package is internally
- Whether a package's internal structure respects any architectural patterns
This is the gap between build-level dependency management and architecture-level dependency management. Build tools answer "how do I build this efficiently?" Architecture tools answer "is this structure healthy?"
Visualizing the Real Dependency Graph
To manage internal dependencies effectively, you need to see them — not at the package level, but at the module and file level.
What to Look For
When you visualize your monorepo's dependency graph, look for these signals:
Fan-in hotspots: Modules that are imported by a disproportionate number of other modules. If one module has 40 inbound connections, it's a coupling magnet. Any change to it affects 40 other places.
Clusters: Groups of modules that are tightly connected to each other but loosely connected to the rest. These might be candidates for a new package, or they might indicate hidden boundaries within an existing package.
Cross-package bridges: Files that are the sole connection between two packages. These are high-risk files — if they change, the entire relationship between two packages changes. They're also refactoring targets: maybe that file should live in a shared package instead.
Orphaned modules: Files that exist but aren't imported by anything. They're dead code, increasing cognitive load and build times without providing value.
Dependency direction violations: In a layered architecture, dependencies should flow downward (from UI to business logic to data). Upward dependencies indicate architectural violations that erode the layered structure.
Using ReposLens for Monorepo Visualization
ReposLens generates an interactive dependency graph from any GitHub repository. For monorepos, this visualization is particularly valuable because it shows the actual import relationships across package boundaries — the connections that package.json files don't capture.
You connect your monorepo repository, and within seconds you see:
- Every module across all packages
- The import relationships between them
- Circular dependency cycles highlighted
- Coupling scores per module
- The overall architectural shape
This gives you a ground-truth view of your monorepo's internal structure. You might discover that your "independent" packages are actually deeply entangled, or that a package you thought was central is actually barely used.
The PR analysis feature is especially useful in monorepos. When someone opens a PR that adds an import from one package to another, ReposLens flags it — allowing you to evaluate whether that new dependency is intentional and appropriate, or whether it's the start of an entanglement.
Best Practices for Monorepo Dependency Management
Healthy monorepo dependency management is about establishing rules, making them visible, and enforcing them automatically.
Define Clear Package Boundaries
Every package in your monorepo should have a clear purpose and a defined interface. Ask:
- What does this package do? (One sentence)
- Who are its consumers? (Which other packages import from it)
- What does it expose? (Its public API)
- What does it depend on? (Its own dependencies)
Document this in a README.md at the root of each package. It doesn't need to be elaborate — a few lines that describe the boundary:
# @app/billing
Handles subscription management, payment processing, and plan limit enforcement.
## Consumers
- `@app/api` (billing endpoints)
- `@app/dashboard` (billing UI)
## Dependencies
- `@app/db` (database access)
- `@app/shared` (utilities)
## Does NOT depend on
- `@app/projects` (use events for cross-module communication)
- `@app/notifications` (use events, not direct imports)
That "Does NOT depend on" section is the most important part. It makes the boundary explicit.
Establish Dependency Direction Rules
Not all dependencies are equal. Some directions are healthy; others are problematic.
A common rule for monorepos:
Feature packages → Shared libraries ✅ (allowed)
Shared libraries → Feature packages ❌ (forbidden)
Feature packages → Feature packages ⚠️ (must be justified)
Feature packages (billing, projects, notifications) should depend on shared libraries (db, shared, ui), not on each other. When features need to communicate, use events or a shared service layer instead of direct imports.
Use Barrel Files to Control Exports
Every package should have an explicit public API defined through its entry point:
// packages/billing/src/index.ts
// Public API — these are the only things other packages should import
export { BillingService } from './services/billing-service';
export { checkPlanLimit } from './utils/plan-limits';
export type { Plan, Subscription } from './types';
// Everything else is internal and should NOT be imported directly
Combine this with TypeScript path aliases or package.json exports to prevent direct file imports:
// packages/billing/package.json
{
"name": "@app/billing",
"exports": {
".": "./src/index.ts"
}
}
Now, import { something } from '@app/billing/src/internal/service' is a TypeScript error. Only the public API is accessible.
Conduct Regular Dependency Audits
Schedule a monthly review of your monorepo's dependency graph. Look for:
- New cross-package dependencies: Were any added this month? Were they intentional?
- Circular dependencies: Did any new cycles appear?
- God package growth: Is
sharedgetting bigger? Should something be extracted? - Unused dependencies: Are there packages that nothing imports from?
- Coupling trends: Is overall coupling increasing or decreasing?
This review doesn't need to be long — 30 minutes with the team looking at the dependency graph is enough to catch trends before they become problems.
Automate Boundary Enforcement
The most effective way to maintain healthy dependencies is to enforce them automatically on every PR.
With Nx module boundaries:
// nx.json or project.json
{
"tags": ["scope:feature", "type:billing"]
}
// .eslintrc.json
{
"rules": {
"@nx/enforce-module-boundaries": [
"error",
{
"depConstraints": [
{
"sourceTag": "scope:feature",
"onlyDependOnLibsWithTags": ["scope:shared"]
},
{
"sourceTag": "scope:shared",
"onlyDependOnLibsWithTags": ["scope:shared"]
}
]
}
]
}
}
With dependency-cruiser:
// .dependency-cruiser.cjs
module.exports = {
forbidden: [
{
name: "no-feature-to-feature",
severity: "error",
comment: "Feature packages must not depend on other feature packages directly",
from: { path: "^packages/(billing|projects|notifications|auth)/" },
to: { path: "^packages/(billing|projects|notifications|auth)/", pathNot: "$1" }
},
{
name: "no-shared-to-feature",
severity: "error",
comment: "Shared packages must not depend on feature packages",
from: { path: "^packages/(shared|ui|db)/" },
to: { path: "^packages/(billing|projects|notifications|auth)/" }
}
]
};
Run these checks in CI. If a PR violates a boundary rule, the build fails.
Use Events for Cross-Feature Communication
When feature packages need to communicate, avoid direct imports. Use an event-driven pattern instead:
// packages/shared/src/events.ts
export type AppEvent =
| { type: 'project.created'; data: { projectId: string; userId: string } }
| { type: 'subscription.changed'; data: { userId: string; plan: string } }
| { type: 'user.registered'; data: { userId: string; email: string } };
export type EventHandler<T extends AppEvent['type']> = (
event: Extract<AppEvent, { type: T }>
) => Promise<void>;
// packages/billing/src/handlers.ts
import type { EventHandler } from '@app/shared/events';
export const onProjectCreated: EventHandler<'project.created'> = async (event) => {
// Check if user has reached their plan's project limit
await checkProjectLimit(event.data.userId);
};
// packages/notifications/src/handlers.ts
export const onProjectCreated: EventHandler<'project.created'> = async (event) => {
// Send a notification to the team
await sendTeamNotification(event.data.projectId);
};
Now billing and notifications both react to project creation without depending on the projects package. The dependency points toward shared (for the event type definitions), not laterally between features.
A Practical Migration Path
If your monorepo already has tangled dependencies, here's a step-by-step approach to cleaning them up.
Step 1: Map the Current State
Generate a dependency graph of your monorepo. Use whatever tool works for your stack — Nx's project graph, dependency-cruiser's visualization, or ReposLens. The goal is to see every cross-package dependency.
You'll likely discover connections you didn't know about. That's normal — and it's exactly why this step is necessary.
Step 2: Identify the Worst Offenders
Look for:
- Packages with the most inbound dependencies (candidates for splitting)
- Circular dependency cycles (the most urgent fixes)
- Cross-feature dependencies (should be replaced with events or shared abstractions)
Prioritize circular dependencies — they're the most damaging because they make packages impossible to understand or refactor in isolation.
Step 3: Fix One Cycle at a Time
Don't try to refactor everything at once. Pick the smallest circular dependency and break it:
- Identify the specific imports that create the cycle
- Determine which direction the dependency should flow
- Extract shared code to a shared package, or replace direct imports with an event-based pattern
- Verify the cycle is broken by re-running the dependency check
- Commit and repeat
Step 4: Add Enforcement
Once you've cleaned up the worst violations, add automated checks that prevent new ones. This is the most important step — without enforcement, the clean state will degrade back to the tangled state within months.
Step 5: Monitor Over Time
Track your dependency metrics monthly:
- Number of circular dependencies (target: zero)
- Number of cross-feature imports (target: zero or decreasing)
- Size of the largest package (target: stable or decreasing)
- Overall coupling score (target: stable or decreasing)
If any metric trends in the wrong direction, investigate before it compounds.
The Long Game
Monorepo dependency management isn't a one-time cleanup. It's an ongoing discipline, like maintaining test coverage or keeping documentation updated. The teams that succeed with monorepos at scale are the ones that:
- Visualize their dependency graph regularly — not just when something breaks
- Define clear boundaries between packages and document them
- Enforce those boundaries automatically on every PR
- Review the architecture periodically to catch drift early
The tools exist to make all of this practical. Nx and Turborepo handle the build orchestration. dependency-cruiser handles rule enforcement. ReposLens handles visualization and PR-level architecture checks. The hard part isn't the tooling — it's the discipline of using them consistently.
Your monorepo's internal dependencies are either an asset (clear boundaries, fast builds, easy refactoring) or a liability (tangled coupling, slow builds, afraid-to-touch-anything paralysis). The difference is whether you manage them deliberately or let them evolve by accident.
Choose deliberately. Visualize your dependencies today, before it's too late.