Most monorepos pay lip service to "share code via libraries." In practice, apps grow huge and libraries stay shallow. The shared/ folder becomes a junk drawer. New code goes wherever's easiest, which is usually wherever the developer is already typing - the app.
After scaling our frontend monorepo to 19 applications and 86 libraries (roughly 74,000 lines of TypeScript), I've seen this pattern from both sides: as the architect setting it up, and as the engineer who later inherits it. The takeaway: if your monorepo's library-first principle is just a guideline, it will erode. Mechanical enforcement is the only thing that scales.
Here's how we made library-first an architectural rule that's hard to violate.
Why "Just Put It in a Library" Fails
The default state of any monorepo: convention says "extract reusable code into libraries"; reality says "I'll do it later." Apps grow into hundreds of files because there's zero friction adding code there. Libraries stay thin because extraction is always an explicit decision someone has to make.
This isn't a discipline problem. It's a friction problem.
Three failure modes I keep seeing:
Apps as code containers. A junior dev needs a button component. They write it in apps/foo/src/components/Button.tsx. Two months later, three other apps have their own slightly different button. None know the others exist.
Shared as junk drawer. Senior devs do extract code, but libs/shared/ becomes a flat dumping ground with no internal structure. Finding a util becomes a 5-minute grep through libs/shared/.
Type confusion. "What kind of library is this?" "It's a library." UI components, business logic, API clients, and state stores all mixed together because the monorepo doesn't have language to distinguish them.
Tools like Nx provide module boundaries you can enforce. Unless they're enforced automatically, they aren't enforced.
The 80/20 Inversion
Our principle: applications are deployment containers, not code containers. They wire libraries together and configure routing. That's it.
How thin is "thin"? Look at one of our domain apps:
- 5 source files in the app
- 143 source files in the domain's libraries
- That's 97% library code, 3% app code. We don't just hit 80/20, we exceed it.
The app folder has roughly four files: main.tsx (bootstrap), app.tsx (layout shell), routes.tsx (route config), and styles.scss. Here's how they wire together.
main.tsx mounts the router and wraps it with cross-cutting providers. Note where these providers come from: shared-ui and third-party packages, not domain-state. State libraries hold stores; cross-cutting concerns belong in shared.
// src/main.tsx
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ErrorBoundary } from '@scope/shared-ui';
import { routes } from './app/routes';
const queryClient = new QueryClient();
const router = createBrowserRouter(routes);
root.render(
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</ErrorBoundary>,
);
The real wiring happens in routes.tsx - this is where features (each a separate library) connect to URL paths, lazy-loaded so each feature ships as its own bundle:
// src/app/routes.tsx
import { lazy } from 'react';
import type { RouteObject } from 'react-router-dom';
import { App } from '../app';
const DashboardPage = lazy(() =>
import('@scope/domain-features-dashboard')
.then(m => ({ default: m.DashboardPage })),
);
const SearchPage = lazy(() =>
import('@scope/domain-features-search')
.then(m => ({ default: m.SearchPage })),
);
export const routes: RouteObject[] = [
{
path: '/',
element: <App />,
children: [
{ index: true, element: <DashboardPage /> },
{ path: 'search', element: <SearchPage /> },
],
},
];
That's the pattern: app boots, mounts routes, routes import features by name, features arrive as their own library. Adding a feature is a one-line addition to the routes file plus a new lib. Apps shrink to their essence; libraries hold everything that matters.
When code lives in libraries, it's testable in isolation, code-splittable per feature, reusable across apps, and promotable to shared without restructuring. When it lives inside the app, none of those properties hold. That's why we keep apps almost empty - in this structure, those properties are the default rather than something we have to engineer.
Type-Tagged Boundaries
Reusable libraries aren't enough on their own. You also need a vocabulary for what kind of library something is, plus rules about which kinds can depend on which.
We use 7 types, each with explicit dependency constraints:
| Source type | Can depend on |
|---|---|
type:app |
feature, ui, data-access, state, util, hooks |
type:feature |
feature, ui, data-access, state, util, hooks |
type:state |
state, data-access, util |
type:ui |
ui, util |
type:data-access |
data-access, util |
type:hooks |
hooks, data-access, util |
type:util |
util |
Read top-to-bottom: type:util is the foundation - pure functions, no deps. type:ui adds presentation - components built from utils, no business logic. type:data-access and type:state handle the data layer. type:feature orchestrates everything into pages and flows. type:app consumes features.
The rules are encoded in ESLint via @nx/enforce-module-boundaries. A type:ui library that imports from type:state fails lint. Not a warning - an error that blocks merge.
Why this matters: most monorepos treat "shared code" as a single category. We split it into seven, each with a contract about what it can know and depend on. Small change, outsized effect: a type:ui library is guaranteed to be presentation-only, because the toolchain prevents anything else.
Normalization Across Domains and Shared
Every domain follows the same 6-library template:
libs/{domain}/
├── data-access/ # API clients, types, services
├── state/ # State stores
├── ui/ # Domain-specific components
├── util/ # Constants, helpers
├── hooks/ # Custom hooks
└── features/ # Pages and flows
This uniformity matters more than it sounds. New developer joining a domain? They already know the layout from the previous one. Cross-team handoff? No mental retooling. Promoting someone to a new team? They're productive on day one.
But the bigger insight - the one that took me longest to appreciate - is that libs/shared/ follows the exact same structure:
libs/shared/
├── data-access/
├── ui/
├── util/
├── hooks/
└── features/
Same types. Same boundary rules. Same package conventions. Just consumed across all domains instead of one.
This solves a chronic monorepo problem: the promotion path from domain to shared.
In most setups, "this should be reusable" triggers a structural refactor: invent a new layout for shared/, rename packages, update imports, fight the boundary checker. Devs put it off, and shared/ either stays empty or becomes the junk drawer.
When shared/ mirrors domain structure, the structural problem is already solved. What varies is how much contract generalization each type needs:
-
Leaf types (
util,ui,hooks): Often agit mv. Pure functions, presentation components, and most hooks promote nearly mechanically. -
data-access: Needs generalization. Domain endpoints and types must become generic interfaces. Real work, but the structure is solved. -
stateandfeatures: Hardest. They depend on multiple other types, and feature units often carry domain assumptions. Promoting a feature to shared is significant work - but the organizational problem is still solved.
The promotion friction goes from "refactor structure + generalize API" to just "generalize API." Roughly half the work, removed by symmetry alone.
Mechanical Enforcement (5 Layers)
Architecture as a guideline fails. Architecture as 5 reinforcing automated checks survives.
Layer 1: The Generator. New domains aren't hand-built. We run one command:
nx g @workspace/domain-app:create --name=my-domain
This scaffolds the app + 6 libraries, correctly tagged from birth. No after-the-fact tagging where someone forgets type:ui and the rule never matches. Tagging happens at scaffold time, by code, on every domain. This is the most important layer because it makes "incorrectly structured library" nearly unreachable.
Layer 2: ESLint module boundaries. Catches dependency violations at lint time. CI runs nx affected -t lint on every PR.
Layer 3: Lint-staged pre-commit. Runs eslint --fix on staged files. Quick errors get auto-fixed; structural errors block commit.
Layer 4: Husky pre-push. Before code leaves the developer's machine: knip:affected (unused imports), typecheck, lint:affected:fix. Slower checks that catch what pre-commit missed.
Layer 5: CI gates. Final wall: build, test, lint, typecheck on all affected projects. PRs can't merge if any layer below let something through.
A single layer fails because devs can disable rules locally, skip hooks with --no-verify, or just push without running tests. Five layers reinforcing the same architecture mean violations would have to slip past every checkpoint - including the generator that won't produce non-conformant code in the first place.
Honest Tradeoffs
This pattern isn't free.
Adoption friction. A new contributor walks into 6+ libraries per domain. There's a learning curve while the structure clicks - usually a few days. After that, the layout is identical across domains, and the consistency pays for itself.
Generator maintenance. Custom generators are code that needs upkeep. When Nx upgrades, when conventions change, when a new lib type emerges - the generator changes too. Plan for someone to own this.
Doesn't fit small projects. A single-app monorepo doesn't benefit from 6 libraries per domain - it's just complexity. Break-even is somewhere around 3+ apps with shared concerns. Below that, fewer libraries with looser boundaries is the right call.
The General Principle
The Nx-specific details are tactical. The principle is portable to any monorepo (Turborepo, Bazel, Yarn workspaces, Rush): structure should be enforced mechanically, not socially.
If you rely on developers to "remember to put it in the right library" or "remember to tag it correctly," you've already lost. The friction accumulates. Apps grow. shared/ becomes the junk drawer.
Make the right thing easy and the wrong thing hard. Then your architecture is real.

Top comments (0)