Most Angular apps don't fail because the framework runs out of steam. They fail around the 18-month mark, when the directory tree turns into a swamp: shared/utils/helpers/index.ts re-exports everything, three teams import each other's internals through it, CI takes 14 minutes because one test change rebuilds the world, and nobody can ship a feature without breaking two others.
The four patterns here — Feature, Core, Shared, and an Nx monorepo with enforced library boundaries — are what stops that. Not 2017 NgModule dogma; the directory-and-dependency-graph discipline underneath, which works just as cleanly with Angular 19 standalone components.
Every section uses the same real app: Mattrx, a multi-tenant marketing-analytics SaaS — Angular 19 standalone, 4 apps in one repo, 540+ components, 22k LOC, 5 product teams sharing a design system and data-access layer.
The mental model: the dependency graph IS the architecture
The single biggest decision in a large Angular app is what's allowed to import what. The arrows go ONE way:
Apps (shells) -> Features (lazy) -> Shared (ui, util)
|
v
Core (imported only by the app shell)
Never:
Shared -> Features (shared can't know about features)
Features -> Features (features can't reach across each other)
Anything -> Core, except the app shell
| Layer | Owns | Imported by |
|---|---|---|
| Core | auth, interceptors, error handler, config, logger | only main.ts, once |
| Shared | buttons, tables, pipes, DTOs - no business logic | any feature |
| Feature | routes, components, services, state for one capability | the app router (lazy) |
| Nx libs | tagged boundaries, ESLint enforcement, nx affected | the whole repo |
Core — the app-singleton layer (modern, no NgModule)
export function provideCore(): EnvironmentProviders {
return makeEnvironmentProviders([
AuthService, ConfigService, LoggerService,
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
provideAppInitializer(() => inject(ConfigService).load()),
]);
}
// apps/customer/src/main.ts — called exactly once
bootstrapApplication(AppComponent, {
providers: [provideCore(), provideRouter(routes), provideAnimationsAsync()],
});
makeEnvironmentProviders is the modern equivalent of the "Core module can only be imported once" guard.
Shared — the cardinal rule
Shared cannot import from Features. Ever. If a "shared" component needs to know about campaigns, it isn't shared — it's a campaigns component.
Each Shared component is its own Nx library, imported at the leaf — no barrel @mattrx/shared/ui that re-exports everything (so the bundler tree-shakes per-library, not per-monolith):
import { MxButton } from '@mattrx/shared/ui/button';import { MxTable } from '@mattrx/shared/ui/table';```
Don't ship a `SharedModule` in 2026 — importing it to use `<mx-button>` drags in `<mx-modal>`, `<mx-toast>`, `<mx-table>`, and everything else. Use standalone leaves.
## Feature — one bounded business capability, lazy-loaded
```ts
// app.routes.ts — the app router doesn't know the feature's internal structure
{ path: 'campaigns', canMatch: [authGuard],
loadChildren: () => import('@mattrx/features/campaigns').then(m => m.CAMPAIGNS_ROUTES) },
The bundler emits one chunk per feature. Landing on /dashboard downloads main.js (~280 KB) + dashboard.chunk.js (~140 KB) — not campaigns, inbox, reports. Feature state is Signals-first (toSignal at the HTTP boundary, computed for derived).
Nx — the structural firewall
Tags declare what each library is and belongs to, and ESLint enforces the arrows:
"@nx/enforce-module-boundaries": ["error", { "depConstraints": [
{ "sourceTag": "type:feature", "onlyDependOnLibsWithTags": ["type:ui","type:data-access","type:util"] },
{ "sourceTag": "type:ui", "onlyDependOnLibsWithTags": ["type:ui","type:util"] },
{ "sourceTag": "type:util", "onlyDependOnLibsWithTags": ["type:util"] },
{ "sourceTag": "scope:customer", "onlyDependOnLibsWithTags": ["scope:customer","scope:shared"] }
]}]
A type:ui library that imports a type:feature library is an ESLint error in CI. That's the difference between "we have a convention" and "the codebase enforces the convention."
And nx affected runs CI only for what the PR touched:
- run: npx nx affected --target=build --base=origin/main # 2.5 min, not 12
The before/after (after the structure landed, 4 weeks)
| Metric | Before | After |
|---|---|---|
| CI on a typical PR | 22 min | 6 min |
| affected:build | 12 min | 2.5 min |
| affected:test | 8 min | 90 s |
| Initial JS (gzipped) | 1.2 MB | 290 KB |
| Cross-team merge conflicts/wk | ~6 | ~1 |
| Scaffold a new feature | ~3 days | ~4 hours |
| "Where does this go?" PR comments/wk | ~12 | ~1 |
| Velocity (features/sprint) | 9 | 14 |
The migration path (4 weeks, not one big PR)
Week 1 — tease apart Core (move Auth, interceptors, error handler, logger, config; replace CoreModule with provideCore()). Week 2 — tease apart Shared (one lib per component, tag everything, turn on @nx/enforce-module-boundaries, fix violations). Week 3 — extract Features one at a time, most isolated first, ~1-2/day. Week 4 — enable nx affected in CI + add any second frontend as a new app that reuses your shared libs.
The right mental model
Enterprise Angular at scale isn't NgModules vs standalone — it's the dependency graph being a layered DAG, and the codebase enforcing that picture so humans don't have to. Core = app-singletons imported once. Shared = presentation + utilities that know no business concepts. Features = lazy-loaded leaves that can't reach each other. Nx = makes the graph visible, enforces it, makes CI fast.
Three habits: generate every library (nx g) with tags from day one; treat boundary violations as test failures; keep apps as thin shells.
The full guide has the real Mattrx layout with file counts, the classic-vs-standalone code for every layer, the nx graph, the full ESLint config, the decision tree for "where does this go?", and the week-by-week migration path:
Originally published on PrepStack.
Top comments (0)