| |

Angular 130 🅰️ Real-World Architecture Patterns

⏱️ Estimated read: 16 min | |

Architecture is the set of decisions that determine how an application is organized, how its parts communicate, and how it changes over time. Angular provides the primitives — components, services, dependency injection, routing, signals — but it does not prescribe how to combine them. That prescription is the architect’s job, and it is where most of the long-term cost of an application lives. A well-architected Angular application is one where adding a feature means adding files to a folder, not modifying a dozen existing ones.

The patterns in this chapter are not theoretical. They are the recurring structures that appear in production Angular applications: feature-based folder organization, smart and presentational component separation, state management with signals and services, facade services that hide complexity behind a clean API, and the domain-driven boundaries that keep features independent. Each pattern solves a specific problem, and the combination produces applications that scale in both size and team.

This chapter covers feature-based organization, smart and presentational components, state management patterns, the facade pattern, module boundaries and dependency rules, routing architecture, and the anti-patterns that cause long-term pain.

Key point: Organize by feature, not by file type. Separate components that fetch state (smart) from components that render it (presentational). Use services as facades that hide the complexity of state, HTTP, and business logic behind a clean API. Enforce boundaries with tags and lint rules so features do not import from each other’s internals. Prefer signals for local state and services for shared state.


Why architecture patterns matter

The scaling problem. An application with ten components can be organized any way. An application with a thousand cannot. Without a pattern, every developer makes a different choice, and the result is a codebase where nothing is where you expect it and every change requires hunting through the project.

The team problem. When multiple teams work on the same application, the architecture determines whether they can work independently. If features are entangled, a change in one feature breaks another. If features are independent, teams can ship on their own schedule.

The change problem. Requirements change. A feature that was simple becomes complex. A dependency that was local becomes shared. An architecture that anticipates change makes these transitions cheap; one that assumes a fixed structure makes them expensive.

The testing problem. A well-architected application is easy to test because the parts are independent. A component that fetches its own data is hard to test; a component that receives data through inputs is easy. The architecture determines the testability.

The onboarding problem. A new developer should be able to find where a feature lives and understand how it is organized within an hour. A consistent architecture makes this possible; an inconsistent one makes onboarding a research project.


a. Feature-based organization

The first decision is how to organize the folders. The two common approaches are organizing by file type (components/, services/, models/) and organizing by feature (checkout/, products/, account/).

Organizing by file type works for small applications and fails for large ones. When everything is in components/, a change to the checkout feature requires editing files scattered across the project. When everything is in checkout/, the change is localized.

A feature-based structure looks like this:

src/app/
├── core/
│   ├── services/
│   │   ├── auth.service.ts
│   │   └── api.service.ts
│   ├── interceptors/
│   │   └── auth.interceptor.ts
│   └── guards/
│       └── auth.guard.ts
├── shared/
│   ├── components/
│   │   ├── button/
│   │   └── modal/
│   ├── directives/
│   └── pipes/
├── features/
│   ├── products/
│   │   ├── components/
│   │   ├── services/
│   │   ├── models/
│   │   ├── products.routes.ts
│   │   └── products.facade.ts
│   ├── checkout/
│   │   ├── components/
│   │   ├── services/
│   │   ├── models/
│   │   ├── checkout.routes.ts
│   │   └── checkout.facade.ts
│   └── account/
│       └── ...
└── app.config.ts

The core/ folder holds singletons that the whole application uses once: authentication, HTTP interceptors, global error handling. The shared/ folder holds reusable UI components and utilities that have no dependency on any feature. The features/ folder holds the application’s functionality, each feature self-contained.

The rule is that a feature can depend on core/ and shared/, but not on another feature’s internals. If the checkout feature needs a product model, it imports it from a shared location or from the products feature’s public API, not from its internal folders.


b. Smart and presentational components

The second pattern is separating components that manage state from components that render it.

