"A scalable Angular architecture is less about folders — and more about ownership, isolation, and dependency direction."
Most Angular architectures collapse long before the app becomes "large."
I've reviewed dozens of enterprise frontends. The pattern is always the same: the app starts clean, features stack up, a shared/ folder quietly becomes a junk drawer, and six months later nobody can tell whose code owns what — or why everything imports from everywhere.
The problem isn't complexity. It's that teams organised code around components instead of business capabilities.
This is a complete teardown of the modular setup I use in production. Every rule here is battle-tested across codebases with 20–80+ developers. No beginner folder advice. No generic "clean architecture" talk.
Let's go.
Table of Contents
- Why Flat Folder Structures Fail at Scale
- The Core Principle: Apps Are Orchestrators
- Domain-Driven Library Structure with Nx
- The Four Library Types
- Dependency Governance: enforce-module-boundaries
- The Shared Library Problem
- Signals-Ready State Architecture
- Standalone APIs and Modern Angular Patterns
- Team Scalability: 5 Devs → 50 Devs
- Architecture Governance Checklist
- The Senior Dev Rule
Why Flat Folder Structures Fail at Scale {#why-flat-folder-structures-fail}
Let me show you what a typical Angular project looks like six months after launch with a growing team:
src/
app/
components/
header/
footer/
login-form/
invoice-list/
invoice-detail/
payment-modal/
analytics-chart/
user-profile/
... (200 more)
services/
auth.service.ts
billing.service.ts
analytics.service.ts
user.service.ts
http-interceptor.service.ts
... (50 more)
models/
user.model.ts
invoice.model.ts
... (30 more)
store/
auth.actions.ts
billing.reducer.ts
analytics.effects.ts
... (100 more)
This structure looks organised. It's not.
It's organised around technical layers (components, services, models), not around business capabilities. The result:
- A
BillingServiceimports fromAuthServicewhich imports from aSharedUtilsServicewhich somehow imports aBillingModel— you now have a circular dependency nobody can untangle. - A developer fixing the invoice flow has to touch 6 different directories.
- Two teams working on
authandbillingsimultaneously are constantly in each other's files. - There's no concept of "who owns this code."
-
shared/absorbs everything nobody knows where else to put.
The architecture hasn't failed because the codebase grew. It failed because there were never any boundaries.
The Core Principle: Apps Are Orchestrators {#apps-are-orchestrators}
Before we go further, this is the most important rule in the entire system:
Apps are orchestrators. They should not own business logic.
The app-shell (or whatever your entry application is) should do exactly three things:
- Bootstrap the application
- Configure top-level routing
- Wire domain libraries together
That's it.
If your app/ directory contains feature components, business services, or domain state — you've already broken the first boundary. Everything else in this article depends on this principle being true.
apps/
shell/ ← orchestrator only. routing + bootstrapping.
admin-portal/ ← separate app for the admin portal
// apps/shell/src/app/app.routes.ts
export const APP_ROUTES: Routes = [
{
path: 'auth',
loadChildren: () =>
import('@myorg/auth/feature-login').then(m => m.AUTH_ROUTES)
},
{
path: 'billing',
loadChildren: () =>
import('@myorg/billing/feature-invoices').then(m => m.BILLING_ROUTES)
},
{
path: 'analytics',
loadChildren: () =>
import('@myorg/analytics/feature-dashboard').then(m => m.ANALYTICS_ROUTES)
}
];
Notice what's missing: zero business logic, zero service imports, zero state. The app shell is just a traffic controller.
Domain-Driven Library Structure with Nx {#domain-driven-library-structure}
The full workspace structure looks like this:
apps/
shell/
admin-portal/
libs/
auth/
feature-login/
feature-register/
data-access/
ui/
utils/
billing/
feature-invoices/
feature-payments/
feature-subscriptions/
data-access/
ui/
analytics/
feature-dashboard/
feature-reports/
data-access/
products/
feature-catalog/
feature-detail/
data-access/
ui/
shared/
ui/ ← design system components ONLY
utils/ ← pure utility functions ONLY
data-models/ ← shared interfaces and types ONLY
Every top-level folder under libs/ is a business domain. Not a technical layer. Not a feature type. A business capability.
auth owns everything authentication-related. billing owns everything billing-related. A developer joining the team on day one can map the system by reading folder names alone.
That's the goal.
The Four Library Types {#the-four-library-types}
Within each domain, libraries follow a strict type system. Every library is one of four types:
1. Feature Libraries
Smart components, route definitions, page-level orchestration. These are the entry points into a domain.
// libs/billing/feature-invoices/src/lib/invoices.routes.ts
export const BILLING_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('./invoices-shell.component')
.then(m => m.InvoicesShellComponent),
providers: [
provideInvoicesState()
]
},
{
path: ':id',
loadComponent: () =>
import('./invoice-detail.component')
.then(m => m.InvoiceDetailComponent)
}
];
Rule: Feature libraries can import from data-access, UI, and utils libraries within the same domain. They cannot import from other feature libraries — ever.
2. Data-Access Libraries
Services, state management, HTTP calls, effects, store definitions. All side effects live here.
// libs/billing/data-access/src/lib/invoices.store.ts
@Injectable()
export class InvoicesStore {
// Signals-based state — scoped to the feature route
readonly invoices = signal<Invoice[]>([]);
readonly loading = signal(false);
readonly selectedId = signal<string | null>(null);
readonly total = computed(() => this.invoices().length);
readonly selected = computed(() =>
this.invoices().find(i => i.id === this.selectedId())
);
constructor(private api: InvoicesApiService) {}
load() {
this.loading.set(true);
this.api.getAll().subscribe(invoices => {
this.invoices.set(invoices);
this.loading.set(false);
});
}
}
Rule: Data-access libraries can only import from utils libraries. Never from UI or feature libraries.
3. UI Libraries
Presentational (dumb) components — no business logic, no service injection, inputs and outputs only.
// libs/billing/ui/src/lib/invoice-card.component.ts
@Component({
selector: 'billing-invoice-card',
standalone: true,
imports: [CommonModule, SharedUiModule],
template: `
<div class="invoice-card" [class.overdue]="invoice.isOverdue">
<h3>{{ invoice.number }}</h3>
<p>{{ invoice.amount | currency }}</p>
<button (click)="viewDetails.emit(invoice.id)">View</button>
</div>
`
})
export class InvoiceCardComponent {
@Input({ required: true }) invoice!: Invoice;
@Output() viewDetails = new EventEmitter<string>();
}
Rule: UI libraries only import from shared UI and shared utils. Zero business logic. Zero service injection.
4. Utility Libraries
Pure functions, guards, pipes, interceptors, type helpers. Framework-agnostic where possible.
// libs/billing/utils/src/lib/invoice.utils.ts
export function calculateTax(amount: number, rate: number): number {
return Math.round(amount * rate * 100) / 100;
}
export function isOverdue(dueDate: Date): boolean {
return new Date() > dueDate;
}
export function formatInvoiceNumber(id: string): string {
return `INV-${id.toUpperCase().slice(0, 8)}`;
}
Rule: Utility libraries have no dependencies on other library types. They are the bottom of the dependency chain.
Dependency Governance: enforce-module-boundaries {#dependency-governance}
This is where most enterprise Angular setups fall apart. A good folder structure means nothing if developers can import across it freely.
The fix is Nx's enforce-module-boundaries ESLint rule. Here's the full config I use:
// .eslintrc.json (root)
{
"@nx/enforce-module-boundaries": [
"error",
{
"enforceBuildableLibDependency": true,
"allow": [],
"depConstraints": [
{
"sourceTag": "type:feature",
"onlyDependOnLibsWithTags": [
"type:data-access",
"type:ui",
"type:utils",
"scope:shared"
]
},
{
"sourceTag": "type:data-access",
"onlyDependOnLibsWithTags": [
"type:utils",
"scope:shared"
]
},
{
"sourceTag": "type:ui",
"onlyDependOnLibsWithTags": [
"type:ui",
"type:utils",
"scope:shared"
]
},
{
"sourceTag": "type:utils",
"onlyDependOnLibsWithTags": [
"type:utils"
]
},
{
"sourceTag": "scope:shared",
"notDependOnLibsWithTags": [
"scope:auth",
"scope:billing",
"scope:analytics",
"scope:products"
]
}
]
}
]
}
And every library's project.json gets tagged accordingly:
// libs/billing/feature-invoices/project.json
{
"name": "billing-feature-invoices",
"tags": ["scope:billing", "type:feature"]
}
// libs/billing/data-access/project.json
{
"name": "billing-data-access",
"tags": ["scope:billing", "type:data-access"]
}
// libs/shared/ui/project.json
{
"name": "shared-ui",
"tags": ["scope:shared", "type:ui"]
}
Now if a developer tries to import a feature library from another feature library:
// libs/auth/feature-login/src/lib/login.component.ts
// ❌ This will FAIL lint — feature cannot import feature
import { InvoicesShellComponent } from '@myorg/billing/feature-invoices';
They get an immediate lint error:
A project tagged with "type:feature" can only depend on libs tagged with
"type:data-access", "type:ui", "type:utils", "scope:shared"
This is a compiler-enforced constraint. Not a convention. Not a code review comment.
The architecture is protected automatically. Every PR, every CI run.
The Shared Library Problem {#the-shared-library-problem}
Here's a warning I give every team I work with:
shared/is the most dangerous folder in your monorepo. Treat it like a public API.
Without discipline, shared/ absorbs everything:
- "I'll just put this service here because both features need it"
- "This component is used in three places, so shared/ makes sense"
- "This model is referenced everywhere, easier to centralise"
Six months later, shared/ has 300 files, 40 services, circular imports nobody can untangle, and every domain depends on it for everything. The entire system becomes a coupled monolith dressed up in a monorepo.
The rule: shared/ libraries contain only three categories of code:
| Library | What goes in | What stays out |
|---|---|---|
shared/ui |
Design system components, layout primitives, generic UI atoms | Domain-specific components, smart components, anything with business logic |
shared/utils |
Pure utility functions, date helpers, string formatters, validators | Services, HTTP calls, state, anything with side effects |
shared/data-models |
Shared interfaces, enums, DTO types used across multiple domains | Domain-specific models, anything that belongs to one domain |
If something doesn't fit cleanly into one of these three categories, it belongs in a domain — not in shared.
Signals-Ready State Architecture {#signals-ready-state-architecture}
Modern Angular (v17+) with Signals changes how we think about state scoping. The key insight: state should be scoped to the route, not hoisted to a global store by default.
// libs/billing/feature-invoices/src/lib/invoices.routes.ts
export const BILLING_ROUTES: Routes = [
{
path: '',
component: InvoicesShellComponent,
providers: [
// State is scoped to this route subtree
// Destroyed when the user navigates away
InvoicesStore,
InvoicesApiService
]
}
];
// libs/billing/data-access/src/lib/invoices.store.ts
@Injectable()
export class InvoicesStore {
private api = inject(InvoicesApiService);
// State
readonly invoices = signal<Invoice[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly filter = signal<InvoiceFilter>({ status: 'all' });
// Derived state
readonly filtered = computed(() => {
const f = this.filter();
return f.status === 'all'
? this.invoices()
: this.invoices().filter(i => i.status === f.status);
});
readonly stats = computed(() => ({
total: this.invoices().length,
overdue: this.invoices().filter(i => i.isOverdue).length,
pending: this.invoices().filter(i => i.status === 'pending').length
}));
// Commands
load = () => {
this.loading.set(true);
this.api.getAll().pipe(
takeUntilDestroyed()
).subscribe({
next: invoices => {
this.invoices.set(invoices);
this.loading.set(false);
},
error: err => {
this.error.set(err.message);
this.loading.set(false);
}
});
};
setFilter = (filter: InvoiceFilter) => this.filter.set(filter);
}
No NgRx boilerplate for domain-local state. No global store pollution. The state lives and dies with the route.
Use NgRx (or equivalent) only when state genuinely needs to persist across route navigation or be shared between multiple sibling domains.
Standalone APIs and Modern Angular Patterns {#standalone-apis}
With standalone components, NgModules are no longer required. This simplifies the library boundary model significantly.
// libs/billing/ui/src/lib/invoice-status-badge.component.ts
@Component({
selector: 'billing-invoice-status-badge',
standalone: true,
imports: [NgClass],
template: `
<span
class="badge"
[ngClass]="{
'badge--paid': status === 'paid',
'badge--pending': status === 'pending',
'badge--overdue': status === 'overdue'
}"
>
{{ status | titlecase }}
</span>
`,
styles: [`
.badge { display: inline-flex; padding: 2px 10px; border-radius: 100px; font-size: 12px; font-weight: 600; }
.badge--paid { background: #dcfce7; color: #166534; }
.badge--pending { background: #fef9c3; color: #854d0e; }
.badge--overdue { background: #fee2e2; color: #991b1b; }
`]
})
export class InvoiceStatusBadgeComponent {
@Input({ required: true }) status!: 'paid' | 'pending' | 'overdue';
}
// Generating a new standalone library with Nx
nx generate @nx/angular:library billing-ui \
--directory=libs/billing/ui \
--standalone \
--tags="scope:billing,type:ui"
With standalone APIs and route-scoped providers, the library model becomes cleaner than ever: each library is a collection of standalone components, services, or utilities. No module ceremony. No barrel file explosion. Just clear, typed exports.
Team Scalability: 5 Devs → 50 Devs {#team-scalability}
Here's what this architecture enables operationally — not just technically.
Parallel team ownership
Team Auth → owns libs/auth/**
Team Billing → owns libs/billing/**
Team Analytics → owns libs/analytics/**
Team Platform → owns libs/shared/** + apps/**
Each team works entirely within their domain. No accidental overwrites. No "can you merge your branch so I can test mine" conversations. No cross-team PRs for routine feature work.
Affected-only CI builds
# Only test and build what actually changed
nx affected:test --base=origin/main
nx affected:build --base=origin/main
nx affected:lint --base=origin/main
At 50 developers, running the full test suite on every PR is not viable. Nx's affected graph calculates exactly which libraries need to be rebuilt based on what changed. A developer fixing a bug in billing/ui doesn't trigger a rebuild of analytics/feature-dashboard.
Onboarding maps to domains
A new developer joining the Billing team gets a clear answer to "where do I start?": libs/billing/. They don't need to understand the full system. They need to understand one domain, and the boundary rules that govern it.
Dependency direction is always documented
The dependency graph itself becomes living documentation. Running nx graph at any point shows exactly which libraries depend on which — visually, in a browser. No architecture doc required. No "what does this actually look like?" questions in Slack.
# Open the interactive dependency graph
nx graph
Architecture Governance Checklist {#governance-checklist}
Before shipping any significant feature or accepting a PR that touches architecture, run through this:
Library boundaries
- [ ] Does every new library have
scopeandtypetags inproject.json? - [ ] Does
nx lintpass with zeroenforce-module-boundariesviolations? - [ ] Does
nx graphshow no circular dependencies?
Domain ownership
- [ ] Does the new code belong to an existing domain, or does it justify a new one?
- [ ] Is business logic living inside a domain library — not in the app shell?
- [ ] Does
shared/still only contain design system components, pure utils, and shared models?
State architecture
- [ ] Is state scoped to the correct level (route-local vs genuinely global)?
- [ ] Are Signals used for synchronous derived state instead of manual subscriptions?
- [ ] Is
takeUntilDestroyed()used in all Observable subscriptions inside services?
Components
- [ ] Are feature library components smart (inject services, own routing)?
- [ ] Are UI library components dumb (inputs/outputs only, zero service injection)?
- [ ] Are all new components standalone?
Performance
- [ ] Are all routes lazy-loaded via
loadComponentorloadChildren? - [ ] Are heavy sections using
@deferfor deferred loading? - [ ] Does
nx affectedcorrectly scope the CI run to changed libraries only?
The Senior Dev Rule {#the-senior-dev-rule}
There's one question I ask when reviewing any architecture:
Can a developer who joined the team today understand the entire system structure in under 10 minutes — just by reading folder names and the dependency graph?
If the answer is no, the architecture has failed its primary job. Not the code quality. Not the test coverage. Not the performance. The architecture.
Because architecture isn't about files. It's about communication. It's the signal you send to every developer who will ever touch this codebase about what matters, who owns what, and what the boundaries are.
Every folder should have a reason to exist.
Every import should cross a documented boundary.
Every service should have a clear ownership chain.
When those three things are true, teams don't step on each other. Codebases don't rot. New engineers find their footing in hours instead of weeks. And the architecture scales — not just technically, but operationally.
Quick Reference: Dependency Rules Summary
App Shell
↓ can import
Feature Libraries (scope:X, type:feature)
↓ can import
Data-Access Libraries (scope:X, type:data-access)
↓ can import
Utility Libraries (scope:X, type:utils)
↓ can import
(nothing — bottom of the chain)
UI Libraries (scope:X, type:ui)
↓ can import
Shared UI + Shared Utils
Shared Libraries (scope:shared)
✗ cannot import any domain scope
❌ Feature → Feature (cross-feature coupling)
❌ Data-access → UI (layer inversion)
❌ Shared → Domain scope (shared absorbing domain logic)
❌ Any circular dependency (caught by nx lint)
Final Word
The architecture described here is not theoretical. It's the setup I return to on every enterprise Angular project because it solves the actual problem: teams, not just codebases, need to scale.
Modular architecture isn't about aesthetics or engineering elegance. It's about giving every developer on your team a clear answer to three questions:
- Where does this code belong? → In the domain that owns the business capability.
- What can this code depend on? → Exactly what the tags and boundary rules allow.
- Who is responsible for this code? → The team that owns the domain.
When those questions have clear answers, everything else follows.
📌 More From Me
I share day-to-day insights on web development, architecture, performance, and best practices. If this was useful, follow along — there's a full series on Enterprise Angular Architecture coming.
🌐 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.
Discussion
What's the first thing that breaks when your Angular app starts scaling? Drop your answer in the comments — I read every one.
And if you're working on a large Angular codebase right now: what's the biggest architectural pain point you're dealing with?
Top comments (0)