The "Shared" Library is a Lie: Fixing Your Nx Monorepo Architecture
"If your Nx monorepo has 12 apps and one giant 'shared' library… You don't have architecture. You have a countdown to technical debt."
I've reviewed over 20+ enterprise Angular monorepos. The teams were smart, the code compiled, and everything worked locally. But beneath the surface, every single one of them was a ticking time bomb.
The #1 failure pattern I see across the industry is treating apps and libraries as the same architectural layer. Teams move fast for six months, then hit a wall. Builds slow down. nx affected starts rebuilding almost everything. And the "shared" folder becomes a dumping ground for 400+ files with no clear owner.
Here's the hard truth most teams learn too late.
The Anti-Pattern That Feels Right (But Kills Velocity)
When you start a monorepo, creating a shared or libs/common folder feels responsible. You have a utility function? Throw it in shared/utils. A data model? shared/models. A custom button? shared/ui.
This leads to a specific, deadly architecture:
- Apps contain business logic (the dashboard app directly calls APIs and calculates tax).
- One massive shared/lib folder (everything from date formatting to payment processing lives together).
-
No dependency boundaries (the
feature-productslibrary imports directly fromfeature-checkout).
The false confidence comes from a single phrase: "But it works locally."
Of course it works. Your machine has 32GB of RAM and a clean build. But in CI, on a team of 20 engineers making 50 commits a day? That "working" architecture collapses under its own weight.
The Enterprise-Grade Reality
In scalable monorepos, we draw hard lines where most teams draw soft suggestions.
| Layer | Responsibility | Example |
|---|---|---|
| Apps | Orchestration only (thin, route-driven, framework-bound) |
dashboard, admin, checkout
|
| Libraries | Business capabilities (domain-owned, framework-agnostic where possible) |
domain/auth, feature/products, util/formatting
|
| Shared | Almost nothing. Explicitly versioned contracts only. |
shared/interfaces, shared/constants
|
The Golden Rule
Apps orchestrate. Libraries own business capabilities.
An app should be boring. It should import a feature library, set up routes, and maybe add a layout. No API calls. No complex state management. No business logic. If you need to know how something works, you belong in a library.
A library should be opinionated. It owns a slice of your business domain. It decides what to export and what to keep private. It has a CODEOWNERS entry, a README, and enforceable dependency rules.
Why Most Monorepos Fail (It's Not the Tooling)
Here's a contrarian opinion: Most monorepos fail not because of tooling, but because teams centralize too much instead of modularizing correctly. "Shared" is the enemy of scalable architecture.
The industry preaches DRY (Don't Repeat Yourself). In monorepos, DRY kills you.
Reuse should be intentional, not accidental. Two similar implementations in two different domains? That's often better than one fragile shared abstraction that tries to serve everyone.
I've seen teams spend three weeks building a "universal" pagination component that works for products, users, orders, and analytics. By week four, it has 15 configuration flags and three bug reports. By week eight, every domain has forked it locally anyway.
Don't abstract until you have three concrete examples. And even then, question whether they truly belong together.
The Real Scaling Killers in Large Monorepos (500+ Modules)
Through my audits, I've identified four patterns that consistently destroy team velocity:
1. Disappearing Boundaries
Domain bleeds into domain. Suddenly, your domain/auth library knows about domain/billing user roles. Good luck untangling that when you need to extract auth for a new app.
2. Shared Library Dumping Ground
A utils folder with 400+ files. No subdirectories. No ownership. Any file can import any other. You've recreated a global namespace inside your monorepo.
3. Apps Containing Business Logic
Finding a critical tax calculation means spelunking through six nested folders inside apps/dashboard/src/app/utils/helpers/legacy/tax/.... This is where knowledge leaves the team.
- No Dependency Governance
Everything imports everything. Your
nx graphlooks like a plate of spaghetti.nx affectedis useless because changing literally anything rebuilds everything.
The Fix: Architecture as Enforcement, Not Documentation
Here's a perspective shift that changed how I think about Nx:
Nx is not a build tool with benefits. Nx is architecture governance that happens to build your code.
Most teams adopt Nx for the monorepo benefits—caching, affected commands, and distributed tasks. The best teams adopt Nx for architectural constraints.
The difference is whether you use tags and boundaries as documentation (ignored after two sprints) or as enforcement (CI fails when violated).
Practical Enforcements That Work
Weekly nx graph reviews in architecture meetings. Open the visualization. Look for the module with the most incoming edges. That's your architecture bottleneck. Make someone responsible for reducing it.
Domain ownership mapping in CODEOWNERS. Every library has a human owner. If a team needs to change a library they don't own, they file an issue or open a PR with a request for review. No more silent drive-by changes.
enforce-module-boundaries as a CI gate. Not a warning. An error. Your PR doesn't merge if a feature library imports another feature library directly.
The Dependency Inversion Principle in Nx Terms
Your dependency graph should flow inward toward stable abstractions.
❌ Wrong: feature-products → shared/utils (concrete dependency)
✅ Correct: feature-products → domain/catalog → shared/interfaces (abstracted)
Why does this matter for performance?
When shared/utils changes, everything that depends on it rebuilds. If 80% of your libraries touch shared/utils, you've lost all incremental build benefits. Your nx affected:test runs almost everything.
When you abstract through domain boundaries, affected commands actually work. A change in shared/interfaces might rebuild five libraries. A change in domain/catalog rebuilds only the features that depend on catalogs.
Rule of Thumb
A library should have more consumers than dependencies.
If a library has 10 dependencies and only 2 consumers, you've inverted the wrong direction. That library is a sink—it pulls in too much complexity and benefits too few consumers. Break it apart or move the dependencies upstream.
The Performance Math That Actually Matters
Incremental builds only work when a change in lib A rebuilds only the apps and libs that depend on A. This fails when:
- Everything depends on
shared/utils. - Circular dependencies exist (Nx will warn you—listen to it).
- Libraries are too granular (overhead) or too coarse (rebuild cascade).
The Enterprise Sweet Spot
For a team of 20-50 engineers, I aim for 50-150 libraries:
- Fewer than 50 → Boundaries are too coarse. A single library does too much. Changes cascade.
- More than 150 → Cognitive load is too high. Engineers can't remember where anything lives.
This is a guideline, not a law. Start lower and split as needed.
CI/CD Impact
Clean architecture transforms CI times.
With proper boundaries, nx affected:test runs 30-60% fewer tests than a full test run. For a 20-minute CI pipeline, that's 6-12 minutes saved per PR.
Over 10 PRs per day? That's 1-2 hours of CI time recovered daily. Per week? An entire engineer-day of waiting for builds. Per quarter? You've gained back a sprint.
This is not theoretical. I've watched teams go from "CI takes 45 minutes, let's get coffee" to "CI takes 12 minutes, let's review while it runs."
Code Examples: Building It Right
Let's make this concrete. Here's how to structure a domain library with intentional exports.
Snippet 1: Domain Library with Clear Exports
// libs/domain/auth/src/index.ts
export { AuthService } from './lib/services/auth.service';
export { User, Role } from './lib/models/user.model';
export { AuthState, authReducer } from './lib/state/auth.state';
export { RequireAuthGuard } from './lib/guards/require-auth.guard';
// NOT exported: internal helpers, sub-components, test utilities
Notice what's not exported. Everything else inside libs/domain/auth is private to that library. You cannot import AuthService from a deeper path. The index.ts is your API contract. Change it intentionally.
**Snippet 2: Tags That Enforce Intent**
// project.json for domain/auth
{
"name": "domain-auth",
"tags": ["scope:domain", "type:domain-logic", "domain:auth"]
}
// project.json for feature-login
{
"name": "feature-login",
"tags": ["scope:domain", "type:feature", "domain:auth"]
}
These tags are meaningless by themselves. They become powerful when combined with enforce-module-boundaries.
Snippet 3: Dependency Constraints That Actually Work
// .eslintrc.json
{
"rules": {
"@nx/enforce-module-boundaries": [
"error",
{
"depConstraints": [
{
"sourceTag": "type:domain-logic",
"onlyDependOnLibsWithTags": ["type:domain-logic", "type:shared-util"]
},
{
"sourceTag": "type:feature",
"onlyDependOnLibsWithTags": ["type:domain-logic", "type:shared-ui"]
},
{
"sourceTag": "type:app",
"onlyDependOnLibsWithTags": ["type:feature", "type:domain-logic"]
}
]
}
]
}
}
With this configuration:
- A
featurelibrary cannot import anotherfeaturelibrary (prevents domain bleeding). - A
domain-logiclibrary cannot importfeaturelibraries (keeps dependencies flowing inward). - An
appcan only import features and domain logic (no direct utils access).
Snippet 4: The Thin App (Orchestration Only)
// apps/dashboard/src/app/app.routes.ts
export const appRoutes: Route[] = [
{
path: 'products',
loadChildren: () => import('@acme/feature-products').then(m => m.ProductsModule)
},
{
path: 'billing',
loadChildren: () => import('@acme/feature-billing').then(m => m.BillingModule)
},
{
path: 'admin',
loadChildren: () => import('@acme/feature-admin').then(m => m.AdminModule),
canActivate: [RequireAdminGuard] // Guard lives in domain/auth
}
];
// NO business logic here.
// NO API calls.
// NO complex state initialization.
// Just routing and orchestration.
If you need to understand what this app does, you read the routes. Every meaningful behavior lives in a library with a clear owner, clear tests, and clear boundaries.
Your Next Step: Run nx graph Right Now
Stop reading. Open your terminal.
nx graph
Look for three things:
- The module with the most incoming edges — That's your architecture bottleneck. Every team depends on it. Every change to it rebuilds half your system.
- Any circular dependencies — Nx will highlight them in red. Fix those first. They break incremental builds completely.
-
Apps with outgoing edges to
shared/utils— If your apps directly import utilities, you've bypassed your domain boundaries.
Pick one bottleneck. Just one. Refactor it next week. Move it behind a domain abstraction. Update your enforce-module-boundaries rules to prevent it from happening again.
Then run nx graph again next month. Watch the spaghetti untangle.
The Bottom Line
You don't have a monorepo problem. You have an architecture problem that happens to live in a monorepo.
Nx gives you the tools to enforce boundaries, visualize dependencies, and build incrementally. But those tools don't help if you keep throwing everything into a shared folder and calling it a day.
Apps orchestrate. Libraries own business capabilities. Shared is a four-letter word.
Now go audit your libs/shared folder. Count the files. Then ask yourself: How many of these truly need to be shared across every domain?
I'd love to hear your war stories. What's the biggest monorepo mistake you've seen in production? Not theory. Actual production bloodbath.
Drop your experience in the comments. 👇
📌 More From Me
I share daily insights on web development, architecture, and frontend ecosystems.
Follow me here on Dev.to, and connect on LinkedIn for professional discussions.
🌐 Connect With Me
If you enjoyed this deep dive into Angular architecture and want more insights on scalable frontend systems, follow my work across platforms:
🔗 LinkedIn — Professional discussions, architecture breakdowns, and engineering insights.
📸 Instagram — Visuals, carousels, and design‑driven posts under the Terminal Elite aesthetic.
🧠 Website — Articles, tutorials, and project showcases.
🎥 YouTube — Deep‑dive videos and live coding sessions.
Top comments (0)