A smart component (also called a container) fetches data, holds state, handles events, and passes data to its children. It knows about services and the application’s state.

A presentational component receives data through inputs, emits events through outputs, and renders. It knows nothing about services or the application’s state.

// Smart: fetches state, delegates rendering
@Component({
  selector: 'app-product-list',
  template: `
    @if (products$ | async; as products) {
      @for (product of products; track product.id) {
        <app-product-card
          [product]="product"
          (addToCart)="onAddToCart($event)" />
      }
    }
  `,
})
export class ProductListComponent {
  private readonly facade = inject(ProductFacade);
  readonly products$ = this.facade.products$;

  onAddToCart(product: Product): void {
    this.facade.addToCart(product);
  }
}
// Presentational: receives data, emits events
@Component({
  selector: 'app-product-card',
  template: `
    <div class="card">
      <h3>{{ product().name }}</h3>
      <p>{{ product().price }}</p>
      <button (click)="addToCart.emit(product())">Add</button>
    </div>
  `,
})
export class ProductCardComponent {
  readonly product = input.required<Product>();
  readonly addToCart = output<Product>();
}

The smart component is the only one that injects the facade. The presentational component is a pure function of its inputs and outputs, which makes it trivially testable and reusable.

The rule is that presentational components should be in shared/ if they have no feature dependency, and in the feature folder if they do. Smart components are always in the feature folder.


c. State management with signals and services

The third pattern is where state lives and how it is shared.

Local state belongs in the component. A component that toggles a dropdown, tracks a form field, or holds a temporary value uses a signal. The state is not shared, and the component owns it.

@Component({ /* ... */ })
export class SearchBoxComponent {
  readonly query = signal('');
  readonly isOpen = signal(false);
}

Shared state belongs in a service. When multiple components need the same data, the state moves to a service that both components inject.

@Injectable({ providedIn: 'root' })
export class CartService {
  private readonly items = signal<CartItem[]>([]);
  readonly items$ = this.items.asReadonly();
  readonly total = computed(() =>
    this.items().reduce((sum, item) => sum + item.price, 0)
  );

  add(item: CartItem): void {
    this.items.update((current) => [...current, item]);
  }

  remove(id: string): void {
    this.items.update((current) => current.filter((i) => i.id !== id));
  }
}

The service exposes the state as a read-only signal and the operations as methods. Components read the signal and call the methods. The state is centralized, and every component sees the same value.

Server state is fetched through HttpClient and cached in the service. The service holds the cache, exposes it as a signal, and provides methods to refresh or invalidate it.

@Injectable({ providedIn: 'root' })
export class ProductService {
  private readonly http = inject(HttpClient);
  private readonly products = signal<Product[]>([]);

  readonly products$ = this.products.asReadonly();

  load(): void {
    this.http.get<Product[]>('/api/products').subscribe((data) => {
      this.products.set(data);
    });
  }
}

The pattern is consistent: the service owns the state, exposes it as a signal, and provides the operations. Components subscribe to the signal and call the methods.


d. The facade pattern

The facade pattern puts a service in front of the state, HTTP, and business logic, exposing a simplified API to the components.

Without a facade, a component might inject three services, combine their data, and handle the loading and error states. With a facade, the component injects one service and reads the state and operations it needs.

@Injectable({ providedIn: 'root' })
export class ProductFacade {
  private readonly productService = inject(ProductService);
  private readonly cartService = inject(CartService);

  readonly products$ = this.productService.products$;
  readonly cartCount$ = computed(() => this.cartService.items$().length);
  readonly loading$ = signal(false);

  loadProducts(): void {
    this.loading$.set(true);
    this.productService.load();
    this.loading$.set(false);
  }

  addToCart(product: Product): void {
    this.cartService.add({
      id: product.id,
      name: product.name,
      price: product.price,
    });
  }
}

The component injects ProductFacade and uses its API. It does not know about ProductService or CartService, and it does not know how the data is fetched or how the cart is stored. The facade hides the complexity behind a stable interface.

