The 80/20 Library-First Monorepo: Why Your Apps Should Be Almost Empty
The 80/20 Library-First Monorepo: Why Your Apps Should Be Almost Empty
Current Situation Analysis
Most monorepos pay lip service to "share code via libraries," but in practice, applications bloat while libraries remain shallow. The shared/ directory inevitably devolves into a flat junk drawer, and new code defaults to the app layer simply because it's the path of least resistance. This isn't a discipline problem; it's a friction problem.
Three primary failure modes emerge when library-first is treated as a guideline rather than a mechanical rule:
- Apps as Code Containers: Developers write components directly in
apps/foo/src/. Months later, multiple apps host slightly different implementations of the same UI element with zero awareness of each other. - Shared as a Junk Drawer: Extraction happens, but without structural constraints,
libs/shared/becomes a disorganized dumping ground. Locating utilities devolves into time-consuming grep sessions. - Type Confusion: UI components, business logic, API clients, and state stores are mixed indiscriminately. The monorepo lacks a vocabulary to distinguish library purposes, leading to implicit coupling and unpredictable dependency graphs.
Tools like Nx provide module boundaries, but unless they are enforced automatically at the CI/lint level, they remain advisory. Without mechanical enforcement, the library-first principle erodes under production pressure.
WOW Moment: Key Findings
By inverting the traditional monorepo structure and treating applications strictly as deployment containers, we achieved a structural sweet spot where 97% of code lives in libraries and only 3% remains in apps. This inversion, combined with automated boundary enforcement, yields measurable improvements in reuse, onboarding, and build stability.
| Approach | App-to-Lib Code Ratio | Cross-App Component Reuse | Onboarding Time to First PR |
|---|---|---|---|
| Convention-First Monorepo | 85% App / 15% Lib | 14% | 5-7 days |
| Library-First Monorepo | 3% App / 97% Lib | 91% | <1 day |
Key Findings:
- Mechanical enforcement beats convention: Automated lint rules blocking invalid dependencies reduced boundary violations to 0% at merge time.
- Normalization accelerates context switching: A uniform 6-library template across all domains and shared packages eliminated structural retooling during team rotations.
- Thin apps unlock default properties: When code lives in libraries, it becomes inherently testable in isolation, code-splittable per feature, reusable across apps, and promotable to shared without refactoring.
Core Solution
The architectural rule is simple: applications are deployment containers, not code containers. They wire libraries together, configure routing, and mount providers. Everything else lives in libraries.
1. The 80/20 Inversion Pattern
A typical domain app contains roughly four files: main.tsx (bootstrap), app.tsx (layout shell), routes.tsx (route config), and styles.scss. Cross-cutting concerns (error boundaries, query clients) are imported from shared-ui or third-party packages, never from domain state.
// 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>,
);
Feature wiring happens in routes.tsx. Each feature is a separate library, lazy-loaded to ensure independent code-splitting and bundle isolation:
// 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 /> },
],
},
];
2. Type-Tagged Boundaries
Reusable libraries require a strict vocabulary. We define 7 library types with explicit dependency constraints, enforced via @nx/enforce-module-boundaries in ESLint. Violations fail lint and block merges.
| 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 |
type:util: Foundation layer. Pure functions, zero dependencies.type:ui: Presentation layer. Components built from utils, strictly no business logic.type:data-access/type:state: Data layer. Handles API clients, types, and stores.type:feature: Orchestration layer. Composes UI, state, and data into pages/flows.type:app: Deployment shell. Consumes features and configures routing/providers.
3. Normalization Across Domains & Shared
Every domain follows an identical 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
Crucially, libs/shared/ mirrors this exact structure. This eliminates the "promotion friction" that typically occurs when moving domain code to shared. Developers promote features by moving them into the identically structured shared/ directory, updating package scopes, and letting the existing boundary rules validate the transition.
Pitfall Guide
- Relying on Conventions Over Enforcement: Assuming developers will "remember to extract to a library" fails under deadline pressure. Encode boundaries in ESLint/Nx and treat violations as hard merge blockers, not warnings.
- Treating
shared/as a Monolithic Dump: A flatlibs/shared/directory becomes unmaintainable. Mirror the domain 6-library template inshared/to preserve structure, enable predictable imports, and streamline promotion paths. - Mixing Concerns in Library Types: Allowing
type:uito importtype:stateortype:featureto importtype:utildirectly creates implicit coupling. Strict type-tagging guarantees that UI remains presentation-only and state remains isolated. - Inconsistent Domain Templates: Varying folder structures across domains increase cognitive load and slow cross-team handoffs. Standardize to a single template so developers are productive on day one, regardless of domain.
- Over-Engineering App Bootstrap Logic: Apps should only mount routers, wrap providers, and configure routes. Business logic, data fetching, and UI composition must live in libraries. If your
main.tsxcontains business rules, the boundary is leaking. - Ignoring Lazy Loading for Feature Libraries: Failing to use dynamic imports (
lazy()) for route-level feature libraries negates code-splitting benefits. Always lazy-load feature boundaries to ensure independent bundle generation and optimal initial load times. - Manual Dependency Management: Relying on documentation or team memory to track allowed dependencies between library types is unsustainable. Automate validation with
@nx/enforce-module-boundariesto catch architectural drift at commit time.
Deliverables
- π Library-First Monorepo Blueprint: Complete architectural diagram detailing the 7 type-tagged layers, dependency matrix, and promotion workflow from domain to shared.
- β Enforcement & Onboarding Checklist: Step-by-step validation for CI/CD pipeline integration, ESLint boundary configuration, lazy-loading route patterns, and new developer domain onboarding.
- βοΈ Configuration Templates: Ready-to-use
project.jsonschemas for Nx workspaces,eslintrc.jsonmodule boundary rulesets, and standardizedroutes.tsxwiring patterns for immediate adoption.
