DEV Community

Cover image for Architecture Teardown: My Modular Angular Setup for Enterprise Scale
abdelaaziz ouakala
abdelaaziz ouakala

Posted on

Architecture Teardown: My Modular Angular Setup for Enterprise Scale

"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

  1. Why Flat Folder Structures Fail at Scale
  2. The Core Principle: Apps Are Orchestrators
  3. Domain-Driven Library Structure with Nx
  4. The Four Library Types
  5. Dependency Governance: enforce-module-boundaries
  6. The Shared Library Problem
  7. Signals-Ready State Architecture
  8. Standalone APIs and Modern Angular Patterns
  9. Team Scalability: 5 Devs → 50 Devs
  10. Architecture Governance Checklist
  11. 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)
Enter fullscreen mode Exit fullscreen mode

This structure looks organised. It's not.

It's organised around technical layers (components, services, models), not around business capabilities. The result:

  • A BillingService imports from AuthService which imports from a SharedUtilsService which somehow imports a BillingModel — 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 auth and billing simultaneously 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:

  1. Bootstrap the application
  2. Configure top-level routing
  3. 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
Enter fullscreen mode Exit fullscreen mode
// 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)
  }
];
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
  }
];
Enter fullscreen mode Exit fullscreen mode

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);
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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>();
}
Enter fullscreen mode Exit fullscreen mode

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)}`;
}
Enter fullscreen mode Exit fullscreen mode

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"
          ]
        }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
    ]
  }
];
Enter fullscreen mode Exit fullscreen mode
// 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);
}
Enter fullscreen mode Exit fullscreen mode

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';
}
Enter fullscreen mode Exit fullscreen mode
// Generating a new standalone library with Nx
nx generate @nx/angular:library billing-ui \
  --directory=libs/billing/ui \
  --standalone \
  --tags="scope:billing,type:ui"
Enter fullscreen mode Exit fullscreen mode

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/**
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 scope and type tags in project.json?
  • [ ] Does nx lint pass with zero enforce-module-boundaries violations?
  • [ ] Does nx graph show 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 loadComponent or loadChildren?
  • [ ] Are heavy sections using @defer for deferred loading?
  • [ ] Does nx affected correctly 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
Enter fullscreen mode Exit fullscreen mode
❌ Feature → Feature       (cross-feature coupling)
❌ Data-access → UI        (layer inversion)
❌ Shared → Domain scope   (shared absorbing domain logic)
❌ Any circular dependency (caught by nx lint)
Enter fullscreen mode Exit fullscreen mode

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:

  1. Where does this code belong? → In the domain that owns the business capability.
  2. What can this code depend on? → Exactly what the tags and boundary rules allow.
  3. 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)