The facade is the boundary between the component and the rest of the application. When the underlying services change, the facade changes, and the components do not.


e. Module boundaries and dependency rules

The fourth pattern is enforcing the boundaries between features. Angular’s compiler does not enforce them; ESLint does, with the @nx/enforce-module-boundaries rule or a similar configuration.

Each project or feature is tagged with a scope and a type:

{
  "tags": ["scope:products", "type:feature"]
}

The lint rule defines which tags can depend on which:

{
  "depConstraints": [
    {
      "sourceTag": "type:feature",
      "onlyDependOnLibsWithTags": ["type:ui", "type:data", "type:util"]
    },
    {
      "sourceTag": "type:ui",
      "onlyDependOnLibsWithTags": ["type:util"]
    }
  ]
}

The rule prevents a feature from importing another feature’s internals, a UI component from importing a data service, and a utility from importing anything. The dependency graph stays acyclic, and the features stay independent.

The boundary is enforced at lint time. A developer who attempts a prohibited import sees the error before committing, not after a production incident.


f. Routing architecture

The fifth pattern is how routes are organized. The two common approaches are a central route configuration and feature-owned route configurations.

A central route configuration lists every route in app.routes.ts. It works for small applications but becomes a coordination bottleneck as the number of features grows.

Feature-owned route configurations let each feature define its own routes and export them as a constant. The application’s route configuration references the feature’s routes with loadChildren.

// features/products/products.routes.ts
export const PRODUCTS_ROUTES: Routes = [
  { path: '', component: ProductListComponent },
  { path: ':id', component: ProductDetailComponent },
];
// app.routes.ts
export const routes: Routes = [
  { path: 'products', loadChildren: () => import('./features/products/products.routes').then(m => m.PRODUCTS_ROUTES) },
  { path: 'checkout', loadChildren: () => import('./features/checkout/checkout.routes').then(m => m.CHECKOUT_ROUTES) },
];

The feature owns its routes, and the application owns the mapping from URL prefixes to features. Adding a feature means adding one line to app.routes.ts and creating the feature’s route file. The application’s route configuration does not grow with the number of routes within a feature.

Lazy loading is the default for feature routes. The feature’s code is loaded only when the user navigates to its path, which reduces the initial bundle size.


Complete Example Session

// ============================================
// PART 1: FEATURE FOLDER STRUCTURE
// ============================================
// src/app/
// ├── core/
// ├── shared/
// ├── features/
// │   ├── products/
// │   │   ├── components/
// │   │   ├── services/
// │   │   ├── models/
// │   │   ├── products.routes.ts
// │   │   └── products.facade.ts
// │   └── checkout/
// └── app.config.ts
// ============================================
// PART 2: PRESENTATIONAL COMPONENT
// ============================================
@Component({
  selector: 'app-product-card',
  template: `
    <div class="card">
      <h3>{{ product().name }}</h3>
      <p>{{ product().price }}</p>
      <button (click)="addToCart.emit(product())">Add</button>
    </div>
  `,
})
export class ProductCardComponent {
  readonly product = input.required<Product>();
  readonly addToCart = output<Product>();
}
// ============================================
// PART 3: SMART COMPONENT
// ============================================
@Component({
  selector: 'app-product-list',
  imports: [ProductCardComponent, AsyncPipe],
  template: `
    @if (products$ | async; as products) {
      @for (product of products; track product.id) {
        <app-product-card
          [product]="product"
          (addToCart)="onAddToCart($event)" />
      }
    }
  `,
})
export class ProductListComponent {
  private readonly facade = inject(ProductFacade);
  readonly products$ = this.facade.products$;

  onAddToCart(product: Product): void {
    this.facade.addToCart(product);
  }
}
// ============================================
// PART 4: STATE SERVICE
// ============================================
@Injectable({ providedIn: 'root' })
export class CartService {
  private readonly items = signal<CartItem[]>([]);
  readonly items$ = this.items.asReadonly();
  readonly total = computed(() =>
    this.items().reduce((sum, item) => sum + item.price, 0)
  );

