Enterprise Angular Architecture in 2026 — Feature, Core, Shared, and Nx Monorepo (Real Project Layout, Production Metrics)
Core, Shared, Feature modules + Nx monorepo — the enterprise Angular layout that survives 4 apps, 20 libs, 5 teams. Real project + production metrics.
- Author
- Randhir Jassal
- Published
- Reading time
- 22 min read
- Views
- 7 views
Enterprise Angular Architecture in 2026 — Feature, Core, Shared, and Nx Monorepo (Real Project Layout, Production Metrics)
Most Angular apps don't fail because the framework runs out of steam. They fail because, somewhere around the 18-month mark, the directory tree turns into a swamp:
shared/utils/helpers/index.tsre-exports everything, three teams import each other's internals through it, the CI build takes 14 minutes because one test change rebuilds the world, and nobody can introduce a new feature without breaking two others.The four patterns in this guide — Feature modules, Core module, Shared module, and an Nx monorepo with enforced library boundaries — are what stops that. They are not 2017-style NgModule dogma. They are the directory-and-dependency-graph discipline underneath, which works just as cleanly with Angular 19's standalone components.
Every section uses the same real app: Mattrx, a multi-tenant marketing analytics SaaS — Angular 19 standalone, 4 apps in one repo (customer SaaS, admin console, marketing site, status page), 540+ components, 22,000 LOC TypeScript, 5 product teams sharing a design system and data-access layer.
TL;DR
| Layer | Purpose | What it owns | Who imports it |
|---|---|---|---|
| Core | App-singletons + cross-cutting concerns | Auth, HTTP interceptors, error handler, logging, env config, app-init guards | Only the AppComponent / app shell — imported once |
| Shared | Reusable presentation pieces | Buttons, inputs, tables, icons, pipes, directives — no business logic, no services | Any feature library |
| Feature | One bounded business capability | Routes, components, services, state for that feature only | The app router only (lazy-loaded) |
| Nx libs | Enforces all of the above | Tagged library boundaries, ESLint enforcement, nx affected, computation cache | The whole monorepo |
The shape in one line: Apps are thin shells. Features are lazy-loaded leaves. Shared is presentational glue. Core is app-singleton wiring. Nx enforces who can import who.
Mattrx wins after the structure landed (4 weeks of work):
nx affected:build: 12 min full → 2.5 min affected (5× faster)nx affected:test: 8 min full → 90 s affected (5× faster)- CI duration on a typical PR: 22 min → 6 min
- Cross-team merge conflicts: ~6/week → ~1/week
- Cross-library "leaky import" violations caught at lint: 14/PR (first week) → 0/PR (week 4)
- Time to scaffold a new feature: 3 days → 4 hours (Nx generator + tag + route)
- Onboarding "where does this go?" questions in PRs: ~12/week → ~1/week
- Production-bundle initial JS: 1.2 MB → 290 KB (lazy features did most; structure made lazy easy)
The architecture wasn't the only reason for those wins — but it was the enabler of all of them.
1. Mattrx — the running production example
/mattrx ← single Nx workspace
├── apps/
│ ├── customer/ ← the main SaaS app (login required)
│ ├── admin/ ← back-office console (different auth)
│ ├── marketing/ ← marketing site (SSR / hydration)
│ └── status/ ← public status page
└── libs/
├── core/ ← app-singleton wiring (per app)
├── shared/
│ ├── ui/ ← presentational components (Button, Modal, etc.)
│ ├── data-access/ ← typed HTTP clients, models
│ ├── util/ ← pure utility fns (no Angular deps)
│ └── design-tokens/ ← tokens shared across apps + marketing
└── features/
├── dashboard/ ← /dashboard
├── campaigns/ ← /campaigns
├── inbox/ ← /inbox (WebSocket-driven)
├── reports/ ← /reports/* (builder + viewer)
├── settings-team/ ← /settings/team
└── settings-billing/ ← /settings/billing
- 4 apps, 9 feature libs, ~12 shared libs.
- Built with Nx 19 + Angular 19 standalone components, OnPush, Signals + RxJS interop.
- 5 product teams map ~1:1 to feature libraries; the design system team owns
shared/ui; a platform team ownscoreand the data-access HTTP layer.
That structure is what this guide walks you through.
2. The mental model — the dependency graph is the architecture
The single biggest architectural decision in any large Angular app is what's allowed to import what. Get that right and the rest falls out; get it wrong and no amount of clever code survives.
THE ALLOWED DEPENDENCY DIRECTION
┌──────────┐
│ Apps │ (customer, admin, marketing, status)
│ (shells) │ • Knows the router. Knows nothing about features.
└────┬─────┘
│ may import
▼
┌──────────┐ ┌────────┐
│ Features │ -> │ Shared │
│ (lazy) │ │ (UI, │ • Features depend on shared
└────┬─────┘ │ util) │ • Shared depends on nothing app-y
│ └────────┘
│ may import
▼
┌──────────┐
│ Core │ (per-app cross-cutting; imported only by the AppComponent)
└──────────┘
The arrows go ONE WAY. Never:
• Shared → Features (shared can't know about features)
• Features → Features (features can't reach across each other)
• Anything → Core, except the app shell
The rest of this guide is a concrete way to express and enforce that picture.
3. Core — the app-singleton layer
Core holds the things you want exactly one instance of, app-wide: HTTP interceptors, the auth service, the error handler, environment config, the logger, app-bootstrap guards.
3.1 What goes in Core
AuthService— the single source of truth for "who is the current user".- HTTP interceptors — auth header, telemetry, error normalization, retry.
LoggerService— wraps console + Sentry + correlation IDs.ConfigService— readswindow.__ENV__orenvironment.ts, exposes typed config.AppInitializer— boot-time work (load feature flags, restore session).- The global error handler.
3.2 What does NOT go in Core
- Components (buttons, inputs, tables) — that's
shared/ui. - Anything tied to a specific feature (campaigns, inbox, reports) — that's a feature library.
- Pure utilities (date formatters, currency helpers) — that's
shared/util.
3.3 The classic NgModule shape
// libs/core/src/lib/core.module.ts — legacy NgModule for reference
@NgModule({
providers: [
AuthService,
ConfigService,
LoggerService,
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true },
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
{ provide: APP_INITIALIZER, useFactory: appInit, deps: [ConfigService, AuthService], multi: true },
],
})
export class CoreModule {
// The "anti-double-import" guard
constructor(@Optional() @SkipSelf() parent?: CoreModule) {
if (parent) {
throw new Error('CoreModule must only be imported in AppModule');
}
}
}
The parent check is the classic guard that prevented anyone from importing CoreModule twice and accidentally creating duplicate singletons.
3.4 The modern (Angular 19) standalone equivalent
// libs/core/src/lib/provide-core.ts — modern, no NgModule needed
import { EnvironmentProviders, makeEnvironmentProviders, ErrorHandler,
provideAppInitializer, inject } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
export function provideCore(): EnvironmentProviders {
return makeEnvironmentProviders([
AuthService,
ConfigService,
LoggerService,
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
provideAppInitializer(() => {
const config = inject(ConfigService);
return config.load();
}),
]);
}
// apps/customer/src/main.ts — the app shell calls provideCore() exactly once
bootstrapApplication(AppComponent, {
providers: [
provideCore(), // ← app-singleton layer
provideRouter(routes, withPreloading(PreloadAllModules)),
provideAnimationsAsync(),
],
});
makeEnvironmentProviders returns an opaque token bundle — it's the modern equivalent of the "Core module can only be imported once" guard. Feature libraries that try to provideCore() again won't get duplicate services; the providers are root-scoped by design.
3.5 Mattrx Core (real)
libs/core/src/lib/
├── provide-core.ts ← the public surface
├── auth/
│ ├── auth.service.ts ← Signal<User | null>
│ ├── auth.guard.ts ← CanActivateFn
│ ├── auth.interceptor.ts ← adds Bearer + tenant header
│ └── auth.routes.ts ← /login, /logout (only one)
├── http/
│ ├── error.interceptor.ts ← normalizes 4xx/5xx + Sentry breadcrumbs
│ └── telemetry.interceptor.ts
├── error/
│ └── global-error.handler.ts
├── config/
│ ├── config.service.ts ← typed env config Signal
│ └── env.ts ← reads window.__ENV__
└── logger/
└── logger.service.ts ← console + Sentry wrapper
Total: 9 service files, ~600 LOC, imported once by every app's main.ts and never touched by features directly.
3.6 The rule
Core is for app-singletons. Imported by main.ts. Never imported by features. One provideCore() per app.
4. Shared — reusable, presentational, business-free
Shared is for things many features need but that have no business knowledge. A button doesn't know what a "campaign" is. A date-format pipe doesn't know about tenants.
4.1 The cardinal rule of Shared
Shared cannot import from Features. Ever.
If a "shared" component needs to know about campaigns, it isn't shared — it's a campaigns component.
This is the rule that, when broken, destroys monorepos. The moment shared/ui/campaign-status-pill.component.ts exists, you have a cycle: editing the Campaign type breaks shared; touching shared rebuilds everything. Don't do it.
4.2 What Mattrx puts in Shared
libs/shared/
├── ui/ ← presentational components
│ ├── button/ ← <mx-button variant="primary">
│ ├── input-text/
│ ├── modal/
│ ├── table/ ← generic table primitive (data-agnostic)
│ ├── skeleton/ ← loading skeletons
│ ├── empty-state/
│ ├── toast/
│ └── icon/ ← lazy-loaded sprite
├── data-access/
│ ├── http/ ← typed wrappers around HttpClient
│ ├── models/ ← Campaign, User, Event, etc. (DTOs)
│ └── pagination.ts ← shared cursor type
├── util/ ← pure functions, no Angular deps
│ ├── date.ts
│ ├── currency.ts
│ ├── slugify.ts
│ └── async.ts ← debounce / sleep / withTimeout
└── design-tokens/ ← tailwind config + CSS vars
4.3 The standalone Shared component pattern
Each Shared component is its own library (Nx library), not a barrel. Imports are leaf-level.
// libs/shared/ui/button/src/lib/mx-button.component.ts
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
@Component({
selector: 'mx-button',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<button [type]="type()" [disabled]="disabled()"
[class]="'mx-btn mx-btn-' + variant()"
(click)="clicked.emit($event)">
<ng-content />
</button>
`,
styles: [/* …Tailwind classes, design-token CSS… */],
})
export class MxButton {
variant = input<'primary' | 'secondary' | 'ghost'>('primary');
type = input<'button' | 'submit'>('button');
disabled = input(false);
clicked = output<MouseEvent>();
}
Features import the leaf:
// libs/features/campaigns/src/lib/list/campaigns-list.component.ts
import { MxButton } from '@mattrx/shared/ui/button';
import { MxTable } from '@mattrx/shared/ui/table';
import { MxEmptyState } from '@mattrx/shared/ui/empty-state';
No barrel @mattrx/shared/ui that re-exports everything. Each libs/shared/ui/<name> has its own package.json (Nx generates this) so the bundler tree-shakes per-library, not per-monolith.
4.4 The classic SharedModule (NgModule version) — what NOT to do
// ❌ legacy — drags everything into every consumer
@NgModule({
declarations: [ButtonComponent, InputComponent, ModalComponent, TableComponent,
SkeletonComponent, EmptyStateComponent, ToastComponent, IconComponent],
exports: [ButtonComponent, InputComponent, ModalComponent, TableComponent,
SkeletonComponent, EmptyStateComponent, ToastComponent, IconComponent],
imports: [CommonModule, FormsModule],
})
export class SharedModule {}
Importing SharedModule to use <mx-button> also imports <mx-modal>, <mx-toast>, <mx-table>, etc. — even if you don't use them. Tree-shaking helps in prod builds, but only partially. Don't ship a SharedModule in 2026. Use standalone leaves.
4.5 The rule
Shared is presentation + utilities. Per-feature standalone leaves, no barrels. Cannot import Features. Cannot import Core.
5. Feature — one bounded business capability
Each feature library owns the routes, components, state, and feature-specific services for one business capability. It does not know about other features.
5.1 The Mattrx feature layout
libs/features/campaigns/
├── src/lib/
│ ├── lib.routes.ts ← the route table this feature exports
│ ├── list/ ← /campaigns
│ │ ├── campaigns-list.component.ts
│ │ ├── campaigns-list.component.html
│ │ └── campaigns-list.component.spec.ts
│ ├── detail/ ← /campaigns/:id
│ │ ├── campaign-detail.component.ts
│ │ └── campaign-detail.component.html
│ ├── new/ ← /campaigns/new
│ │ └── new-campaign.component.ts
│ ├── data/ ← FEATURE-SCOPED data layer
│ │ ├── campaigns.service.ts ← uses @mattrx/shared/data-access/http
│ │ └── campaigns.signals.ts ← feature state via signals
│ └── ui/ ← FEATURE-LOCAL ui (not shared)
│ ├── campaign-row.component.ts
│ └── campaign-status-pill.component.ts
├── project.json ← Nx project config
├── tsconfig.json
└── README.md ← who owns it, what it does
Two important details:
ui/here is feature-local. Anything that genuinely needs business knowledge ("this pill shows campaign status with our specific colors") lives in the feature, not inshared/ui.data/is feature-scoped. It can useshared/data-access/httpto make HTTP calls, but theCampaignsServiceitself stays in the feature.
5.2 Lazy-loaded by the app router
// apps/customer/src/app/app.routes.ts
import { Routes } from '@angular/router';
import { authGuard } from '@mattrx/core/auth';
export const APP_ROUTES: Routes = [
{ path: '', loadComponent: () => import('@mattrx/core/auth').then(m => m.LoginComponent) },
// Each feature exports its own route table — app router doesn't need to know structure
{ path: 'dashboard',
canMatch: [authGuard],
loadChildren: () => import('@mattrx/features/dashboard').then(m => m.DASHBOARD_ROUTES) },
{ path: 'campaigns',
canMatch: [authGuard],
loadChildren: () => import('@mattrx/features/campaigns').then(m => m.CAMPAIGNS_ROUTES) },
{ path: 'inbox',
canMatch: [authGuard],
loadChildren: () => import('@mattrx/features/inbox').then(m => m.INBOX_ROUTES) },
{ path: 'reports',
canMatch: [authGuard],
loadChildren: () => import('@mattrx/features/reports').then(m => m.REPORTS_ROUTES) },
];
// libs/features/campaigns/src/lib/lib.routes.ts
import { Routes } from '@angular/router';
export const CAMPAIGNS_ROUTES: Routes = [
{ path: '', loadComponent: () => import('./list/campaigns-list.component').then(m => m.CampaignsListComponent) },
{ path: 'new', loadComponent: () => import('./new/new-campaign.component').then(m => m.NewCampaignComponent) },
{ path: ':id', loadComponent: () => import('./detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
];
The bundler emits one chunk per feature. The user landing on /dashboard downloads:
main.js(the app shell + Core: ~280 KB)dashboard.chunk.js(~140 KB)
They do not download campaigns, inbox, reports, settings, etc. Click on campaigns — that chunk fetches in <300ms (and was prefetched in idle time anyway).
5.3 Feature state — Signals first
// libs/features/campaigns/src/lib/data/campaigns.service.ts
import { inject, Injectable, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
import { Campaign } from '@mattrx/shared/data-access/models';
@Injectable({ providedIn: 'root' })
export class CampaignsService {
private http = inject(HttpClient);
// local feature state
readonly query = signal('');
readonly selected = signal<Set<string>>(new Set());
// server data (HTTP → Signal at the boundary)
readonly campaigns = toSignal(
this.http.get<Campaign[]>('/api/campaigns'),
{ initialValue: [] as Campaign[] },
);
// derived
readonly filtered = computed(() =>
this.campaigns().filter(c => c.name.toLowerCase().includes(this.query().toLowerCase())),
);
// mutations stay in the feature, too
archive(id: string) {
return this.http.post(`/api/campaigns/${id}/archive`, {});
}
}
This service is providedIn: 'root' but it lives in the feature library. It only gets instantiated when the user navigates to /campaigns (because the lazy chunk hasn't loaded otherwise).
5.4 The rule
One feature = one library = one bounded business capability. Its routes, components, services, and state live together. It depends on shared/* and core/* only. It does not import other features.
6. Nx Monorepo — the structure that enforces all this
A monorepo is the architecture for an app of Mattrx's size. The reasons are concrete:
- One
package.json+ one lockfile across 4 apps + 20 libs → consistent versions. nx affected→ CI only rebuilds and tests the impact of a PR, not the world.- The dependency graph is visible (
nx graph) and enforceable (@nx/enforce-module-boundaries). - Shared design tokens, models, and util libraries live in one place, not as private packages.
6.1 The Nx workspace at Mattrx
npx create-nx-workspace@latest mattrx --preset=angular-monorepo
Once set up, generators do the boring work:
# Add a new app
npx nx g @nx/angular:app admin --standalone --routing
# Add a feature library
npx nx g @nx/angular:lib features/billing --standalone --buildable --tags=type:feature,scope:customer
# Add a shared UI library (one component)
npx nx g @nx/angular:lib shared/ui/dropdown --standalone --buildable --tags=type:ui,scope:shared
# Run only what your change affects
npx nx affected:test --base=main
npx nx affected:build --base=main
The tags in those commands are the architecture enforcement. Each library declares what it is (type:feature, type:ui, type:data-access, type:util, type:core) and what scope it belongs to.
6.2 The dependency graph — visible
npx nx graph
Renders a clickable graph of every project and every import edge. You can spot illegal arrows visually before you even run lint:
┌─────────────────┐
│ app: customer │
└────────┬─────────┘
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
feat:dashboard feat:campaigns feat:inbox
│ │ │
│ ┌─────────────┴─────────────┐ │
▼ ▼ ▼ ▼
shared/ui (button, table, ...) shared/data-access (http, models)
│ │
▼ ▼
shared/util (no further deps)
The graph should look like a layered DAG. If you see a feature → feature edge, or a shared → feature edge, you have a leak.
6.3 Enforce boundaries with ESLint (the bit that makes it stick)
// .eslintrc.json — Nx's module-boundaries rule (real Mattrx config)
{
"rules": {
"@nx/enforce-module-boundaries": [
"error",
{
"depConstraints": [
{ "sourceTag": "scope:customer", "onlyDependOnLibsWithTags": ["scope:customer", "scope:shared"] },
{ "sourceTag": "scope:admin", "onlyDependOnLibsWithTags": ["scope:admin", "scope:shared"] },
{ "sourceTag": "scope:shared", "onlyDependOnLibsWithTags": ["scope:shared"] },
{ "sourceTag": "type:app", "onlyDependOnLibsWithTags": ["type:feature", "type:ui", "type:data-access", "type:util", "type:core"] },
{ "sourceTag": "type:feature", "onlyDependOnLibsWithTags": ["type:ui", "type:data-access", "type:util"] },
{ "sourceTag": "type:ui", "onlyDependOnLibsWithTags": ["type:ui", "type:util"] },
{ "sourceTag": "type:data-access", "onlyDependOnLibsWithTags": ["type:data-access", "type:util"] },
{ "sourceTag": "type:util", "onlyDependOnLibsWithTags": ["type:util"] },
{ "sourceTag": "type:core", "onlyDependOnLibsWithTags": ["type:data-access", "type:util"] }
]
}
]
}
}
What this means:
- A
type:uilibrary cannot import atype:featurelibrary. Trying to gives you an ESLint error in CI. - A
type:utillibrary cannot import anything Angular-specific (sincetype:utilonly depends ontype:util). scope:customerlibraries can't accidentally pull fromscope:admin.
This is the structural firewall. It's the difference between "we have a convention" and "the codebase enforces the convention."
6.4 nx affected — the CI speedup that pays for the migration
# .github/workflows/pr.yml — runs only what the PR actually changed
- run: npx nx affected --target=lint --base=origin/main
- run: npx nx affected --target=test --base=origin/main
- run: npx nx affected --target=build --base=origin/main
Nx computes the dependency graph, sees that your PR touched libs/features/campaigns, and runs the targets only for campaigns and its transitive dependents. For Mattrx:
- Full build of 4 apps + 20 libs: 12 minutes in CI.
- Typical PR's affected build: 2.5 minutes.
- That's a 5× speedup on every PR, every day, for every engineer.
Combined with Nx's computation cache (results stored, replayed if inputs unchanged), a re-run of the same job takes seconds.
6.5 Nx generators — the scaffolder that beats copy-paste
When a new feature needs to exist:
npx nx g @nx/angular:lib features/billing-portal \
--standalone --buildable --strict --change-detection=OnPush \
--tags=type:feature,scope:customer
This emits:
libs/features/billing-portal/with the standard layout.- A pre-tagged ESLint config inheriting the boundary rules.
- A
project.jsonwired forlint,test,build. - A skeleton component with
OnPush, standalone, strict TS. - Path mapping in
tsconfig.base.json→@mattrx/features/billing-portal.
4 hours to a new lazy-loaded feature route, versus a few days of manual config and arguing about structure. We measured.
7. The Mattrx project structure (real, with file counts)
mattrx/ [21,940 LOC TypeScript]
├── apps/
│ ├── customer/ (282 LOC) ← thin shell + routes
│ ├── admin/ (190 LOC)
│ ├── marketing/ (412 LOC, SSR enabled)
│ └── status/ (74 LOC)
├── libs/
│ ├── core/
│ │ ├── auth/ (480 LOC)
│ │ ├── http/ (240 LOC)
│ │ ├── error/ (95 LOC)
│ │ ├── config/ (120 LOC)
│ │ └── logger/ (60 LOC)
│ ├── shared/
│ │ ├── ui/button/ (75 LOC)
│ │ ├── ui/input-text/ (110 LOC)
│ │ ├── ui/modal/ (180 LOC)
│ │ ├── ui/table/ (340 LOC) ← virtualized
│ │ ├── ui/skeleton/ (35 LOC)
│ │ ├── ui/empty-state/ (40 LOC)
│ │ ├── ui/toast/ (160 LOC)
│ │ ├── ui/icon/ (95 LOC)
│ │ ├── data-access/http/ (220 LOC) ← typed HttpClient wrappers
│ │ ├── data-access/models/ (510 LOC) ← DTOs + zod schemas
│ │ ├── util/date/ (140 LOC)
│ │ ├── util/currency/ (60 LOC)
│ │ ├── util/async/ (90 LOC)
│ │ └── design-tokens/ (Tailwind config + CSS vars)
│ └── features/
│ ├── dashboard/ (1,820 LOC)
│ ├── campaigns/ (3,940 LOC)
│ ├── inbox/ (2,610 LOC)
│ ├── reports/ (4,210 LOC)
│ ├── settings-team/ (1,180 LOC)
│ └── settings-billing/ (1,490 LOC)
└── tools/ (Nx generators, custom executors)
A few invariants worth naming:
- Apps are tiny. The customer app is 282 LOC. Everything heavy is a feature library.
- Features are the biggest libraries. That's correct — that's where the business logic lives.
- Shared/ui components are small. A button is 75 lines. A modal is 180. They have no business knowledge.
- No barrel index files in shared. Each leaf is its own path.
8. The aggregate Mattrx metrics (before/after structural migration)
| Metric | Before (single app, no Nx) | After (Nx + Core/Shared/Feature) |
|---|---|---|
| CI duration on typical PR | 22 min | 6 min |
affected:build | full 12 min (no concept of affected) | 2.5 min |
affected:test | full 8 min | 90 s |
| Initial JS bundle (gzipped) | 1.2 MB | 290 KB |
| Cross-team merge conflicts / week | ~6 | ~1 |
| Boundary violations caught at lint | n/a | 14/PR week 1 → 0/PR week 4 |
| Time to scaffold a new feature | ~3 days | ~4 hours |
| Onboarding "where does this go?" PR comments / week | ~12 | ~1 |
| Mean time to fix a bug in a feature | 2.1 days | 0.8 day |
| Frontend team velocity (features merged / sprint) | 9 | 14 |
The architecture didn't do every one of those wins on its own. Smaller bundles came from lazy loading; faster CI came from nx affected; fewer conflicts came from features being independent. But all of them depended on the structure being right first.
9. Migration path — what we'd recommend (and what we did at Mattrx)
If you're going from a single Angular app to this, four weeks is realistic. Don't try to do it all in one PR.
WEEK 1 — Tease apart Core
├── Create libs/core/* via Nx generators
├── Move AuthService, interceptors, error handler, logger, config
├── Replace CoreModule import in AppModule with provideCore()
└── Verify: only AppComponent provides Core. CI green.
WEEK 2 — Tease apart Shared
├── Move shared components into libs/shared/ui/<name> (one lib per component)
├── Move pure utilities into libs/shared/util/<name>
├── Move DTOs + zod schemas into libs/shared/data-access/models
├── Tag every shared lib type:ui or type:util or type:data-access
└── Turn on @nx/enforce-module-boundaries. Fix violations.
WEEK 3 — Extract Features (one at a time)
├── Pick the most isolated feature first (Mattrx: settings-billing)
├── nx g @nx/angular:lib features/<name> --standalone --tags=type:feature,scope:customer
├── Move feature components + service + state in
├── Replace route from `loadChildren` of an NgModule → import of feature's ROUTES
├── Verify: feature renders, no lint errors, bundle dropped
└── Repeat for every feature. ~1–2 features per day.
WEEK 4 — Enable `nx affected` + multi-app
├── Set base branch in CI to origin/main
├── Convert CI to use nx affected --target=...
├── If you have a second frontend (admin, marketing, status), create it as a new app:
│ nx g @nx/angular:app <name> --standalone --routing
│ it can immediately consume your shared libraries
└── Document the architecture in /docs/ARCHITECTURE.md
9.1 Things that bit us during migration
- Circular library deps showed up exactly twice. Nx detects them at build; the fix was always "extract the shared bit into a smaller lower-level library".
- A
shared/ui/page-headercomponent had business knowledge (it knew about the user's tenant). It was a mis-classification — moved intolibs/features/_layout(a small infrastructure feature that contains the app chrome). - Two features wanted to share a small UI primitive (a pill). We resisted putting it in
shared/ui; instead the second feature imported from the first ONCE; on the third use we extracted toshared/ui. The "Rule of Three" applied here. @nx/enforce-module-boundariesviolations spiked the first week. That's expected: it's surfacing the structural debt that was always there. We fixed them in a series of small PRs rather than one big one.
10. Decision tree (the cheat sheet)
START — "where do I put this thing?"
│
▼
Does it have business knowledge (knows about Campaign, Inbox, etc.)?
├── YES → It belongs to a Feature.
│ Which feature? Whose domain is it?
│ ├── Owned by one feature → libs/features/<that>/
│ └── Genuinely cross-feature business concern → another feature, NOT shared
│
└── NO → It's presentation, utility, or data-access.
├── Reusable UI component (button, modal, table)? → libs/shared/ui/<name>/
├── Pure function (date format, currency)? → libs/shared/util/<name>/
├── HTTP client / DTO / model? → libs/shared/data-access/<name>/
└── App singleton (auth, interceptor, error handler)? → libs/core/<name>/
When in doubt: it belongs in a Feature. Promote to Shared only after the Rule of Three.
11. Honest stuff
- Nx is overhead until your team is ~5+ engineers or you have multiple frontends. For a 2-engineer SPA, a single
apps/customeris fine. Don't pre-optimize. - The biggest wins come from
nx affectedand from forcing the dependency graph to be a DAG. Both are gained by setting up Nx well once and then never thinking about it again. - Standalone components made the Core/Shared/Feature pattern easier, not obsolete. The pattern is about the dependency graph, not about NgModules. Standalone components express the same shape in less code.
- Don't put business knowledge in Shared. This single mistake breaks every monorepo eventually. ESLint boundary tags catch it before it ships.
- Features are leaves. They can't import each other. If two features genuinely need to share, extract a new library at the right level — Shared (if it has no business knowledge) or a new lower-level feature library (if it has).
- Onboarding is the silent metric. When the answer to "where does this go?" is mechanical, new engineers ship in week one instead of week three.
12. The mental checklist
Before merging any PR in an Nx monorepo:
- Does this change belong in the smallest library that contains all its dependencies? (If it'd live just as well in a lower-level library, move it down.)
- Did I introduce a cross-library import the ESLint boundary rule should flag?
- Is anything new in
shared/*that mentions a business concept (Campaign, Tenant, Inbox)? - Did I add a new singleton service in a feature library that should be in
core/*? - Does the new code follow the feature's own conventions (Signals + OnPush + lazy route)?
- Did
nx affected:lintandnx affected:testactually run the new tests? - Is the
nx graphstill a clean DAG (no cycles)?
13. Closing — the right mental model
Enterprise Angular at scale isn't about NgModules vs standalone components. It's about the dependency graph being a layered DAG, and the codebase enforcing that picture so humans don't have to.
The four patterns are how you get there:
- Core — app-singletons, imported once.
- Shared — presentation + utilities, knows no business concepts.
- Features — business capabilities, lazy-loaded leaves, can't reach each other.
- Nx monorepo — makes the graph visible, enforces it with ESLint, makes CI fast with
nx affected.
Three habits that make this stack pay off long-term:
- Generate, don't hand-roll. Every new library through
nx g @nx/angular:lib. Tags from day one. No exceptions, even for "tiny" libs. - Treat boundary violations as test failures. They mean someone is about to create the leak that will rot the codebase. Fix the design, don't suppress the rule.
- Apps are thin shells. Reroute on the first feature, lazy-load it. Don't let the app shell accumulate logic.
Apply that, and the next person hired to your codebase ships in their first week. The one after that ships in their first day. That's what enterprise architecture is for.
Further reading
- Nx docs — Module boundaries — the rule that makes this enforceable.
- Nx docs —
nx affected— the CI speedup. - Angular docs —
makeEnvironmentProviders— the modernforRoot()replacement. - Angular docs — Standalone components — the building blocks.
- Angular docs — Routing & lazy loading — the feature delivery mechanism.
- PrepStack — Angular Performance Optimization Guide — the perf playbook this architecture enables.
- PrepStack — Angular Signals vs RxJS — what to use inside the feature libraries.
Considering an Nx migration or splitting a monolithic Angular app into Core/Shared/Feature? Email randhir.jassal@gmail.com with the rough structure and team shape — happy to point at the boundary lines that will save the most pain.
Get the next issue
A short, curated email with the newest posts and questions.