  add(item: CartItem): void {
    this.items.update((current) => [...current, item]);
  }

  remove(id: string): void {
    this.items.update((current) => current.filter((i) => i.id !== id));
  }
}
// ============================================
// PART 5: SERVER STATE SERVICE
// ============================================
@Injectable({ providedIn: 'root' })
export class ProductService {
  private readonly http = inject(HttpClient);
  private readonly products = signal<Product[]>([]);
  readonly products$ = this.products.asReadonly();

  load(): void {
    this.http.get<Product[]>('/api/products').subscribe((data) => {
      this.products.set(data);
    });
  }
}
// ============================================
// PART 6: FACADE SERVICE
// ============================================
@Injectable({ providedIn: 'root' })
export class ProductFacade {
  private readonly productService = inject(ProductService);
  private readonly cartService = inject(CartService);

  readonly products$ = this.productService.products$;
  readonly cartCount$ = computed(() => this.cartService.items$().length);

  loadProducts(): void {
    this.productService.load();
  }

  addToCart(product: Product): void {
    this.cartService.add({
      id: product.id,
      name: product.name,
      price: product.price,
    });
  }
}
// ============================================
// PART 7: FEATURE ROUTES
// ============================================
// features/products/products.routes.ts
export const PRODUCTS_ROUTES: Routes = [
  { path: '', component: ProductListComponent },
  { path: ':id', component: ProductDetailComponent },
];
// ============================================
// PART 8: APP ROUTES
// ============================================
export const routes: Routes = [
  {
    path: 'products',
    loadChildren: () =>
      import('./features/products/products.routes').then(
        (m) => m.PRODUCTS_ROUTES
      ),
  },
  {
    path: 'checkout',
    loadChildren: () =>
      import('./features/checkout/checkout.routes').then(
        (m) => m.CHECKOUT_ROUTES
      ),
  },
];
// ============================================
// PART 9: MODULE BOUNDARY RULES
// ============================================
{
  "depConstraints": [
    {
      "sourceTag": "type:feature",
      "onlyDependOnLibsWithTags": ["type:ui", "type:data", "type:util"]
    },
    {
      "sourceTag": "type:ui",
      "onlyDependOnLibsWithTags": ["type:util"]
    },
    {
      "sourceTag": "type:util",
      "onlyDependOnLibsWithTags": []
    }
  ]
}
// ============================================
// PART 10: LOCAL STATE WITH SIGNALS
// ============================================
@Component({ /* ... */ })
export class SearchBoxComponent {
  readonly query = signal('');
  readonly isOpen = signal(false);

  toggle(): void {
    this.isOpen.update((open) => !open);
  }
}

These ten parts cover the feature folder structure, a presentational component, a smart component, a state service, a server state service, a facade service, feature routes, app routes, module boundary rules, and local state with signals.


Quick Reference

Folder Organization

FolderPurpose
core/Singletons: auth, interceptors, guards
shared/Reusable UI, directives, pipes
features/Application functionality, self-contained
features/<name>/components/Components for the feature
features/<name>/services/Services for the feature
features/<name>/models/Types for the feature
features/<name>/<name>.routes.tsRoutes for the feature
features/<name>/<name>.facade.tsFacade for the feature

Component Types

TypeResponsibilityInjects services
SmartFetch state, handle eventsYes
PresentationalRender data, emit eventsNo

State Management

State TypeWhere it lives
LocalComponent signal
SharedInjectable service with signal
ServerService with HTTP and cache
Derivedcomputed() signal

Facade Pattern

AspectDetail
PurposeHide complexity behind a clean API
InjectsState, HTTP, business logic services
ExposesSignals and methods
BenefitComponents do not know about services

Dependency Rules

Source TagCan depend on
type:featuretype:ui, type:data, type:util
type:uitype:util
type:utilNothing
type:appEverything

Routing Architecture

PatternPurpose
Feature routesEach feature owns its routes
Lazy loadingLoad feature code on demand
loadChildrenReference feature routes
Central routesMap URL prefixes to features

Best Practices

✅ Do This:

// Separate smart and presentational components
@Component({ /* smart: injects facade */ })
export class ProductListComponent { }

@Component({ /* presentational: inputs and outputs */ })
export class ProductCardComponent { }

// Use signals for local state
readonly query = signal('');

// Use services for shared state
@Injectable({ providedIn: 'root' })
export class CartService { }

// Use facades to hide complexity
@Injectable({ providedIn: 'root' })
export class ProductFacade { }

// Enforce boundaries with tags
{ "tags": ["scope:products", "type:feature"] }

❌ Don’t Do This:

// Fetch data in a presentational component
@Component({ /* ... */ })
export class ProductCardComponent {
  private http = inject(HttpClient);  // ❌ presentational should not fetch
}

// Put all components in one folder
// components/product-card.ts  // ❌ organize by feature

// Import from another feature's internals
import { ProductService } from '../products/services/product.service';  // ❌

// Put shared state in a component
readonly cart = signal<CartItem[]>([]);  // ❌ if shared, move to service

// Skip the facade for complex features
// Component injects three services directly  // ❌

Common Pitfalls

PitfallWhy It HappensFix
Feature entanglementNo boundary enforcementUse tags and lint rules
Untestable componentsComponents fetch their own dataUse smart/presentational split
State inconsistencyState duplicated in componentsMove shared state to a service
Circular dependenciesFeatures import each otherDefine a shared location
Large initial bundleNo lazy loadingUse loadChildren
Facade bypassedComponents inject services directlyEnforce facade as the boundary
Type leaksFeature imports another feature’s modelMove shared types to shared/

Real-World Examples

1. Feature Folder

features/products/
├── components/
├── services/
├── models/
├── products.routes.ts
└── products.facade.ts

2. Smart Component

@Component({ /* ... */ })
export class ProductListComponent {
  private readonly facade = inject(ProductFacade);
  readonly products$ = this.facade.products$;
}

3. Presentational Component

@Component({ /* ... */ })
export class ProductCardComponent {
  readonly product = input.required<Product>();
  readonly addToCart = output<Product>();
}

4. State Service

@Injectable({ providedIn: 'root' })
export class CartService {
  private readonly items = signal<CartItem[]>([]);
  readonly items$ = this.items.asReadonly();
}

5. Facade

@Injectable({ providedIn: 'root' })
export class ProductFacade {
  private readonly productService = inject(ProductService);
  readonly products$ = this.productService.products$;
}

6. Feature Routes

export const PRODUCTS_ROUTES: Routes = [
  { path: '', component: ProductListComponent },
];

7. Lazy Loading

{
  path: 'products',
  loadChildren: () => import('./features/products/products.routes')
    .then((m) => m.PRODUCTS_ROUTES),
}

8. Boundary Tags

{ "tags": ["scope:products", "type:feature"] }

9. Dependency Constraints

{
  "sourceTag": "type:feature",
  "onlyDependOnLibsWithTags": ["type:ui", "type:data"]
}

10. Local State

readonly query = signal('');
readonly isOpen = signal(false);

Visual

Feature-Based Organization

┌──────────────────────────────────────────────────────────────┐
│  src/app/                                                    │
│  ├── core/                                                   │
│  │   └── Singletons: auth, interceptors, guards              │
│  ├── shared/                                                 │
│  │   └── Reusable UI, directives, pipes                      │
│  ├── features/                                               │
│  │   ├── products/                                           │
│  │   │   ├── components/                                     │
│  │   │   ├── services/                                       │
│  │   │   ├── models/                                         │
│  │   │   ├── products.routes.ts                              │
│  │   │   └── products.facade.ts                              │
│  │   └── checkout/                                           │
│  └── app.config.ts                                           │
└──────────────────────────────────────────────────────────────┘

Smart vs Presentational

┌──────────────────────────────────────────────────────────────┐
│  SMART COMPONENT                                             │
│  ├── Injects facade                                          │
│  ├── Fetches state                                           │
│  ├── Handles events                                          │
│  └── Passes data to children                                 │
│                                                              │
│  PRESENTATIONAL COMPONENT                                    │
│  ├── Receives inputs                                         │
│  ├── Emits outputs                                           │
│  ├── Renders                                                 │
│  └── Knows nothing about services                            │
└──────────────────────────────────────────────────────────────┘

State Management

┌──────────────────────────────────────────────────────────────┐
│  LOCAL STATE                                                 │
│  └── Component signal                                        │
│                                                              │
│  SHARED STATE                                                │
│  └── Service signal, injected by multiple components         │
│                                                              │
│  SERVER STATE                                                │
│  └── Service with HTTP and cache                             │
│                                                              │
│  DERIVED STATE                                               │
│  └── computed() from other signals                           │
└──────────────────────────────────────────────────────────────┘

Facade Pattern

┌──────────────────────────────────────────────────────────────┐
│  COMPONENT                                                   │
│       │                                                      │
│       ▼                                                      │
│  FACADE                                                      │
│  ├── ProductService (server state)                           │
│  ├── CartService (shared state)                              │
│  └── Business logic                                          │
│                                                              │
│  The component sees one API.                                 │
│  The facade hides the complexity.                            │
└──────────────────────────────────────────────────────────────┘

Dependency Boundaries

┌──────────────────────────────────────────────────────────────┐
│  type:app      → can depend on everything                    │
│  type:feature  → can depend on ui, data, util                │
│  type:ui       → can depend on util                          │
│  type:util     → can depend on nothing                       │
│                                                              │
│  Enforced by lint rules, not by the compiler.                │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Organize byFeature, not file type
core/Singletons: auth, interceptors, guards
shared/Reusable UI, directives, pipes
features/Application functionality
Smart componentFetches state, injects facade
PresentationalInputs and outputs only
Local stateComponent signal
Shared stateInjectable service with signal
Server stateService with HTTP and cache
FacadeHides complexity behind a clean API
BoundariesEnforced by tags and lint rules
RoutingFeature-owned route files, lazy loaded

Key takeaways:

  • Organize by feature, not by file type. A feature folder contains everything the feature needs: components, services, models, routes, and facade. A change to the feature is localized to its folder.
  • Separate smart and presentational components. Smart components fetch state and inject facades. Presentational components receive inputs and emit outputs, with no knowledge of services. This makes them testable and reusable.
  • Use signals for local state and services for shared state. Local state lives in the component. Shared state lives in a service that exposes it as a read-only signal and provides methods to update it.
  • Use facades to hide complexity. A facade injects the services a feature needs and exposes a simplified API. Components inject the facade, not the underlying services.
  • Enforce boundaries with tags and lint rules. Features should not import from each other’s internals. Tags and the module boundary lint rule keep the dependency graph acyclic and the features independent.
  • Organize routes by feature. Each feature defines its own routes and exports them. The application’s route configuration references them with loadChildren, which enables lazy loading and keeps the application’s route configuration small.

Remember: Architecture is the set of decisions that determine how an application is organized and how it changes over time. The patterns in this chapter are not rules imposed by Angular; they are the structures that production teams have found to work. Organize by feature so that changes are localized. Separate smart and presentational components so that rendering is testable. Use signals for local state and services for shared state so that state has a clear owner. Use facades so that components do not depend on the details of services. Enforce boundaries so that features stay independent. Organize routes by feature so that the application scales. The result is an application where adding a feature means adding a folder, and where a change to one feature does not ripple through the rest.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!