Design Patterns in Angular v20
1. Introduction
What are Design Patterns?
Design patterns are reusable solutions to common problems in software design. They are not direct code snippets but rather templates that can be applied in various situations. Think of them as blueprints for building robust, scalable, and maintainable software systems. They represent the best practices evolved over time by experienced software developers, providing a shared vocabulary and understanding among team members.
The importance of design patterns stems from their ability to:
- Improve maintainability: By providing structured solutions, patterns make code easier to understand and modify.
- Enhance scalability: Applications built with patterns can more easily grow and accommodate new features without significant refactoring.
- Promote reusability: Patterns often encapsulate logic in a way that allows components to be reused across different parts of an application or even different projects.
- Increase robustness: Well-applied patterns can help mitigate common pitfalls and vulnerabilities in software.
- Facilitate communication: A shared understanding of patterns allows developers to discuss design challenges and solutions more effectively.
Why Design Patterns in Angular?
Angular, a powerful framework for building complex single-page applications, presents unique architectural considerations that design patterns effectively address. Its component-based architecture, reactive programming paradigm with RxJS, and robust dependency injection system naturally align with many established design patterns.
Specific challenges in Angular development that design patterns help overcome include:
- Managing component communication: As applications grow, direct parent-child communication can become cumbersome. Patterns like Mediator or Observer can centralize or streamline this.
- Handling complex state: Large applications often struggle with state management. Patterns like State and Observer (with RxJS/Signals) are crucial.
- Ensuring code reusability: Preventing “copy-paste” code by encapsulating common logic.
- Decoupling concerns: Separating UI logic from business logic, data fetching, and state management.
- Improving testability: Loosely coupled components and services are inherently easier to test in isolation.
- Adapting to changing requirements: Patterns provide flexible structures that can be adapted more readily to new features or modifications.
Angular’s core concepts – components, services, modules, directives, and pipes – are themselves embodiments of various design patterns, making the framework naturally aligned with these principles.
Target Audience
This document is intended for Angular developers of all experience levels, from those just starting their journey to seasoned architects. It aims to provide practical guidance on how to leverage design patterns to build higher-quality Angular applications, focusing on the latest features and best practices introduced in Angular v20.
2. Core Angular Concepts as Patterns
Many fundamental Angular concepts inherently follow or are heavily influenced by classic design patterns. Understanding this connection helps in appreciating the framework’s design and in applying patterns effectively.
Component Pattern (Composite, Decorator-like aspects)
Angular Components embody aspects of several patterns:
- Composite: Components often form a tree-like structure, where a parent component can contain and manage child components. This mirrors the Composite pattern, allowing clients to treat individual objects and compositions of objects uniformly.
- Decorator-like aspects: While not a pure Decorator pattern, components can “decorate” the DOM with new behavior and visual elements. Directives, in particular, are explicit decorators of elements or components.
Service Pattern (Singleton, Dependency Injection)
Angular Services are a prime example of:
- Singleton: Services provided at the root level (
providedIn: 'root') or a module level are typically singletons, meaning only one instance exists throughout the application or within a specific injector scope. This ensures a shared state and centralized logic. - Dependency Injection: Angular’s robust DI system is fundamental to how services are provided and consumed. It promotes loose coupling by allowing classes to declare their dependencies rather than creating them, with the Angular injector handling the instantiation and provision.
Module Pattern (Encapsulation, Organization)
While standalone components are now the recommended default, NgModules historically fulfilled the role of:
- Encapsulation: Modules encapsulate components, directives, pipes, and services, defining a clear boundary for their responsibilities.
- Organization: They act as containers for related functionalities, helping organize large applications into manageable chunks. Even with standalone components, the concept of grouping related features (e.g., via folder structure or lazy-loaded routes) remains crucial, continuing the spirit of modular organization.
Directive Pattern (Decorator, extending behavior)
Angular Directives are a direct manifestation of the Decorator pattern.
- Decorator: Directives (especially attribute directives) allow you to attach new behavior to existing DOM elements or components without modifying their core structure. They “decorate” the element with additional responsibilities. For example,
NgClassorNgStyledecorate an element with dynamic styling.
Pipes Pattern (Strategy, data transformation)
Angular Pipes align with the Strategy pattern.
- Strategy: Pipes define a family of algorithms (transformations) that can be interchanged. For example, the
DatePipe,CurrencyPipe, orUpperCasePipeeach encapsulate a specific data transformation strategy. You can apply different pipes to the same data, and the choice of pipe (strategy) can be changed dynamically in the template without altering the underlying data.
3. Creational Design Patterns in Angular
Creational patterns deal with object creation mechanisms, trying to create objects in a manner suitable to the situation. In Angular, this often involves controlling the instantiation of services, components, or data models.
Singleton
Definition: Ensures a class has only one instance and provides a global point of access to that instance.
Problem: In Angular, you often need services that manage global state, handle application-wide logic (like authentication, logging, or API interactions), or share data across many components. Without the Singleton pattern, multiple instances of such services could lead to inconsistent state or inefficient resource usage.
Angular Implementation: Angular’s Dependency Injection system inherently supports the Singleton pattern. By default, services provided at the root level (
providedIn: 'root') are singletons within the entire application.// src/app/services/auth.service.ts import { Injectable, signal, Signal } from '@angular/core'; interface User { id: string; name: string; } @Injectable({ providedIn: 'root' // This makes AuthService a singleton }) export class AuthService { private _currentUser = signal<User | null>(null); currentUser: Signal<User | null> = this._currentUser.asReadonly(); constructor() { console.log('AuthService instance created (should only happen once)'); // In a real app, load user from localStorage or check session const storedUser = localStorage.getItem('currentUser'); if (storedUser) { this._currentUser.set(JSON.parse(storedUser)); } } login(username: string, password: string): boolean { // Simulate API call if (username === 'test' && password === 'password') { const user: User = { id: '1', name: 'Test User' }; this._currentUser.set(user); localStorage.setItem('currentUser', JSON.stringify(user)); return true; } return false; } logout(): void { this._currentUser.set(null); localStorage.removeItem('currentUser'); } isLoggedIn(): boolean { return this._currentUser() !== null; } }Any component or service injecting
AuthServicewill receive the exact same instance:// src/app/app.component.ts import { Component, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { AuthService } from './services/auth.service'; @Component({ selector: 'app-root', standalone: true, imports: [CommonModule], template: ` <h1>Angular Singleton Example</h1> <div *ngIf="authService.isLoggedIn(); else loggedOut"> Welcome, {{ authService.currentUser()?.name }}! <button (click)="authService.logout()">Logout</button> </div> <ng-template #loggedOut> <p>Please log in.</p> <button (click)="login()">Login</button> </ng-template> ` }) export class AppComponent { authService = inject(AuthService); // Angular injects the singleton instance login() { this.authService.login('test', 'password'); } }Use Cases:
- Global configuration services.
- State management services (e.g., a central store for user data, shopping cart).
- Logger services.
- Utility services that manage application-wide resources (e.g., a WebSocket connection manager).
Factory Method
Definition: Defines an interface for creating an object, but lets subclasses alter the type of objects that will be created. In Angular, this often involves creating different instances of components or services based on certain criteria.
Problem: You need to create objects that share a common interface or base class, but the exact type of object to be instantiated depends on a runtime condition or configuration. Directly instantiating objects with
newwould lead to tightly coupled code.Angular Implementation: In Angular, you can implement Factory Method using a service that acts as the factory, returning different instances based on input. With standalone components, dynamic component creation is also relevant.
// src/app/interfaces/notification.interface.ts export interface Notification { display(): void; } // src/app/models/success-notification.ts export class SuccessNotification implements Notification { constructor(private message: string) {} display(): void { console.log(`Success: ${this.message}`); // In a real app, show a green toast notification } } // src/app/models/error-notification.ts export class ErrorNotification implements Notification { constructor(private message: string) {} display(): void { console.error(`Error: ${this.message}`); // In a real app, show a red alert } } // src/app/services/notification-factory.service.ts import { Injectable } from '@angular/core'; import { Notification, SuccessNotification, ErrorNotification } from '../interfaces/notification.interface'; type NotificationType = 'success' | 'error'; @Injectable({ providedIn: 'root' }) export class NotificationFactoryService { createNotification(type: NotificationType, message: string): Notification { switch (type) { case 'success': return new SuccessNotification(message); case 'error': return new ErrorNotification(message); default: throw new Error('Unknown notification type'); } } }Usage in a component:
// src/app/app.component.ts import { Component, inject } from '@angular/core'; import { NotificationFactoryService } from './services/notification-factory.service'; @Component({ selector: 'app-root', standalone: true, // ... imports template: ` <button (click)="showSuccess()">Show Success</button> <button (click)="showError()">Show Error</button> ` }) export class AppComponent { notificationFactory = inject(NotificationFactoryService); showSuccess() { const successNotif = this.notificationFactory.createNotification('success', 'Operation completed!'); successNotif.display(); } showError() { const errorNotif = this.notificationFactory.createNotification('error', 'Something went wrong!'); errorNotif.display(); } }Use Cases:
- Creating different types of logging mechanisms (console, file, remote).
- Generating various report types (PDF, Excel) based on user selection.
- Instantiating different UI components (e.g., a dialog, a sidebar, a toast) dynamically based on context.
- Handling multi-language content creation.
Abstract Factory
Definition: Provides an interface for creating families of related or dependent objects without specifying their concrete classes.
Problem: Your application needs to support multiple “families” of related objects, and you want to ensure that the objects created belong to the same family. For example, different UI themes (light/dark) might have different button, input, and card components, but you always want to use components from a consistent theme.
Angular Implementation: This can be implemented using multiple factory services, each providing a different set of related objects, and then injecting the appropriate factory based on a configuration.
// src/app/interfaces/ui-elements.interface.ts export interface Button { click(): void; render(): string; } export interface Input { typeText(text: string): void; render(): string; } // Light Theme Implementations export class LightButton implements Button { click(): void { console.log('Light Button Click!'); } render(): string { return '<button style="background-color: lightblue;">Light Button</button>'; } } export class LightInput implements Input { typeText(text: string): void { console.log(`Light Input: ${text}`); } render(): string { return '<input style="border: 1px solid lightgray;">'; } } // Dark Theme Implementations export class DarkButton implements Button { click(): void { console.log('Dark Button Click!'); } render(): string { return '<button style="background-color: darkblue; color: white;">Dark Button</button>'; } } export class DarkInput implements Input { typeText(text: string): void { console.log(`Dark Input: ${text}`); } render(): string { return '<input style="border: 1px solid darkgray; color: white; background-color: #333;">'; } } // src/app/factories/ui-factory.abstract.ts import { Button, Input } from '../interfaces/ui-elements.interface'; export abstract class UiFactory { abstract createButton(): Button; abstract createInput(): Input; } // src/app/factories/light-ui.factory.ts import { Injectable } from '@angular/core'; import { UiFactory } from './ui-factory.abstract'; import { LightButton, LightInput } from '../interfaces/ui-elements.interface'; @Injectable({ providedIn: 'root' }) // Or 'any' if scoped export class LightUiFactory extends UiFactory { createButton(): LightButton { return new LightButton(); } createInput(): LightInput { return new LightInput(); } } // src/app/factories/dark-ui.factory.ts import { Injectable } from '@angular/core'; import { UiFactory } from './ui-factory.abstract'; import { DarkButton, DarkInput } from '../interfaces/ui-elements.interface'; @Injectable({ providedIn: 'root' }) // Or 'any' if scoped export class DarkUiFactory extends UiFactory { createButton(): DarkButton { return new DarkButton(); } createInput(): DarkInput { return new DarkInput(); } } // src/app/injection-tokens/ui-factory.token.ts import { InjectionToken } from '@angular/core'; import { UiFactory } from '../factories/ui-factory.abstract'; export const UI_FACTORY = new InjectionToken<UiFactory>('UI_FACTORY'); // src/app/app.config.ts (or relevant NgModule providers array) import { ApplicationConfig, importProvidersFrom } from '@angular/core'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; import { provideHttpClient } from '@angular/common/http'; import { UI_FACTORY } from './injection-tokens/ui-factory.token'; import { LightUiFactory } from './factories/light-ui.factory'; import { DarkUiFactory } from './factories/dark-ui.factory'; // This could come from a user setting or feature flag const IS_DARK_THEME = true; export const appConfig: ApplicationConfig = { providers: [ provideRouter(routes), provideHttpClient(), { provide: UI_FACTORY, useClass: IS_DARK_THEME ? DarkUiFactory : LightUiFactory } ] }; // src/app/app.component.ts import { Component, inject } from '@angular/core'; import { UI_FACTORY } from './injection-tokens/ui-factory.token'; import { Button, Input } from './interfaces/ui-elements.interface'; @Component({ selector: 'app-root', standalone: true, template: ` <h2>Abstract Factory Example ({{ themeName }})</h2> <div [innerHTML]="buttonHtml"></div> <div [innerHTML]="inputHtml"></div> <button (click)="onButtonClick()">Test Button Click</button> <button (click)="onInputType()">Test Input Type</button> ` }) export class AppComponent { private uiFactory = inject(UI_FACTORY); private button: Button; private input: Input; buttonHtml: string = ''; inputHtml: string = ''; themeName: string = IS_DARK_THEME ? 'Dark Theme' : 'Light Theme'; // From app.config.ts constructor() { this.button = this.uiFactory.createButton(); this.input = this.uiFactory.createInput(); this.buttonHtml = this.button.render(); this.inputHtml = this.input.render(); } onButtonClick() { this.button.click(); } onInputType() { this.input.typeText('Hello World'); } }Use Cases:
- Supporting multiple UI themes (e.g., Material Design, Bootstrap, custom).
- Providing different implementations for external integrations (e.g., different payment gateways, different analytics providers).
- Creating platform-specific components (e.g., web, mobile, desktop variations).
Builder
Definition: Constructs a complex object step by step. The same construction process can create different representations of the object.
Problem: You need to create a complex object that has many optional parts or requires a specific construction order. A constructor with many parameters would be unreadable and error-prone.
Angular Implementation: A service or a class can act as a builder to construct complex data models, forms, or dynamic components.
// src/app/models/report.model.ts export class Report { title!: string; header?: string; content!: string[]; footer?: string; sections: { name: string; text: string }[] = []; print(): void { console.log('--- Report ---'); if (this.header) console.log(`Header: ${this.header}`); console.log(`Title: ${this.title}`); this.content.forEach(p => console.log(p)); this.sections.forEach(s => console.log(`\nSection ${s.name}:\n${s.text}`)); if (this.footer) console.log(`Footer: ${this.footer}`); console.log('--------------'); } } // src/app/builders/report.builder.ts import { Report } from '../models/report.model'; export class ReportBuilder { private report: Report; constructor() { this.report = new Report(); } withTitle(title: string): ReportBuilder { this.report.title = title; return this; } withHeader(header: string): ReportBuilder { this.report.header = header; return this; } withContent(content: string[]): ReportBuilder { this.report.content = content; return this; } withFooter(footer: string): ReportBuilder { this.report.footer = footer; return this; } addSection(name: string, text: string): ReportBuilder { this.report.sections.push({ name, text }); return this; } build(): Report { if (!this.report.title || !this.report.content?.length) { throw new Error('Report must have a title and content.'); } return this.report; } }Usage in a component:
// src/app/app.component.ts import { Component } from '@angular/core'; import { ReportBuilder } from './builders/report.builder'; @Component({ selector: 'app-root', standalone: true, template: ` <button (click)="createSimpleReport()">Create Simple Report</button> <button (click)="createDetailedReport()">Create Detailed Report</button> ` }) export class AppComponent { createSimpleReport() { try { const simpleReport = new ReportBuilder() .withTitle('Monthly Summary') .withContent(['This is a summary of the month.', 'Key highlights included...']) .build(); simpleReport.print(); } catch (e: any) { console.error(e.message); } } createDetailedReport() { try { const detailedReport = new ReportBuilder() .withTitle('Annual Performance Review') .withHeader('Confidential') .withContent(['This report outlines the annual performance.']) .addSection('Financials', 'Detailed financial data is presented here.') .addSection('Operations', 'Operational improvements and challenges.') .withFooter('End of Report - © 2025') .build(); detailedReport.print(); } catch (e: any) { console.error(e.message); } } }Use Cases:
- Constructing complex dynamic forms where fields and validations depend on various conditions.
- Building query objects for API calls with numerous optional filters and sorting criteria.
- Creating UI components (e.g., modals, notification banners) with varying configurations and content.
- Generating reports or documents with configurable sections.
Prototype
Definition: Creates new objects by copying an existing object (the prototype).
Problem: You need to create new objects based on an existing instance, especially when object creation is expensive or when you need to avoid tight coupling between the client and the concrete classes being instantiated. While less common in Angular due to its reactive nature and preference for immutable data, it can be useful for mutable data structures or configuration objects.
Angular Implementation: This typically involves implementing a
clone()method on your data models or using utility functions to deep-copy objects.// src/app/models/user-settings.model.ts export class UserSettings { theme: string; notificationsEnabled: boolean; language: string; constructor(theme: string, notificationsEnabled: boolean, language: string) { this.theme = theme; this.notificationsEnabled = notificationsEnabled; this.language = language; } // The clone method clone(): UserSettings { // Deep copy properties if they are complex objects return new UserSettings(this.theme, this.notificationsEnabled, this.language); } display(): void { console.log(`Settings: Theme=${this.theme}, Notifications=${this.notificationsEnabled}, Language=${this.language}`); } } // src/app/app.component.ts import { Component } from '@angular/core'; import { UserSettings } from './models/user-settings.model'; @Component({ selector: 'app-root', standalone: true, template: ` <button (click)="modifySettings()">Modify Settings</button> ` }) export class AppComponent { private defaultSettings = new UserSettings('light', true, 'en'); constructor() { console.log('Default Settings:'); this.defaultSettings.display(); } modifySettings() { // Create a mutable copy using the prototype pattern const userSpecificSettings = this.defaultSettings.clone(); userSpecificSettings.theme = 'dark'; userSpecificSettings.notificationsEnabled = false; userSpecificSettings.display(); // Modified copy console.log('Original Settings after modification attempt:'); this.defaultSettings.display(); // Original remains unchanged } }Use Cases:
- When creating new instances of an object is expensive, and an existing object can serve as a template.
- Managing mutable configuration objects where you need to create a new, modifiable version without affecting the original.
- In scenarios where you need to support “undo” functionality for object state (less common directly, as Memento or Command patterns are often preferred).
4. Structural Design Patterns in Angular
Structural patterns deal with object composition, i.e., how objects are assembled to form larger structures. They focus on simplifying the relationships between entities.
Adapter
Definition: Converts the interface of a class into another interface clients expect. Adapter lets classes work together that couldn’t otherwise because of incompatible interfaces.
Problem: You need to integrate an external library, a legacy API, or a third-party service whose data structures or method signatures do not match Angular’s conventions or your application’s expected interfaces. Directly using it would require repetitive data mapping or awkward method calls.
Angular Implementation: Implement an Angular service that acts as an adapter, translating calls and data between your application’s interface and the external one.
// src/app/interfaces/user.interface.ts export interface User { id: string; fullName: string; emailAddress: string; } // External "Legacy" API User Model export interface LegacyUser { _id: string; first_name: string; last_name: string; contact_email: string; // ... other legacy fields } // src/app/services/legacy-api.service.ts (Simulated external API) import { Injectable } from '@angular/core'; import { Observable, of } from 'rxjs'; import { LegacyUser } from '../interfaces/user.interface'; @Injectable({ providedIn: 'root' }) export class LegacyApiService { getLegacyUser(id: string): Observable<LegacyUser> { // Simulate API call console.log(`Fetching legacy user with ID: ${id}`); return of({ _id: id, first_name: 'John', last_name: 'Doe', contact_email: 'john.doe@example.com', // ... }); } } // src/app/adapters/user.adapter.ts import { Injectable, inject } from '@angular/core'; import { Observable, map } from 'rxjs'; import { User, LegacyUser } from '../interfaces/user.interface'; import { LegacyApiService } from '../services/legacy-api.service'; @Injectable({ providedIn: 'root' }) export class UserAdapter { private legacyApiService = inject(LegacyApiService); // Method to convert LegacyUser to User private adaptLegacyUser(legacyUser: LegacyUser): User { return { id: legacyUser._id, fullName: `${legacyUser.first_name} ${legacyUser.last_name}`, emailAddress: legacyUser.contact_email }; } // Method to fetch and adapt getUser(id: string): Observable<User> { return this.legacyApiService.getLegacyUser(id).pipe( map(legacyUser => this.adaptLegacyUser(legacyUser)) ); } }Usage in a component:
// src/app/app.component.ts import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { UserAdapter } from './adapters/user.adapter'; import { User } from './interfaces/user.interface'; import { Observable } from 'rxjs'; @Component({ selector: 'app-root', standalone: true, imports: [CommonModule], template: ` <h2>Adapter Pattern Example</h2> <div *ngIf="user$ | async as user"> <p>ID: {{ user.id }}</p> <p>Name: {{ user.fullName }}</p> <p>Email: {{ user.emailAddress }}</p> </div> ` }) export class AppComponent implements OnInit { private userAdapter = inject(UserAdapter); user$!: Observable<User>; ngOnInit(): void { this.user$ = this.userAdapter.getUser('legacy-user-123'); } }Use Cases:
- Integrating third-party libraries or components into your Angular application.
- Consuming legacy backend APIs with inconsistent naming or data structures.
- Providing a consistent interface to multiple similar services (e.g., different payment providers, all adapted to a generic
PaymentGatewayinterface).
Bridge
Definition: Decouples an abstraction from its implementation so that the two can vary independently.
Problem: You have both an abstraction (e.g., a generic UI component like a
Button) and its implementation (e.g., how that button renders on different platforms or uses different CSS frameworks). If these are tightly coupled, changing one requires changing the other, leading to a proliferation of classes (e.g.,MaterialButton,BootstrapButton).Angular Implementation: This can be seen in highly reusable UI component libraries where the component’s logic (abstraction) is separate from its rendering or underlying CSS framework integration (implementation). The component takes an “implementation” as an input or uses DI to inject it.
// src/app/abstractions/dialog.abstraction.ts export abstract class AbstractDialog { protected constructor(protected implementation: DialogImplementation) {} abstract open(title: string, message: string): void; abstract close(): void; } // src/app/implementations/dialog.implementation.ts export abstract class DialogImplementation { abstract show(title: string, message: string): void; abstract hide(): void; } // src/app/implementations/material-dialog.implementation.ts // This would typically involve a third-party Material library import { Injectable } from '@angular/core'; import { DialogImplementation } from './dialog.implementation'; @Injectable({ providedIn: 'root' }) export class MaterialDialogImplementation implements DialogImplementation { show(title: string, message: string): void { console.log(`[Material Dialog] Showing: ${title} - ${message}`); // Simulate opening a Material dialog } hide(): void { console.log('[Material Dialog] Hiding'); // Simulate closing a Material dialog } } // src/app/implementations/bootstrap-dialog.implementation.ts // This would typically involve a third-party Bootstrap library import { Injectable } from '@angular/core'; import { DialogImplementation } from './dialog.implementation'; @Injectable({ providedIn: 'root' }) export class BootstrapDialogImplementation implements DialogImplementation { show(title: string, message: string): void { console.log(`[Bootstrap Dialog] Showing: ${title} - ${message}`); // Simulate opening a Bootstrap modal } hide(): void { console.log('[Bootstrap Dialog] Hiding'); // Simulate closing a Bootstrap modal } } // src/app/services/dialog.service.ts import { Injectable, inject, InjectionToken } from '@angular/core'; import { DialogImplementation } from '../implementations/dialog.implementation'; // Token to allow dynamic injection of different implementations export const DIALOG_IMPLEMENTATION = new InjectionToken<DialogImplementation>('DIALOG_IMPLEMENTATION'); @Injectable({ providedIn: 'root' // Provided by the root, but the actual implementation is configured in app.config.ts }) export class DialogService { private implementation = inject(DIALOG_IMPLEMENTATION); open(title: string, message: string): void { this.implementation.show(title, message); } close(): void { this.implementation.hide(); } } // src/app/app.config.ts (where you choose the implementation) import { ApplicationConfig } from '@angular/core'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; import { DIALOG_IMPLEMENTATION } from './services/dialog.service'; import { MaterialDialogImplementation } from './implementations/material-dialog.implementation'; import { BootstrapDialogImplementation } from './implementations/bootstrap-dialog.implementation'; // Toggle this to switch implementations const USE_MATERIAL_DIALOG = true; export const appConfig: ApplicationConfig = { providers: [ provideRouter(routes), { provide: DIALOG_IMPLEMENTATION, useClass: USE_MATERIAL_DIALOG ? MaterialDialogImplementation : BootstrapDialogImplementation } ] }; // src/app/app.component.ts import { Component, inject } from '@angular/core'; import { DialogService } from './services/dialog.service'; @Component({ selector: 'app-root', standalone: true, template: ` <h2>Bridge Pattern Example</h2> <button (click)="openDialog()">Open Dialog</button> <button (click)="closeDialog()">Close Dialog</button> ` }) export class AppComponent { dialogService = inject(DialogService); openDialog() { this.dialogService.open('Welcome', 'This is a message from the dialog.'); } closeDialog() { this.dialogService.close(); } }Use Cases:
- Building cross-platform components that share core logic but have different rendering engines (e.g., Angular for web, NativeScript/Ionic for mobile).
- Creating a generic UI component library that can easily switch between different CSS frameworks (e.g., Material, Bootstrap, Ant Design) without rewriting the component logic.
- Providing interchangeable data storage mechanisms (e.g., local storage, session storage, backend API) for a single data access layer.
Composite
Definition: Composes objects into tree structures to represent part-whole hierarchies. Composite lets clients treat individual objects and compositions of objects uniformly.
Problem: You need to work with objects that can be grouped into a tree-like structure, where both individual objects and groups of objects should be treated in the same way. A common example is a menu system with menu items and submenus, or a file system with files and folders.
Angular Implementation: Components naturally lend themselves to the Composite pattern, especially when building nested UI elements like menus, file explorers, or organizational charts.
// src/app/interfaces/menu-item.interface.ts export interface MenuItem { name: string; icon?: string; onClick(): void; // Can be empty for composite nodes children?: MenuItem[]; } // src/app/components/menu-item/menu-item.component.ts import { Component, Input } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MenuItem } from '../../interfaces/menu-item.interface'; @Component({ selector: 'app-menu-item', standalone: true, imports: [CommonModule], template: ` <li class="menu-item" (click)="item.onClick()"> <span *ngIf="item.icon" class="icon">{{ item.icon }}</span> {{ item.name }} <ul *ngIf="item.children && item.children.length > 0" class="sub-menu"> <ng-container *ngFor="let child of item.children"> <app-menu-item [item]="child"></app-menu-item> </ng-container> </ul> </li> `, styles: [` .menu-item { cursor: pointer; padding: 5px; margin-left: 15px; } .menu-item:hover { background-color: #f0f0f0; } .sub-menu { list-style-type: none; padding-left: 10px; } .icon { margin-right: 5px; } `] }) export class MenuItemComponent { @Input() item!: MenuItem; } // src/app/app.component.ts import { Component } from '@angular/core'; import { MenuItemComponent } from './components/menu-item/menu-item.component'; import { MenuItem } from './interfaces/menu-item.interface'; @Component({ selector: 'app-root', standalone: true, imports: [MenuItemComponent], template: ` <h2>Composite Pattern Example: Menu</h2> <ul class="main-menu"> <ng-container *ngFor="let item of menuItems"> <app-menu-item [item]="item"></app-menu-item> </ng-container> </ul> `, styles: [` .main-menu { list-style-type: none; padding: 0; } `] }) export class AppComponent { menuItems: MenuItem[] = [ { name: 'Dashboard', icon: '📊', onClick: () => console.log('Navigating to Dashboard') }, { name: 'Products', icon: '📦', onClick: () => {}, // No direct action for parent children: [ { name: 'View All Products', onClick: () => console.log('Navigating to All Products') }, { name: 'Add New Product', onClick: () => console.log('Opening Add Product form') } ] }, { name: 'Settings', icon: '⚙️', onClick: () => {}, // No direct action for parent children: [ { name: 'User Profile', onClick: () => console.log('Navigating to User Profile') }, { name: 'Security', onClick: () => console.log('Navigating to Security Settings') }, { name: 'Preferences', onClick: () => {}, children: [ { name: 'Theme', onClick: () => console.log('Changing theme') }, { name: 'Language', onClick: () => console.log('Changing language') } ] } ] }, { name: 'Help', icon: '❓', onClick: () => console.log('Opening Help documentation') } ]; }Use Cases:
- Building hierarchical navigation menus, sitemaps, or breadcrumbs.
- Representing file system structures or organizational charts in UI.
- Creating reusable UI component hierarchies where parent components manage child components uniformly.
- Any scenario where you have “parts” and “wholes” that clients should treat interchangeably.
Decorator
Definition: Attaches additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending functionality.
Problem: You want to add new features or behaviors to existing objects (components, services, or even RxJS observables) without altering their original code or creating a multitude of subclasses.
Angular Implementation:
- Directives: Angular directives are a primary mechanism for decorating DOM elements or components with additional behavior.
- Higher-Order Components (HOCs) / Higher-Order Directives (HODs): While not a built-in Angular concept, you can create functions that take a component/directive and return a new one with enhanced capabilities.
- RxJS Operators: These are powerful examples of the Decorator pattern applied to observables, adding functionality to streams.
Example 1: Decorating a Component with a Directive
// src/app/directives/tooltip.directive.ts import { Directive, ElementRef, HostListener, Input } from '@angular/core'; @Directive({ selector: '[appTooltip]', // Apply as an attribute standalone: true }) export class TooltipDirective { @Input('appTooltip') tooltipText: string = ''; private tooltipElement: HTMLElement | null = null; constructor(private el: ElementRef) {} @HostListener('mouseenter') onMouseEnter() { if (this.tooltipText) { this.createTooltip(); } } @HostListener('mouseleave') onMouseLeave() { this.destroyTooltip(); } private createTooltip() { this.tooltipElement = document.createElement('div'); this.tooltipElement.innerText = this.tooltipText; this.tooltipElement.style.cssText = ` position: absolute; background-color: #333; color: white; padding: 5px 10px; border-radius: 4px; font-size: 12px; z-index: 1000; white-space: nowrap; `; document.body.appendChild(this.tooltipElement); this.positionTooltip(); } private positionTooltip() { if (this.tooltipElement) { const hostRect = this.el.nativeElement.getBoundingClientRect(); this.tooltipElement.style.left = `${hostRect.left + window.scrollX + hostRect.width / 2 - this.tooltipElement.offsetWidth / 2}px`; this.tooltipElement.style.top = `${hostRect.top + window.scrollY - this.tooltipElement.offsetHeight - 5}px`; } } private destroyTooltip() { if (this.tooltipElement) { this.tooltipElement.remove(); this.tooltipElement = null; } } }Usage in a component’s HTML:
<!-- src/app/app.component.html --> <button appTooltip="This is a button tooltip">Hover over me</button> <p appTooltip="Detailed information for this paragraph.">Some text.</p>// src/app/app.component.ts import { Component } from '@angular/core'; import { TooltipDirective } from './directives/tooltip.directive'; // Import the directive @Component({ selector: 'app-root', standalone: true, imports: [TooltipDirective], // Make the directive available templateUrl: './app.component.html' // Assumes HTML above is in app.component.html }) export class AppComponent { // ... component logic }Example 2: RxJS Operators (Implicit Decorator)
// src/app/services/data.service.ts import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { map, retry, catchError } from 'rxjs/operators'; import { of } from 'rxjs'; interface Product { id: number; name: string; price: number; } @Injectable({ providedIn: 'root' }) export class DataService { constructor(private http: HttpClient) {} getProducts(): Observable<Product[]> { // Simulate an HTTP call return of([ { id: 1, name: 'Laptop', price: 1200 }, { id: 2, name: 'Mouse', price: 25 } ]).pipe( retry(2), // Decorates the observable with retry logic map(products => products.map(p => ({ ...p, price: p.price * 1.05 }))), // Decorates with price adjustment catchError(error => { // Decorates with error handling console.error('Error fetching products:', error); return of([]); // Return an empty array on error }) ); } }Use Cases:
- Adding dynamic behavior (tooltips, drag-and-drop, debouncing) to existing elements or components using directives.
- Enhancing observables with error handling, logging, transformation, or filtering using RxJS operators.
- Creating reusable logging or analytics capabilities that can be “attached” to methods or classes.
Facade
Definition: Provides a unified interface to a set of interfaces in a subsystem. Facade defines a higher-level interface that makes the subsystem easier to use.
Problem: Your application interacts with a complex subsystem (e.g., multiple backend APIs, intricate state management logic, or several third-party libraries). Directly calling methods on individual parts of this subsystem makes client code complex, tightly coupled, and hard to maintain.
Angular Implementation: An Angular service can act as a facade, encapsulating the complexity of underlying services or interactions. This is very common in Angular, especially for data access layers.
// src/app/services/product-api.service.ts (Lower-level service) import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; interface ProductApiResult { product_id: string; product_name: string; unit_price: number; } @Injectable({ providedIn: 'root' }) export class ProductApiService { constructor(private http: HttpClient) {} getProductsApi(): Observable<ProductApiResult[]> { // Simulates fetching raw product data console.log('ProductApiService: Fetching products from API...'); return of([ { product_id: 'P001', product_name: 'Laptop', unit_price: 1200 }, { product_id: 'P002', product_name: 'Keyboard', unit_price: 75 } ]); } // ... other product-related API calls (create, update, delete) } // src/app/services/inventory-api.service.ts (Another lower-level service) import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, of } from 'rxjs'; interface InventoryItem { item_id: string; stock_count: number; warehouse_location: string; } @Injectable({ providedIn: 'root' }) export class InventoryApiService { constructor(private http: HttpClient) {} getInventoryApi(productId: string): Observable<InventoryItem> { // Simulates fetching inventory data for a product console.log(`InventoryApiService: Fetching inventory for ${productId}...`); return of({ item_id: productId, stock_count: productId === 'P001' ? 10 : 50, warehouse_location: 'A1' }); } } // src/app/models/product.model.ts (Domain Model) export interface Product { id: string; name: string; price: number; stock: number; } // src/app/facades/shop.facade.ts (The Facade Service) import { Injectable, inject } from '@angular/core'; import { Observable, forkJoin } from 'rxjs'; import { map, switchMap } from 'rxjs/operators'; import { ProductApiService } from '../services/product-api.service'; import { InventoryApiService } from '../services/inventory-api.service'; import { Product } from '../models/product.model'; @Injectable({ providedIn: 'root' }) export class ShopFacade { private productApi = inject(ProductApiService); private inventoryApi = inject(InventoryApiService); getProductsWithStock(): Observable<Product[]> { console.log('ShopFacade: Getting all products with stock information...'); return this.productApi.getProductsApi().pipe( switchMap(productResults => { const productObservables = productResults.map(p => this.inventoryApi.getInventoryApi(p.product_id).pipe( map(inventory => ({ id: p.product_id, name: p.product_name, price: p.unit_price, stock: inventory.stock_count })) ) ); return forkJoin(productObservables); // Combine all product+inventory observables }) ); } // Add more high-level operations here, e.g., placeOrder() }Usage in a component:
// src/app/app.component.ts import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ShopFacade } from './facades/shop.facade'; import { Product } from './models/product.model'; import { Observable } from 'rxjs'; @Component({ selector: 'app-root', standalone: true, imports: [CommonModule], template: ` <h2>Facade Pattern Example: Shop Products</h2> <ul *ngIf="products$ | async as products"> <li *ngFor="let product of products"> {{ product.name }} - ${{ product.price }} (Stock: {{ product.stock }}) </li> </ul> ` }) export class AppComponent implements OnInit { private shopFacade = inject(ShopFacade); products$!: Observable<Product[]>; ngOnInit(): void { this.products$ = this.shopFacade.getProductsWithStock(); } }Use Cases:
- Simplifying complex API interactions by combining calls to multiple backend services into a single, cohesive interface.
- Encapsulating complex state management logic (e.g., in NgRx effects or ngrx/data services) behind a simpler service.
- Providing a simplified interface to a third-party library that has many classes and methods.
- Creating a “business logic” layer that combines data from various sources and presents it in a unified way to the UI.
Flyweight
Definition: Reduces the number of objects created to improve performance and memory usage. It does this by sharing common parts of state between multiple objects instead of keeping it in each object.
Problem: Your application needs to display a large number of similar objects (e.g., list items, cells in a grid, particles in a simulation) where many objects share common data but have small unique parts. Creating a unique instance for each object consumes excessive memory and can impact performance.
Angular Implementation: While not always a direct mapping, Angular’s change detection and rendering optimizations, especially with
*ngForand trackBy, or advanced techniques like virtual scrolling, can be considered forms of applying Flyweight principles by recycling or efficiently managing rendering of many similar elements. Explicit implementation involves a factory that reuses shared objects.// src/app/models/character.model.ts (The "Flyweight" - intrinsic state) export class CharacterStyle { constructor( public font: string, public color: string, public size: string ) {} // A simple cache for styles private static cache: Map<string, CharacterStyle> = new Map(); static getStyle(font: string, color: string, size: string): CharacterStyle { const key = `${font}-${color}-${size}`; if (!CharacterStyle.cache.has(key)) { CharacterStyle.cache.set(key, new CharacterStyle(font, color, size)); } return CharacterStyle.cache.get(key)!; } } // src/app/components/text-character/text-character.component.ts (The "Context" - extrinsic state) import { Component, Input, OnInit } from '@angular/core'; import { CharacterStyle } from '../../models/character.model'; @Component({ selector: 'app-text-character', standalone: true, template: ` <span [style.font-family]="characterStyle.font" [style.color]="characterStyle.color" [style.font-size]="characterStyle.size"> {{ char }} </span> ` }) export class TextCharacterComponent implements OnInit { @Input() char!: string; @Input() styleProps!: { font: string; color: string; size: string }; // Extrinsic state characterStyle!: CharacterStyle; ngOnInit(): void { // The component uses the shared CharacterStyle instance this.characterStyle = CharacterStyle.getStyle( this.styleProps.font, this.styleProps.color, this.styleProps.size ); } } // src/app/app.component.ts import { Component } from '@angular/core'; import { TextCharacterComponent } from './components/text-character/text-character.component'; import { CommonModule } from '@angular/common'; @Component({ selector: 'app-root', standalone: true, imports: [CommonModule, TextCharacterComponent], template: ` <h2>Flyweight Pattern Example: Styled Text</h2> <div class="text-display"> <ng-container *ngFor="let charConfig of textConfigs"> <app-text-character [char]="charConfig.char" [styleProps]="charConfig.style" ></app-text-character> </ng-container> </div> <p>Check console for `CharacterStyle` instance creation logs.</p> `, styles: [` .text-display { display: flex; flex-wrap: wrap; border: 1px solid #ccc; padding: 10px; } `] }) export class AppComponent { // Simulate text with varying styles. // Notice many characters share the same style properties (flyweights). textConfigs = [ { char: 'H', style: { font: 'Arial', color: 'red', size: '20px' } }, { char: 'e', style: { font: 'Arial', color: 'red', size: '20px' } }, { char: 'l', style: { font: 'Arial', color: 'red', size: '20px' } }, { char: 'l', style: { font: 'Arial', color: 'red', size: '20px' } }, { char: 'o', style: { font: 'Arial', color: 'red', size: '20px' } }, { char: ' ', style: { font: 'Arial', color: 'black', size: '16px' } }, { char: 'W', style: { font: 'Verdana', color: 'blue', size: '18px' } }, { char: 'o', style: { font: 'Verdana', color: 'blue', size: '18px' } }, { char: 'r', style: { font: 'Verdana', color: 'blue', size: '18px' } }, { char: 'l', style: { font: 'Verdana', color: 'blue', size: '18px' } }, { char: 'd', style: { font: 'Verdana', color: 'blue', size: '18px' } }, { char: '!', style: { font: 'Arial', color: 'red', size: '20px' } } ]; // You'll see "CharacterStyle instance created" only a few times in console, // not for every character, demonstrating sharing. }Use Cases:
- Virtual scrolling or large data grids where only a subset of items are rendered, and rendering components are recycled (e.g., Angular CDK Virtual Scroll).
- Displaying large numbers of UI elements (e.g., icons, simple buttons) that share common visual styles and only differ in their content or position.
- Managing resources like image caches where many objects might point to the same underlying image data.
Proxy
Definition: Provides a surrogate or placeholder for another object to control access to it.
Problem: You need to control access to an object, add behavior before or after its methods are called, or defer its instantiation until it’s actually needed (lazy loading). Directly accessing the object might bypass security checks, logging, or lead to unnecessary resource consumption.
Angular Implementation:
- Lazy Loading Modules/Components: Angular’s router lazy loading is a prime example, where modules or standalone components are only loaded when their route is activated.
- HTTP Interceptors: These act as proxies for
HttpClientrequests, allowing you to intercept and modify requests/responses globally. - Creating a service that wraps another service to add logging, caching, or security.
Example 1: Lazy Loading (Router’s Proxy) The router configuration demonstrates implicit Proxy pattern where
loadComponentdefers loadingAdminDashboardComponentuntil theadminpath is accessed.// src/app/app.routes.ts import { Routes } from '@angular/router'; export const routes: Routes = [ { path: '', redirectTo: 'home', pathMatch: 'full' }, { path: 'home', loadComponent: () => import('./components/home/home.component').then(m => m.HomeComponent) }, { path: 'admin', // This is where the Proxy pattern is applied: AdminDashboardComponent is lazy-loaded loadComponent: () => import('./components/admin-dashboard/admin-dashboard.component').then(m => m.AdminDashboardComponent) }, { path: '**', redirectTo: 'home' } ];// src/app/components/home/home.component.ts import { Component } from '@angular/core'; import { RouterLink } from '@angular/router'; @Component({ selector: 'app-home', standalone: true, imports: [RouterLink], template: ` <h2>Home Component</h2> <p>This is the home page. The admin module is not loaded yet.</p> <button [routerLink]="['/admin']">Go to Admin Dashboard</button> ` }) export class HomeComponent {} // src/app/components/admin-dashboard/admin-dashboard.component.ts import { Component } from '@angular/core'; @Component({ selector: 'app-admin-dashboard', standalone: true, template: ` <h2>Admin Dashboard (Lazy Loaded)</h2> <p>This component was loaded only when you navigated to '/admin'.</p> ` }) export class AdminDashboardComponent { constructor() { console.log('AdminDashboardComponent loaded!'); // You'll see this only on navigation } }Example 2: Caching Proxy Service
// src/app/services/heavy-data.service.ts (The "Real Subject") import { Injectable } from '@angular/core'; import { Observable, of } from 'rxjs'; import { delay, tap } from 'rxjs/operators'; @Injectable({ providedIn: 'root' }) export class HeavyDataService { getComplexData(): Observable<string> { console.log('HeavyDataService: Fetching complex data...'); // Simulate a heavy operation (e.g., large data fetch, complex calculation) return of('Complex Data fetched from source').pipe(delay(1000)); } } // src/app/proxies/data-caching.proxy.ts (The "Proxy") import { Injectable, inject } from '@angular/core'; import { Observable, of } from 'rxjs'; import { tap } from 'rxjs/operators'; import { HeavyDataService } from '../services/heavy-data.service'; @Injectable({ providedIn: 'root' }) export class DataCachingProxy { private heavyDataService = inject(HeavyDataService); private cache: string | null = null; private lastFetched: number = 0; private cacheDurationMs = 5000; // Cache for 5 seconds getComplexData(): Observable<string> { const now = Date.now(); if (this.cache && (now - this.lastFetched < this.cacheDurationMs)) { console.log('DataCachingProxy: Returning data from cache.'); return of(this.cache); } else { console.log('DataCachingProxy: Cache expired or empty, fetching from real service.'); return this.heavyDataService.getComplexData().pipe( tap(data => { this.cache = data; this.lastFetched = now; }) ); } } }Usage in a component:
// src/app/app.component.ts import { Component, inject } from '@angular/core'; import { DataCachingProxy } from './proxies/data-caching.proxy'; import { CommonModule } from '@angular/common'; @Component({ selector: 'app-root', standalone: true, imports: [CommonModule], template: ` <h2>Proxy Pattern Example: Caching Service</h2> <button (click)="fetchData()">Fetch Data</button> <p>Data: {{ cachedData }}</p> <p>Check console to see if real service is called or cache is used.</p> ` }) export class AppComponent { private dataProxy = inject(DataCachingProxy); cachedData: string | null = null; fetchData(): void { this.dataProxy.getComplexData().subscribe(data => { this.cachedData = data; }); } }Use Cases:
- Lazy loading: Deferring the loading of modules, components, or data until they are explicitly needed (e.g., with Angular Router).
- Caching: Providing a cached version of expensive operations (as shown above).
- Security/Access Control: Restricting access to certain functionalities based on user roles (e.g., an
AuthProxyServicethat checks permissions before allowing access to certainAdminServicemethods). - Logging/Monitoring: Intercepting method calls to log their execution or performance metrics.
5. Behavioral Design Patterns in Angular
Behavioral patterns deal with the algorithms and assignment of responsibilities between objects. They describe how objects and classes interact and distribute responsibility.
Chain of Responsibility
Definition: Passes a request along a chain of handlers. Upon receiving a request, each handler decides either to process the request or to pass it to the next handler in the chain.
Problem: You have a series of operations or validations that might need to be applied to a request or an object, and the order or execution depends on various conditions. You want to avoid coupling the sender of a request to its receivers, allowing multiple objects to handle the request without knowing the full chain structure.
Angular Implementation: HTTP Interceptors in Angular are a perfect example. Other applications include form validation pipelines, or event processing where multiple services might respond to an action.
Example: HTTP Interceptors (Built-in Chain of Responsibility) Angular’s
HTTP_INTERCEPTORSprovide a powerful, built-in implementation of the Chain of Responsibility pattern. Each interceptor can process an HTTP request before passing it to the next in the chain, or even short-circuit the chain.// src/app/interceptors/auth.interceptor.ts import { Injectable } from '@angular/core'; import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent } from '@angular/common/http'; import { Observable } from 'rxjs'; @Injectable() export class AuthInterceptor implements HttpInterceptor { intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { // Add authorization token (if available) const authToken = localStorage.getItem('authToken'); if (authToken) { request = request.clone({ setHeaders: { Authorization: `Bearer ${authToken}` } }); console.log('AuthInterceptor: Added auth token.'); } return next.handle(request); } } // src/app/interceptors/logging.interceptor.ts import { Injectable } from '@angular/core'; import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpResponse } from '@angular/common/http'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; @Injectable() export class LoggingInterceptor implements HttpInterceptor { intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { const started = Date.now(); console.log(`LoggingInterceptor: ${request.method} ${request.url} started.`); return next.handle(request).pipe( tap(event => { if (event instanceof HttpResponse) { const elapsed = Date.now() - started; console.log(`LoggingInterceptor: ${request.method} ${request.url} completed in ${elapsed} ms.`); } }) ); } } // src/app/app.config.ts (or NgModule providers) import { ApplicationConfig } from '@angular/core'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; import { provideHttpClient, withInterceptorsFromDi, HTTP_INTERCEPTORS } from '@angular/common/http'; import { AuthInterceptor } from './interceptors/auth.interceptor'; import { LoggingInterceptor } from './interceptors/logging.interceptor'; export const appConfig: ApplicationConfig = { providers: [ provideRouter(routes), provideHttpClient(withInterceptorsFromDi()), // Enable DI-based interceptors { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor, multi: true } ] }; // src/app/app.component.ts import { Component, inject } from '@angular/core'; import { HttpClient, HttpClientModule } from '@angular/common/http'; import { CommonModule } from '@angular/common'; @Component({ selector: 'app-root', standalone: true, imports: [CommonModule, HttpClientModule], // HttpClientModule needed for basic HttpClient DI template: ` <h2>Chain of Responsibility Example: HTTP Interceptors</h2> <button (click)="fetchData()">Fetch Data (Check Console)</button> <p *ngIf="data">Data: {{ data | json }}</p> ` }) export class AppComponent { private http = inject(HttpClient); data: any; fetchData() { // Set a dummy token for AuthInterceptor to act localStorage.setItem('authToken', 'my-dummy-jwt'); this.http.get('https://jsonplaceholder.typicode.com/posts/1').subscribe({ next: response => { this.data = response; console.log('Component: Data received!'); }, error: err => console.error('Component: Error fetching data', err) }); } }Use Cases:
- HTTP request processing: Authentication, logging, error handling, caching, retry logic via HTTP Interceptors.
- Form validation pipelines: A series of validators that process user input.
- Workflow processing: Defining steps in a business process where each step can decide to process or pass to the next.
- Event handling: Allowing multiple handlers to potentially react to an event until one handles it.
Command
Definition: Encapsulates a request as an object, thereby letting you parameterize clients with different requests, queue or log requests, and support undoable operations.
Problem: You need to decouple the object that invokes an operation from the object that knows how to perform it. You might also need to queue operations, log them, or provide undo/redo functionality.
Angular Implementation: Command pattern can be implemented using a service that orchestrates commands, each command being a class that implements a common interface with an
execute()method (and optionallyundo()).// src/app/commands/command.interface.ts export interface Command { execute(): void; undo?(): void; // Optional for undoable commands } // src/app/commands/add-task.command.ts import { Command } from './command.interface'; import { TaskService } from '../services/task.service'; import { Task } from '../models/task.model'; export class AddTaskCommand implements Command { private taskId: string | null = null; // Store ID for undo constructor(private taskService: TaskService, private task: Task) {} execute(): void { console.log(`Executing AddTaskCommand: ${this.task.title}`); this.taskService.addTask(this.task); this.taskId = this.task.id; // Assume service assigns ID } undo?(): void { if (this.taskId) { console.log(`Undoing AddTaskCommand: ${this.task.title}`); this.taskService.deleteTask(this.taskId); } } } // src/app/commands/delete-task.command.ts import { Command } from './command.interface'; import { TaskService } from '../services/task.service'; import { Task } from '../models/task.model'; export class DeleteTaskCommand implements Command { private deletedTask: Task | null = null; constructor(private taskService: TaskService, private taskId: string) {} execute(): void { console.log(`Executing DeleteTaskCommand for ID: ${this.taskId}`); this.deletedTask = this.taskService.getTaskById(this.taskId); if (this.deletedTask) { this.taskService.deleteTask(this.taskId); } else { console.warn(`Task with ID ${this.taskId} not found for deletion.`); } } undo?(): void { if (this.deletedTask) { console.log(`Undoing DeleteTaskCommand for: ${this.deletedTask.title}`); this.taskService.addTask(this.deletedTask); } } } // src/app/services/task.service.ts (Simulated backend/state) import { Injectable, signal, computed } from '@angular/core'; import { Task } from '../models/task.model'; import { v4 as uuidv4 } from 'uuid'; // npm install uuid, npm install @types/uuid @Injectable({ providedIn: 'root' }) export class TaskService { private _tasks = signal<Task[]>([]); tasks = computed(() => this._tasks()); // Expose as computed signal addTask(task: Task): void { const newTask = { ...task, id: task.id || uuidv4() }; // Assign ID if not present this._tasks.update(currentTasks => [...currentTasks, newTask]); console.log(`Task added: ${newTask.title}`); } deleteTask(id: string): void { this._tasks.update(currentTasks => currentTasks.filter(t => t.id !== id)); console.log(`Task deleted: ID ${id}`); } getTaskById(id: string): Task | null { return this._tasks().find(t => t.id === id) || null; } } // src/app/services/command-history.service.ts (Invoker / History) import { Injectable } from '@angular/core'; import { Command } from '../commands/command.interface'; @Injectable({ providedIn: 'root' }) export class CommandHistoryService { private history: Command[] = []; private currentIndex: number = -1; executeCommand(command: Command): void { // Clear redo history if a new command is executed if (this.currentIndex < this.history.length - 1) { this.history = this.history.slice(0, this.currentIndex + 1); } command.execute(); this.history.push(command); this.currentIndex = this.history.length - 1; console.log('Command executed. History:', this.history.length); } undo(): void { if (this.currentIndex >= 0) { const command = this.history[this.currentIndex]; if (command.undo) { command.undo(); this.currentIndex--; console.log('Undo executed. History Index:', this.currentIndex); } else { console.warn('Cannot undo: Command does not support undo operation.'); } } else { console.warn('Nothing to undo.'); } } redo(): void { if (this.currentIndex < this.history.length - 1) { this.currentIndex++; const command = this.history[this.currentIndex]; command.execute(); // Re-execute the command console.log('Redo executed. History Index:', this.currentIndex); } else { console.warn('Nothing to redo.'); } } canUndo(): boolean { return this.currentIndex >= 0 && !!this.history[this.currentIndex]?.undo; } canRedo(): boolean { return this.currentIndex < this.history.length - 1; } } // src/app/app.component.ts import { Component, inject } from '@angular/core'; import { CommandHistoryService } from './services/command-history.service'; import { TaskService } from './services/task.service'; import { AddTaskCommand } from './commands/add-task.command'; import { DeleteTaskCommand } from './commands/delete-task.command'; import { Task } from './models/task.model'; import { CommonModule } from '@angular/common'; @Component({ selector: 'app-root', standalone: true, imports: [CommonModule], template: ` <h2>Command Pattern Example: Task Manager Undo/Redo</h2> <button (click)="addTask()">Add Task</button> <button (click)="deleteLastTask()">Delete Last Task</button> <button (click)="commandHistory.undo()" [disabled]="!commandHistory.canUndo()">Undo</button> <button (click)="commandHistory.redo()" [disabled]="!commandHistory.canRedo()">Redo</button> <h3>Tasks:</h3> <ul *ngIf="taskService.tasks().length > 0; else noTasks"> <li *ngFor="let task of taskService.tasks()"> {{ task.title }} (ID: {{ task.id }}) </li> </ul> <ng-template #noTasks><p>No tasks yet.</p></ng-template> ` }) export class AppComponent { commandHistory = inject(CommandHistoryService); taskService = inject(TaskService); private taskCounter = 0; addTask(): void { this.taskCounter++; const newTask: Task = { id: '', // Will be assigned by service title: `Task ${this.taskCounter}`, completed: false }; const command = new AddTaskCommand(this.taskService, newTask); this.commandHistory.executeCommand(command); } deleteLastTask(): void { const tasks = this.taskService.tasks(); if (tasks.length > 0) { const lastTaskId = tasks[tasks.length - 1].id; const command = new DeleteTaskCommand(this.taskService, lastTaskId); this.commandHistory.executeCommand(command); } else { console.warn('No tasks to delete.'); } } }Use Cases:
- Undo/Redo functionality: As shown in the example, encapsulating operations allows them to be reversed.
- Macro recording: Recording a sequence of actions.
- Queueing operations: Deferring execution of tasks (e.g., in a background worker).
- GUI actions: Decoupling buttons or menu items from the specific logic they trigger, making it easy to change or add new actions.
- Transaction management: Grouping related operations that can be committed or rolled back.
Iterator
Definition: Provides a way to access the elements of an aggregate object sequentially without exposing its underlying representation.
Problem: You have a collection of objects (e.g., a custom data structure like a linked list, or a complex filtered/sorted list) and you want to traverse its elements without exposing its internal implementation details. JavaScript’s
for...ofloop and iterators cover many basic needs, but for custom traversal logic, you might implement your own.Angular Implementation: While
*ngForin templates abstracts much of the iteration, you can create custom iterable services or classes that adhere to JavaScript’s iterator protocol for more complex scenarios.// src/app/models/playlist.model.ts interface Song { title: string; artist: string; } // Implementing a custom Iterable/Iterator for a Playlist export class Playlist implements Iterable<Song> { private songs: Song[] = []; private currentSongIndex = 0; constructor(songs: Song[]) { this.songs = songs; } addSong(song: Song): void { this.songs.push(song); } // Makes this class iterable (supports for...of) [Symbol.iterator](): Iterator<Song> { let index = 0; const songs = this.songs; // Capture songs for the closure return { next(): IteratorResult<Song> { if (index < songs.length) { return { value: songs[index++], done: false }; } else { return { value: undefined, done: true }; } } }; } // Optional: Custom method to iterate in reverse *reverseIterator(): Iterator<Song> { for (let i = this.songs.length - 1; i >= 0; i--) { yield this.songs[i]; } } // Optional: Custom method for current/next song (more stateful iterator) nextSong(): Song | undefined { if (this.currentSongIndex < this.songs.length) { return this.songs[this.currentSongIndex++]; } return undefined; } reset(): void { this.currentSongIndex = 0; } } // src/app/app.component.ts import { Component, OnInit } from '@angular/core'; import { Playlist } from './models/playlist.model'; import { CommonModule } from '@angular/common'; @Component({ selector: 'app-root', standalone: true, imports: [CommonModule], template: ` <h2>Iterator Pattern Example: Custom Playlist</h2> <h3>Using for...of (default iterator)</h3> <ul> <li *ngFor="let song of playlist;"> {{ song.title }} by {{ song.artist }} </li> </ul> <h3>Using custom reverseIterator()</h3> <button (click)="listSongsReverse()">List Songs in Reverse (Console)</button> <h3>Using stateful nextSong()</h3> <button (click)="playNextSong()">Play Next Song</button> <button (click)="resetPlaylist()">Reset Playlist</button> <p>Currently playing: {{ currentPlayingSong }}</p> ` }) export class AppComponent implements OnInit { playlist!: Playlist; currentPlayingSong: string = 'None'; ngOnInit(): void { this.playlist = new Playlist([ { title: 'Song A', artist: 'Artist X' }, { title: 'Song B', artist: 'Artist Y' }, { title: 'Song C', artist: 'Artist Z' } ]); this.playlist.addSong({ title: 'Song D', artist: 'Artist A' }); } listSongsReverse(): void { console.log('--- Songs in Reverse ---'); for (const song of this.playlist.reverseIterator()) { console.log(`${song.title} by ${song.artist}`); } console.log('------------------------'); } playNextSong(): void { const next = this.playlist.nextSong(); if (next) { this.currentPlayingSong = `${next.title} by ${next.artist}`; console.log(`Playing: ${this.currentPlayingSong}`); } else { this.currentPlayingSong = 'End of playlist.'; console.log('End of playlist.'); } } resetPlaylist(): void { this.playlist.reset(); this.currentPlayingSong = 'None'; console.log('Playlist reset.'); } }Use Cases:
- Creating custom data structures (e.g., binary trees, graphs) that need specific traversal orders.
- Implementing pagination or lazy-loading data from a source where you only want to retrieve a subset of items at a time.
- When working with complex filters or transformations on a collection, and you want to abstract the iteration logic.
- Building UI components that need to present items from a collection in a non-standard sequence.
Mediator
Definition: Defines an object that encapsulates how a set of objects interact. Mediator promotes loose coupling by keeping objects from referring to each other explicitly, and it lets you vary their interaction independently.
Problem: You have many components or services that need to communicate with each other, leading to a tangled web of direct dependencies (spaghetti code). Changes in one component might require changes in many others, making the system hard to maintain and extend.
Angular Implementation: An Angular service can act as a Mediator, providing a central hub for communication between otherwise independent components. This often involves RxJS
SubjectorEventEmitterfor communication.// src/app/mediators/event.mediator.service.ts import { Injectable } from '@angular/core'; import { Subject, Observable } from 'rxjs'; // Define a type for events to improve type safety interface AppEvent { type: string; payload?: any; } @Injectable({ providedIn: 'root' }) export class EventMediatorService { private eventSubject = new Subject<AppEvent>(); // Publish an event publish(event: AppEvent): void { console.log(`Mediator: Publishing event - Type: ${event.type}, Payload:`, event.payload); this.eventSubject.next(event); } // Subscribe to all events or specific types onEvent(type?: string): Observable<AppEvent> { if (type) { return this.eventSubject.asObservable().pipe( filter(event => event.type === type) ); } return this.eventSubject.asObservable(); } } // src/app/components/user-profile-editor/user-profile-editor.component.ts import { Component, inject, OnInit } from '@angular/core'; import { EventMediatorService } from '../../mediators/event.mediator.service'; import { FormsModule } from '@angular/forms'; // For ngModel @Component({ selector: 'app-user-profile-editor', standalone: true, imports: [FormsModule], template: ` <div style="border: 1px solid blue; padding: 10px; margin: 10px;"> <h4>User Profile Editor</h4> <label> Username: <input [(ngModel)]="username" (ngModelChange)="onUsernameChange()"> </label> </div> ` }) export class UserProfileEditorComponent implements OnInit { private mediator = inject(EventMediatorService); username: string = 'Default User'; ngOnInit(): void { this.onUsernameChange(); // Initial publish } onUsernameChange(): void { this.mediator.publish({ type: 'USERNAME_CHANGED', payload: this.username }); } } // src/app/components/welcome-message/welcome-message.component.ts import { Component, inject, OnInit, OnDestroy } from '@angular/core'; import { EventMediatorService } from '../../mediators/event.mediator.service'; import { Subscription } from 'rxjs'; @Component({ selector: 'app-welcome-message', standalone: true, template: ` <div style="border: 1px solid green; padding: 10px; margin: 10px;"> <h4>Welcome Message</h4> <p>Hello, {{ displayedUsername }}!</p> </div> ` }) export class WelcomeMessageComponent implements OnInit, OnDestroy { private mediator = inject(EventMediatorService); displayedUsername: string = 'Guest'; private subscription!: Subscription; ngOnInit(): void { this.subscription = this.mediator.onEvent('USERNAME_CHANGED').subscribe(event => { this.displayedUsername = event.payload; }); } ngOnDestroy(): void { this.subscription.unsubscribe(); } } // src/app/components/activity-log/activity-log.component.ts import { Component, inject, OnInit, OnDestroy } from '@angular/core'; import { EventMediatorService } from '../../mediators/event.mediator.service'; import { Subscription } from 'rxjs'; import { CommonModule } from '@angular/common'; @Component({ selector: 'app-activity-log', standalone: true, imports: [CommonModule], template: ` <div style="border: 1px solid red; padding: 10px; margin: 10px;"> <h4>Activity Log</h4> <ul> <li *ngFor="let log of activityLogs">{{ log }}</li> </ul> </div> ` }) export class ActivityLogComponent implements OnInit, OnDestroy { private mediator = inject(EventMediatorService); activityLogs: string[] = []; private subscription!: Subscription; ngOnInit(): void { this.subscription = this.mediator.onEvent().subscribe(event => { this.activityLogs.push(`[${new Date().toLocaleTimeString()}] Event: ${event.type}, Payload: ${JSON.stringify(event.payload)}`); if (this.activityLogs.length > 5) { // Keep log brief for demo this.activityLogs.shift(); } }); } ngOnDestroy(): void { this.subscription.unsubscribe(); } } // src/app/app.component.ts import { Component } from '@angular/core'; import { UserProfileEditorComponent } from './components/user-profile-editor/user-profile-editor.component'; import { WelcomeMessageComponent } from './components/welcome-message/welcome-message.component'; import { ActivityLogComponent } from './components/activity-log/activity-log.component'; @Component({ selector: 'app-root', standalone: true, imports: [UserProfileEditorComponent, WelcomeMessageComponent, ActivityLogComponent], template: ` <h2>Mediator Pattern Example</h2> <div style="display: flex; justify-content: space-around;"> <app-user-profile-editor></app-user-profile-editor> <app-welcome-message></app-welcome-message> <app-activity-log></app-activity-log> </div> ` }) export class AppComponent {}Use Cases:
- Complex component communication: When many components need to interact but you want to avoid direct event binding chains or
@ViewChildreferences. - Workflow management: Orchestrating steps in a multi-step form or process where different components contribute to the workflow.
- State synchronization: When changes in one part of the application need to be reflected in unrelated parts without using a full-fledged state management library for simple cases.
- Decoupling: Reducing dependencies between different parts of a system, making them easier to test and maintain independently.
- Complex component communication: When many components need to interact but you want to avoid direct event binding chains or
Memento
Definition: Without violating encapsulation, capture and externalize an object’s internal state so that the object can be restored to this state later.
Problem: You need to save and restore the state of an object (e.g., a component’s form data, a drawing canvas, a game state) at various points, without exposing its internal structure.
Angular Implementation: A service can act as a “Caretaker” to store and retrieve mementos (snapshots of state) provided by “Originator” components or services. RxJS
BehaviorSubjector Signals can represent the current state.// src/app/models/text-editor.memento.ts (The Memento) export interface TextEditorMemento { content: string; cursorPosition: number; } // src/app/services/text-editor.service.ts (The Originator) import { Injectable, signal } from '@angular/core'; import { TextEditorMemento } from '../models/text-editor.memento'; @Injectable({ providedIn: 'root' }) export class TextEditorService { content = signal<string>(''); cursorPosition = signal<number>(0); type(text: string): void { this.content.update(currentContent => currentContent + text); this.cursorPosition.update(currentPos => currentPos + text.length); console.log(`Editor: Typed "${text}", Content: "${this.content()}"`); } deleteLastChar(): void { if (this.content().length > 0) { this.content.update(currentContent => currentContent.slice(0, -1)); this.cursorPosition.update(currentPos => currentPos - 1); console.log(`Editor: Deleted last char, Content: "${this.content()}"`); } } // Creates a memento (snapshot of current state) save(): TextEditorMemento { return { content: this.content(), cursorPosition: this.cursorPosition() }; } // Restores state from a memento restore(memento: TextEditorMemento): void { this.content.set(memento.content); this.cursorPosition.set(memento.cursorPosition); console.log(`Editor: Restored state to "${this.content()}" at cursor ${this.cursorPosition()}`); } } // src/app/services/editor-history.service.ts (The Caretaker) import { Injectable } from '@angular/core'; import { TextEditorMemento } from '../models/text-editor.memento'; @Injectable({ providedIn: 'root' }) export class EditorHistoryService { private mementos: TextEditorMemento[] = []; private currentStateIndex: number = -1; addMemento(memento: TextEditorMemento): void { // Clear redo history if a new state is saved if (this.currentStateIndex < this.mementos.length - 1) { this.mementos = this.mementos.slice(0, this.currentStateIndex + 1); } this.mementos.push(memento); this.currentStateIndex = this.mementos.length - 1; console.log(`History: Saved state. Total mementos: ${this.mementos.length}`); } getUndoMemento(): TextEditorMemento | null { if (this.currentStateIndex > 0) { this.currentStateIndex--; return this.mementos[this.currentStateIndex]; } console.warn('History: No previous state to undo.'); return null; } getRedoMemento(): TextEditorMemento | null { if (this.currentStateIndex < this.mementos.length - 1) { this.currentStateIndex++; return this.mementos[this.currentStateIndex]; } console.warn('History: No next state to redo.'); return null; } canUndo(): boolean { return this.currentStateIndex > 0; } canRedo(): boolean { return this.currentStateIndex < this.mementos.length - 1; } } // src/app/app.component.ts import { Component, inject } from '@angular/core'; import { TextEditorService } from './services/text-editor.service'; import { EditorHistoryService } from './services/editor-history.service'; import { FormsModule } from '@angular/forms'; // For ngModel @Component({ selector: 'app-root', standalone: true, imports: [FormsModule], template: ` <h2>Memento Pattern Example: Text Editor Undo/Redo</h2> <textarea [(ngModel)]="editorService.content" (ngModelChange)="onContentChange($event)" rows="5" cols="50" placeholder="Type something..." ></textarea> <br> <button (click)="undo()" [disabled]="!historyService.canUndo()">Undo</button> <button (click)="redo()" [disabled]="!historyService.canRedo()">Redo</button> <button (click)="saveState()">Save State</button> <p>Current Content: "{{ editorService.content() }}" | Cursor: {{ editorService.cursorPosition() }}</p> ` }) export class AppComponent { editorService = inject(TextEditorService); historyService = inject(EditorHistoryService); constructor() { this.saveState(); // Save initial empty state } onContentChange(newContent: string): void { // In a real app, you might debounce this or save on specific actions // For simplicity, let's just update and save when ngModel changes this.editorService.content.set(newContent); this.editorService.cursorPosition.set(newContent.length); // Simple cursor for demo this.saveState(); } saveState(): void { const memento = this.editorService.save(); this.historyService.addMemento(memento); } undo(): void { const memento = this.historyService.getUndoMemento(); if (memento) { this.editorService.restore(memento); } } redo(): void { const memento = this.historyService.getRedoMemento(); if (memento) { this.editorService.restore(memento); } } }Use Cases:
- Implementing undo/redo functionality in editors, drawing applications, or forms.
- Saving and restoring game states.
- Providing snapshots of a component’s complex UI state (e.g., filter criteria, table configurations).
- Building a “wizard” or multi-step form where users can go back and forth between steps, retaining intermediate states.
Observer
Definition: Defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically.
Problem: You have a “subject” object whose state changes, and multiple “observer” objects that need to react to these changes. Directly notifying each observer leads to tight coupling.
Angular Implementation: This is the most crucial behavioral pattern in Angular, primarily implemented through RxJS Observables and Subjects and now Angular Signals.
Example 1: RxJS Observables (Classic Observer)
// src/app/services/data-stream.service.ts (The Subject) import { Injectable } from '@angular/core'; import { Subject, Observable } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class DataStreamService { private _data = new Subject<string>(); // Observable to which observers subscribe data$ = this._data.asObservable(); publishData(message: string): void { console.log(`DataStreamService: Publishing "${message}"`); this._data.next(message); } } // src/app/components/data-publisher/data-publisher.component.ts import { Component, inject } from '@angular/core'; import { DataStreamService } from '../../services/data-stream.service'; import { FormsModule } from '@angular/forms'; @Component({ selector: 'app-data-publisher', standalone: true, imports: [FormsModule], template: ` <div style="border: 1px solid purple; padding: 10px; margin: 10px;"> <h4>Data Publisher</h4> <input [(ngModel)]="message" placeholder="Type data"> <button (click)="publish()">Publish Data</button> </div> ` }) export class DataPublisherComponent { private dataStream = inject(DataStreamService); message: string = ''; publish(): void { this.dataStream.publish(this.message); this.message = ''; } } // src/app/components/data-consumer/data-consumer.component.ts import { Component, inject, OnInit, OnDestroy } from '@angular/core'; import { DataStreamService } from '../../services/data-stream.service'; import { Subscription } from 'rxjs'; import { CommonModule } from '@angular/common'; @Component({ selector: 'app-data-consumer', standalone: true, imports: [CommonModule], template: ` <div style="border: 1px solid orange; padding: 10px; margin: 10px;"> <h4>Data Consumer 1</h4> <p>Received Data: {{ latestData }}</p> <p>All Data:</p> <ul> <li *ngFor="let data of allData">{{ data }}</li> </ul> </div> ` }) export class DataConsumerComponent implements OnInit, OnDestroy { private dataStream = inject(DataStreamService); latestData: string = 'No data yet'; allData: string[] = []; private subscription!: Subscription; ngOnInit(): void { // Subscribe to the observable this.subscription = this.dataStream.data$.subscribe(data => { this.latestData = data; this.allData.push(data); }); } ngOnDestroy(): void { this.subscription.unsubscribe(); // Important to prevent memory leaks } } // src/app/components/data-consumer-two/data-consumer-two.component.ts import { Component, inject } from '@angular/core'; import { DataStreamService } from '../../services/data-stream.service'; import { AsyncPipe } from '@angular/common'; // Use AsyncPipe to automatically manage subscriptions @Component({ selector: 'app-data-consumer-two', standalone: true, imports: [AsyncPipe], template: ` <div style="border: 1px solid teal; padding: 10px; margin: 10px;"> <h4>Data Consumer 2 (with AsyncPipe)</h4> <p>Received Data: {{ (dataStream.data$ | async) || 'No data yet' }}</p> </div> ` }) export class DataConsumerTwoComponent { dataStream = inject(DataStreamService); } // src/app/app.component.ts import { Component } from '@angular/core'; import { DataPublisherComponent } from './components/data-publisher/data-publisher.component'; import { DataConsumerComponent } from './components/data-consumer/data-consumer.component'; import { DataConsumerTwoComponent } from './components/data-consumer-two/data-consumer-two.component'; @Component({ selector: 'app-root', standalone: true, imports: [DataPublisherComponent, DataConsumerComponent, DataConsumerTwoComponent], template: ` <h2>Observer Pattern Example: RxJS (with Subjects)</h2> <div style="display: flex; justify-content: space-around;"> <app-data-publisher></app-data-publisher> <app-data-consumer></app-data-consumer> <app-data-consumer-two></app-data-consumer-two> </div> ` }) export class AppComponent {}Example 2: Angular Signals (Modern Angular Reactivity) Angular v20 stabilizes Signals, offering a new, more explicit way to handle reactivity that inherently uses the Observer pattern.
// src/app/services/counter.service.ts (The Subject/Observable equivalent) import { Injectable, signal, computed, effect } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class CounterService { private _count = signal(0); readonly count = computed(() => this._count()); // Expose as read-only computed signal increment(): void { this._count.update(value => value + 1); } decrement(): void { this._count.update(value => value - 1); } setCount(value: number): void { this._count.set(value); } constructor() { // An 'effect' acts like an observer for debugging/side effects effect(() => { console.log(`CounterService: Current count is ${this.count()}`); }); } } // src/app/components/counter-display/counter-display.component.ts import { Component, inject } from '@angular/core'; import { CounterService } from '../../services/counter.service'; @Component({ selector: 'app-counter-display', standalone: true, template: ` <div style="border: 1px solid gray; padding: 10px; margin: 10px;"> <h4>Counter Display</h4> <p>Current Count: {{ counterService.count() }}</p> </div> ` }) export class CounterDisplayComponent { counterService = inject(CounterService); } // src/app/components/counter-controls/counter-controls.component.ts import { Component, inject } from '@angular/core'; import { CounterService } from '../../services/counter.service'; @Component({ selector: 'app-counter-controls', standalone: true, template: ` <div style="border: 1px solid blue; padding: 10px; margin: 10px;"> <h4>Counter Controls</h4> <button (click)="counterService.increment()">Increment</button> <button (click)="counterService.decrement()">Decrement</button> <button (click)="counterService.setCount(0)">Reset</button> </div> ` }) export class CounterControlsComponent { counterService = inject(CounterService); } // src/app/app.component.ts import { Component } from '@angular/core'; import { CounterDisplayComponent } from './components/counter-display/counter-display.component'; import { CounterControlsComponent } from './components/counter-controls/counter-controls.component'; @Component({ selector: 'app-root', standalone: true, imports: [CounterDisplayComponent, CounterControlsComponent], template: ` <h2>Observer Pattern Example: Angular Signals</h2> <div style="display: flex; justify-content: space-around;"> <app-counter-controls></app-counter-controls> <app-counter-display></app-counter-display> </div> ` }) export class AppComponent {}Use Cases:
- Asynchronous operations: Handling HTTP requests, user events (clicks, input), and animations.
- State management: Notifying components about changes in application state (e.g., user login status, shopping cart updates).
- Real-time data: Subscribing to WebSocket streams for live updates.
- Cross-component communication: Decoupling components by allowing them to react to events published by a shared service.
State
Definition: Allows an object to alter its behavior when its internal state changes. The object will appear to change its class.
Problem: An object’s behavior depends on its current state, and its behavior needs to change dynamically based on that state. Using many conditional (
if/elseorswitch) statements to manage state transitions becomes complex and hard to maintain as the number of states and transitions grows.Angular Implementation: This can be applied to manage component behavior (e.g., a “Wizard” component), or a service that handles the state of a complex process. Often, this involves defining an interface for states and concrete state classes. Libraries like XState or NgRx/component-store can formalize this with state machines.
// src/app/states/document-state.interface.ts import { DocumentService } from '../services/document.service'; // Forward declaration export interface DocumentState { edit(): void; save(): void; publish(): void; archive(): void; getStatus(): string; } // src/app/states/draft-state.ts import { DocumentState } from './document-state.interface'; import { DocumentService } from '../services/document.service'; export class DraftState implements DocumentState { constructor(private context: DocumentService) {} edit(): void { console.log('Already in Draft state, continuing editing.'); } save(): void { console.log('Saving draft...'); } publish(): void { console.log('Publishing document from Draft...'); this.context.changeState('published'); } archive(): void { console.log('Cannot archive from Draft state.'); } getStatus(): string { return 'Draft'; } } // src/app/states/published-state.ts import { DocumentState } from './document-state.interface'; import { DocumentService } from '../services/document.service'; export class PublishedState implements DocumentState { constructor(private context: DocumentService) {} edit(): void { console.log('Document is Published. Reverting to Draft for editing...'); this.context.changeState('draft'); } save(): void { console.log('Cannot save a Published document directly. Edit first.'); } publish(): void { console.log('Already Published.'); } archive(): void { console.log('Archiving document from Published...'); this.context.changeState('archived'); } getStatus(): string { return 'Published'; } } // src/app/states/archived-state.ts import { DocumentState } from './document-state.interface'; import { DocumentService } from '../services/document.service'; export class ArchivedState implements DocumentState { constructor(private context: DocumentService) {} edit(): void { console.log('Cannot edit from Archived state. Restore first.'); } save(): void { console.log('Cannot save from Archived state.'); } publish(): void { console.log('Cannot publish from Archived state.'); } archive(): void { console.log('Already Archived.'); } getStatus(): string { return 'Archived'; } } // src/app/services/document.service.ts (The Context) import { Injectable, signal } from '@angular/core'; import { DocumentState } from '../states/document-state.interface'; import { DraftState } from '../states/draft-state'; import { PublishedState } from '../states/published-state'; import { ArchivedState } from '../states/archived-state'; type StateType = 'draft' | 'published' | 'archived'; @Injectable({ providedIn: 'root' }) export class DocumentService { private currentState!: DocumentState; status = signal<string>(''); // For displaying current status private states: { [key in StateType]: DocumentState } = { draft: new DraftState(this), published: new PublishedState(this), archived: new ArchivedState(this), }; constructor() { this.changeState('draft'); // Initial state } changeState(newState: StateType): void { this.currentState = this.states[newState]; this.status.set(this.currentState.getStatus()); console.log(`Document state changed to: ${this.currentState.getStatus()}`); } // Delegate actions to the current state object edit(): void { this.currentState.edit(); } save(): void { this.currentState.save(); } publish(): void { this.currentState.publish(); } archive(): void { this.currentState.archive(); } } // src/app/app.component.ts import { Component, inject } from '@angular/core'; import { DocumentService } from './services/document.service'; import { CommonModule } from '@angular/common'; @Component({ selector: 'app-root', standalone: true, imports: [CommonModule], template: ` <h2>State Pattern Example: Document Workflow</h2> <p>Current Document Status: <strong>{{ documentService.status() }}</strong></p> <button (click)="documentService.edit()">Edit</button> <button (click)="documentService.save()">Save</button> <button (click)="documentService.publish()">Publish</button> <button (click)="documentService.archive()">Archive</button> ` }) export class AppComponent { documentService = inject(DocumentService); }Use Cases:
- Workflow management: Handling the lifecycle of entities like orders (pending, shipped, delivered), documents (draft, published, archived), or user states (logged out, logged in, awaiting verification).
- UI component behavior: A form that changes its validation rules or available actions based on its completion state.
- Game development: Managing character states (idle, running, attacking).
- Any scenario where an object’s behavior varies significantly based on its internal state, and you want to avoid large conditional logic blocks.
Strategy
Definition: Defines a family of algorithms, encapsulates each one, and makes them interchangeable. Strategy lets the algorithm vary independently from clients that use it.
Problem: You have different algorithms for performing a task (e.g., sorting, validating, calculating prices), and you want to switch between them dynamically at runtime without modifying the client code that uses the algorithms.
Angular Implementation: This typically involves defining an interface for the strategy and creating multiple services that implement this interface. Dependency Injection can then be used to provide the desired strategy at runtime. Angular Pipes, as mentioned earlier, are an implicit use of Strategy for data transformation.
// src/app/strategies/sort-strategy.interface.ts export interface SortStrategy<T> { sort(data: T[], key?: keyof T): T[]; getName(): string; } // src/app/strategies/alphabetical-sort.strategy.ts import { SortStrategy } from './sort-strategy.interface'; import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class AlphabeticalSortStrategy<T> implements SortStrategy<T> { sort(data: T[], key?: keyof T): T[] { console.log('Sorting alphabetically...'); const sorted = [...data].sort((a, b) => { const valA = key ? a[key] : a; const valB = key ? b[key] : b; if (typeof valA === 'string' && typeof valB === 'string') { return valA.localeCompare(valB); } return 0; // Fallback for non-string }); return sorted; } getName(): string { return 'Alphabetical'; } } // src/app/strategies/reverse-alphabetical-sort.strategy.ts import { SortStrategy } from './sort-strategy.interface'; import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class ReverseAlphabeticalSortStrategy<T> implements SortStrategy<T> { sort(data: T[], key?: keyof T): T[] { console.log('Sorting reverse alphabetically...'); const sorted = [...data].sort((a, b) => { const valA = key ? a[key] : a; const valB = key ? b[key] : b; if (typeof valA === 'string' && typeof valB === 'string') { return valB.localeCompare(valA); } return 0; // Fallback for non-string }); return sorted; } getName(): string { return 'Reverse Alphabetical'; } } // src/app/services/data-sorter.service.ts (The Context) import { Injectable, InjectionToken, inject } from '@angular/core'; import { SortStrategy } from '../strategies/sort-strategy.interface'; import { AlphabeticalSortStrategy } from '../strategies/alphabetical-sort.strategy'; import { ReverseAlphabeticalSortStrategy } from '../strategies/reverse-alphabetical-sort.strategy'; // Define injection tokens for strategies to allow dynamic provision export const ALPHA_SORT_STRATEGY = new InjectionToken<SortStrategy<any>>('ALPHA_SORT_STRATEGY'); export const REVERSE_ALPHA_SORT_STRATEGY = new InjectionToken<SortStrategy<any>>('REVERSE_ALPHA_SORT_STRATEGY'); @Injectable({ providedIn: 'root' }) export class DataSorterService { private currentStrategy!: SortStrategy<any>; private alphabeticalStrategy = inject(AlphabeticalSortStrategy); private reverseAlphabeticalStrategy = inject(ReverseAlphabeticalSortStrategy); constructor() { this.setStrategy('alphabetical'); // Default strategy } setStrategy(type: 'alphabetical' | 'reverseAlphabetical'): void { switch (type) { case 'alphabetical': this.currentStrategy = this.alphabeticalStrategy; break; case 'reverseAlphabetical': this.currentStrategy = this.reverseAlphabeticalStrategy; break; default: throw new Error('Unknown sort strategy'); } console.log(`Sorter: Strategy set to ${this.currentStrategy.getName()}`); } sortData<T>(data: T[], key?: keyof T): T[] { return this.currentStrategy.sort(data, key); } } // src/app/app.config.ts (If you want to use Injection Tokens for providers directly) /* import { ApplicationConfig } from '@angular/core'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; import { ALPHA_SORT_STRATEGY, REVERSE_ALPHA_SORT_STRATEGY } from './services/data-sorter.service'; import { AlphabeticalSortStrategy } from './strategies/alphabetical-sort.strategy'; import { ReverseAlphabeticalSortStrategy } from './strategies/reverse-alphabetical-sort.strategy'; export const appConfig: ApplicationConfig = { providers: [ provideRouter(routes), { provide: ALPHA_SORT_STRATEGY, useClass: AlphabeticalSortStrategy }, { provide: REVERSE_ALPHA_SORT_STRATEGY, useClass: ReverseAlphabeticalSortStrategy }, // ... and then DataSorterService could inject ALPHA_SORT_STRATEGY etc. ] }; */ // src/app/app.component.ts import { Component, inject, signal } from '@angular/core'; import { DataSorterService } from './services/data-sorter.service'; import { CommonModule } from '@angular/common'; interface Item { id: number; name: string; } @Component({ selector: 'app-root', standalone: true, imports: [CommonModule], template: ` <h2>Strategy Pattern Example: Data Sorting</h2> <p>Current Strategy: {{ dataSorter.currentStrategy?.getName() }}</p> <button (click)="setStrategy('alphabetical')">Sort Alphabetically</button> <button (click)="setStrategy('reverseAlphabetical')">Sort Reverse Alphabetically</button> <h3>Original Items:</h3> <ul> <li *ngFor="let item of originalItems()">{{ item.name }}</li> </ul> <h3>Sorted Items:</h3> <ul> <li *ngFor="let item of sortedItems()">{{ item.name }}</li> </ul> ` }) export class AppComponent { dataSorter = inject(DataSorterService); originalItems = signal<Item[]>([ { id: 1, name: 'Banana' }, { id: 2, name: 'Apple' }, { id: 3, name: 'Cherry' }, { id: 4, name: 'Date' } ]); sortedItems = signal<Item[]>([]); constructor() { this.updateSortedItems(); } setStrategy(type: 'alphabetical' | 'reverseAlphabetical'): void { this.dataSorter.setStrategy(type); this.updateSortedItems(); } private updateSortedItems(): void { this.sortedItems.set(this.dataSorter.sortData(this.originalItems(), 'name')); } }Use Cases:
- Different validation rules: Applying different sets of validation rules to a form based on user input or context.
- Sorting algorithms: Allowing users to choose between various sorting methods (e.g., by name, by date, by price) for a list or table.
- Payment processing: Integrating different payment gateways (PayPal, Stripe) under a common interface.
- Data export/import formats: Supporting different file formats (CSV, JSON, XML) for data operations.
Template Method
Definition: Defines the skeleton of an algorithm in an operation, deferring some steps to subclasses. Template Method lets subclasses redefine certain steps of an algorithm without changing the algorithm’s structure.
Problem: You have an algorithm with several steps, where some steps are common across variations, but others need to be implemented differently by specific implementations (components or services). You want to ensure the overall flow remains consistent while allowing flexibility for specific steps.
Angular Implementation: This can be achieved using abstract classes for services or components, where the abstract class defines the “template” method and abstract methods for the steps that need to be implemented by concrete classes.
// src/app/services/report-generator.abstract.ts (Abstract Template) import { Injectable } from '@angular/core'; @Injectable() // Mark as injectable but don't provide in root/any as it's abstract export abstract class AbstractReportGenerator { // The template method - defines the algorithm skeleton generateReport(): void { this.collectData(); this.processData(); this.formatOutput(); this.sendReport(); console.log(`Report generation completed for: ${this.getReportType()}`); } // Abstract methods to be implemented by concrete subclasses protected abstract collectData(): void; protected abstract processData(): void; protected abstract formatOutput(): void; protected abstract getReportType(): string; // For identification // Hook methods (optional, with default implementation) protected sendReport(): void { console.log('Default: Sending report via email.'); } } // src/app/services/sales-report.service.ts (Concrete Implementation 1) import { Injectable } from '@angular/core'; import { AbstractReportGenerator } from './report-generator.abstract'; @Injectable({ providedIn: 'root' }) export class SalesReportGenerator extends AbstractReportGenerator { protected collectData(): void { console.log('Collecting sales data...'); } protected processData(): void { console.log('Calculating sales aggregates...'); } protected formatOutput(): void { console.log('Formatting sales report as PDF.'); } protected getReportType(): string { return 'Sales Report'; } // Can override sendReport() if needed } // src/app/services/financial-report.service.ts (Concrete Implementation 2) import { Injectable } from '@angular/core'; import { AbstractReportGenerator } from './report-generator.abstract'; @Injectable({ providedIn: 'root' }) export class FinancialReportGenerator extends AbstractReportGenerator { protected collectData(): void { console.log('Collecting financial ledger data...'); } protected processData(): void { console.log('Running balance sheet calculations...'); } protected formatOutput(): void { console.log('Formatting financial report as Excel.'); } protected getReportType(): string { return 'Financial Report'; } protected override sendReport(): void { console.log('Sending financial report securely to finance department.'); } } // src/app/app.component.ts import { Component, inject } from '@angular/core'; import { SalesReportGenerator } from './services/sales-report.service'; import { FinancialReportGenerator } from './services/financial-report.service'; @Component({ selector: 'app-root', standalone: true, template: ` <h2>Template Method Example: Report Generation</h2> <button (click)="generateSalesReport()">Generate Sales Report</button> <button (click)="generateFinancialReport()">Generate Financial Report</button> ` }) export class AppComponent { salesReportGenerator = inject(SalesReportGenerator); financialReportGenerator = inject(FinancialReportGenerator); generateSalesReport(): void { this.salesReportGenerator.generateReport(); } generateFinancialReport(): void { this.financialReportGenerator.generateReport(); } }Use Cases:
- Data processing pipelines: ETL (Extract, Transform, Load) processes where the overall flow is fixed, but extraction, transformation, or loading steps vary for different data sources/destinations.
- Report generation: As shown, where the structure of the report is defined, but data collection and formatting differ.
- Algorithm variations: Implementing different variations of an algorithm (e.g., different build processes, different test execution flows) while keeping the core steps consistent.
- Wizard/Multi-step forms: Defining a common navigation and submission flow, but each step’s content and validation logic are implemented by individual components.
Visitor
Definition: Represents an operation to be performed on the elements of an object structure. Visitor lets you define a new operation without changing the classes of the elements on which it operates.
Problem: You have a complex object structure (e.g., a tree of UI elements, a syntax tree, a data model) and you need to perform new operations on its elements frequently. Adding new operations directly to each element class would require modifying many classes, violating the Open/Closed Principle.
Angular Implementation: While less common in typical Angular component logic, it can be useful for processing complex, hierarchical data structures or when building tools that inspect or transform component trees (e.g., for analytics, accessibility audits, or dynamic rendering).
// src/app/visitors/html-element-visitor.interface.ts export interface HTMLElementVisitor { visitDiv(element: DivElement): void; visitParagraph(element: ParagraphElement): void; visitButton(element: ButtonElement): void; } // src/app/elements/html-element.abstract.ts import { HTMLElementVisitor } from '../visitors/html-element-visitor.interface'; export abstract class HTMLElement { constructor(public id: string) {} abstract accept(visitor: HTMLElementVisitor): void; } // src/app/elements/div.element.ts import { HTMLElement } from './html-element.abstract'; import { HTMLElementVisitor } from '../visitors/html-element-visitor.interface'; export class DivElement extends HTMLElement { constructor(id: string, public children: HTMLElement[] = []) { super(id); } accept(visitor: HTMLElementVisitor): void { visitor.visitDiv(this); } } // src/app/elements/paragraph.element.ts import { HTMLElement } from './html-element.abstract'; import { HTMLElementVisitor } from '../visitors/html-element-visitor.interface'; export class ParagraphElement extends HTMLElement { constructor(id: string, public text: string) { super(id); } accept(visitor: HTMLElementVisitor): void { visitor.visitParagraph(this); } } // src/app/elements/button.element.ts import { HTMLElement } from './html-element.abstract'; import { HTMLElementVisitor } from '../visitors/html-element-visitor.interface'; export class ButtonElement extends HTMLElement { constructor(id: string, public label: string) { super(id); } accept(visitor: HTMLElementVisitor): void { visitor.visitButton(this); } } // src/app/visitors/html-renderer.visitor.ts (Concrete Visitor 1) import { Injectable } from '@angular/core'; import { HTMLElementVisitor } from './html-element-visitor.interface'; import { DivElement, ParagraphElement, ButtonElement } from '../elements/html-element.abstract'; @Injectable({ providedIn: 'root' }) export class HtmlRendererVisitor implements HTMLElementVisitor { private htmlOutput: string[] = []; render(elements: HTMLElement[]): string { this.htmlOutput = []; elements.forEach(element => element.accept(this)); return this.htmlOutput.join('\n'); } visitDiv(element: DivElement): void { this.htmlOutput.push(`<div id="${element.id}">`); element.children.forEach(child => child.accept(this)); this.htmlOutput.push(`</div>`); } visitParagraph(element: ParagraphElement): void { this.htmlOutput.push(`<p id="${element.id}">${element.text}</p>`); } visitButton(element: ButtonElement): void { this.htmlOutput.push(`<button id="${element.id}">${element.label}</button>`); } } // src/app/visitors/html-validator.visitor.ts (Concrete Visitor 2) import { Injectable } from '@angular/core'; import { HTMLElementVisitor } from './html-element-visitor.interface'; import { DivElement, ParagraphElement, ButtonElement } from '../elements/html-element.abstract'; @Injectable({ providedIn: 'root' }) export class HtmlValidatorVisitor implements HTMLElementVisitor { private errors: string[] = []; validate(elements: HTMLElement[]): string[] { this.errors = []; elements.forEach(element => element.accept(this)); return this.errors; } visitDiv(element: DivElement): void { if (!element.id) this.errors.push('DivElement missing ID.'); element.children.forEach(child => child.accept(this)); // Validate children too } visitParagraph(element: ParagraphElement): void { if (!element.id) this.errors.push('ParagraphElement missing ID.'); if (!element.text || element.text.trim() === '') this.errors.push(`ParagraphElement ${element.id} has empty text.`); } visitButton(element: ButtonElement): void { if (!element.id) this.errors.push('ButtonElement missing ID.'); if (!element.label || element.label.trim() === '') this.errors.push(`ButtonElement ${element.id} has empty label.`); } } // src/app/app.component.ts import { Component, inject, OnInit } from '@angular/core'; import { DivElement, ParagraphElement, ButtonElement, HTMLElement } from './elements/html-element.abstract'; import { HtmlRendererVisitor } from './visitors/html-renderer.visitor'; import { HtmlValidatorVisitor } from './visitors/html-validator.visitor'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; // For rendering dynamic HTML import { CommonModule } from '@angular/common'; @Component({ selector: 'app-root', standalone: true, imports: [CommonModule], template: ` <h2>Visitor Pattern Example: HTML Element Operations</h2> <button (click)="renderHtml()">Render HTML</button> <button (click)="validateHtml()">Validate HTML</button> <h3>Rendered Output:</h3> <div style="border: 1px dashed #ccc; padding: 10px;" [innerHTML]="renderedHtml"></div> <h3>Validation Results:</h3> <ul *ngIf="validationErrors.length > 0"> <li *ngFor="let error of validationErrors" style="color: red;">{{ error }}</li> </ul> <p *ngIf="validationErrors.length === 0" style="color: green;">No validation errors found!</p> ` }) export class AppComponent implements OnInit { private renderer = inject(HtmlRendererVisitor); private validator = inject(HtmlValidatorVisitor); private sanitizer = inject(DomSanitizer); htmlStructure!: HTMLElement[]; renderedHtml: SafeHtml = ''; validationErrors: string[] = []; ngOnInit(): void { // Define a complex object structure (e.g., abstract syntax tree for HTML) this.htmlStructure = [ new DivElement('container-1', [ new ParagraphElement('para-1', 'This is a paragraph.'), new ButtonElement('btn-1', 'Click Me'), ]), new DivElement('container-2', [ new ParagraphElement('para-2', 'Another paragraph.'), new ButtonElement('btn-2', 'Submit'), new ParagraphElement('para-3', ''), // Intentionally empty for validation test new ButtonElement('', 'Invalid Button') // Intentionally missing ID ]) ]; } renderHtml(): void { const rawHtml = this.renderer.render(this.htmlStructure); this.renderedHtml = this.sanitizer.bypassSecurityTrustHtml(rawHtml); this.validationErrors = []; // Clear previous validation } validateHtml(): void { this.validationErrors = this.validator.validate(this.htmlStructure); this.renderedHtml = ''; // Clear previous render } }Use Cases:
- Tooling for UI trees: Building linters, accessibility checkers, or performance analyzers that traverse and inspect a component tree or a virtual DOM.
- Serialization/Deserialization: Converting complex object structures to different formats (e.g., JSON, XML) without tightly coupling the elements to the format.
- Data migration/transformation: Applying different transformation rules to hierarchical data models.
- Reporting: Generating various reports from a complex data structure by visiting elements and extracting relevant information.
6. Architectural Patterns & Beyond (Angular Specific)
Angular’s design naturally encourages certain architectural patterns, and its ecosystem provides powerful tools that embody these patterns.
Container/Presentational Components (Smart/Dumb components)
- Description: This pattern separates components into two categories:
- Presentational (Dumb) Components: Focused solely on how things look. They receive data via
@Input()properties, emit user interactions via@Output()events, and have no direct knowledge of data services or application state. They are typically stateless and reusable. - Container (Smart) Components: Focused on how things work. They manage state, fetch data from services, handle business logic, and pass data down to presentational components. They often contain the application’s logic and orchestrate presentational components.
- Presentational (Dumb) Components: Focused solely on how things look. They receive data via
- Benefits: Improved reusability of presentational components, clearer separation of concerns, easier testing (presentational components can be tested in isolation with mock inputs), and better performance (onPush change detection is more effective with presentational components).
- Angular Alignment: This pattern aligns perfectly with Angular’s component architecture. Container components often
injectservices, while presentational components are pure UI. - Example:
// src/app/models/product.model.ts export interface Product { id: string; name: string; price: number; } // src/app/services/product.service.ts import { Injectable, signal, computed } from '@angular/core'; import { Product } from '../models/product.model'; import { Observable, of, delay } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class ProductService { private _products = signal<Product[]>([ { id: '1', name: 'Laptop', price: 1200 }, { id: '2', name: 'Mouse', price: 25 }, { id: '3', name: 'Keyboard', price: 75 }, ]); products = computed(() => this._products()); // Simulate API call getProducts(): Observable<Product[]> { console.log('Fetching products...'); return of(this._products()).pipe(delay(500)); } addProduct(product: Product): void { this._products.update(currentProducts => [...currentProducts, product]); } } // src/app/components/presentational/product-card/product-card.component.ts import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Product } from '../../../models/product.model'; @Component({ selector: 'app-product-card', standalone: true, imports: [CommonModule], template: ` <div class="product-card"> <h3>{{ product.name }}</h3> <p>Price: \${{ product.price }}</p> <button (click)="addToCart.emit(product)">Add to Cart</button> </div> `, styles: [` .product-card { border: 1px solid #eee; padding: 15px; margin: 10px; border-radius: 8px; box-shadow: 2px 2px 8px rgba(0,0,0,0.1); text-align: center; } `], changeDetection: ChangeDetectionStrategy.OnPush // Optimized for presentational }) export class ProductCardComponent { @Input() product!: Product; @Output() addToCart = new EventEmitter<Product>(); // No service injections, no direct data manipulation here. // Only displays data and emits events. } // src/app/components/container/product-list-container/product-list-container.component.ts import { Component, OnInit, inject } from '@angular/core'; import { ProductService } from '../../../services/product.service'; import { ProductCardComponent } from '../../presentational/product-card/product-card.component'; import { Product } from '../../../models/product.model'; import { CommonModule } from '@angular/common'; @Component({ selector: 'app-product-list-container', standalone: true, imports: [CommonModule, ProductCardComponent], template: ` <h2>Our Products</h2> <div class="product-grid"> <app-product-card *ngFor="let product of products" [product]="product" (addToCart)="handleAddToCart($event)" ></app-product-card> </div> <p>Items in cart: {{ cartItems.length }}</p> <button (click)="logCart()">Log Cart</button> `, styles: [` .product-grid { display: flex; flex-wrap: wrap; justify-content: center; } `] }) export class ProductListContainerComponent implements OnInit { private productService = inject(ProductService); products: Product[] = []; cartItems: Product[] = []; ngOnInit(): void { this.productService.getProducts().subscribe(products => { this.products = products; }); } handleAddToCart(product: Product): void { this.cartItems.push(product); console.log(`Added ${product.name} to cart.`); } logCart(): void { console.log('Current cart:', this.cartItems); } } // src/app/app.component.ts import { Component } from '@angular/core'; import { ProductListContainerComponent } from './components/container/product-list-container/product-list-container.component'; @Component({ selector: 'app-root', standalone: true, imports: [ProductListContainerComponent], template: ` <app-product-list-container></app-product-list-container> ` }) export class AppComponent {}
Redux/NgRx (State Management)
- Description: Redux is a predictable state container for JavaScript apps, widely adopted in the Angular ecosystem through NgRx (or other libraries like NGXS, Akita, Elf). It enforces a unidirectional data flow and centralizes application state, using concepts from several design patterns:
- Command: Actions are essentially commands that describe what happened.
- Observer: The store is an observable that components subscribe to for state changes.
- State: The store holds the application’s entire state.
- Facade: Selectors provide a simplified way to access state, and action creators provide a simplified way to dispatch actions, acting as facades over the complex store operations.
- Benefits: Centralized and predictable state management, easier debugging (time-travel debugging), clear separation of concerns, and enhanced testability.
- Angular Alignment: NgRx integrates seamlessly with Angular’s reactive nature and RxJS. It’s often used for large-scale applications where consistent state management is critical.
MVVM (Model-View-ViewModel)
- Description: A UI architectural pattern that separates the UI (View) from the business logic and data (Model) using a ViewModel.
- Model: Represents the data and business logic.
- View: The UI (HTML template in Angular).
- ViewModel: An abstraction of the View that contains its presentation logic and state. It exposes data from the Model in a way that is consumable by the View, and translates View interactions back into Model operations.
- Angular Alignment: Angular naturally aligns with MVVM:
- View: The Angular template (
.htmlfile). - ViewModel: The Angular Component’s class (
.tsfile). It holds the component’s state, methods to manipulate that state, and properties bound to the template. - Model: Angular Services often act as the Model layer, encapsulating business logic, data fetching, and state.
- View: The Angular template (
- Example (Conceptual):
// Model (Service) // src/app/services/user-profile.service.ts import { Injectable, signal } from '@angular/core'; import { Observable, of, delay } from 'rxjs'; export interface UserData { id: string; firstName: string; lastName: string; email: string; } @Injectable({ providedIn: 'root' }) export class UserProfileService { private _userProfile = signal<UserData>({ id: '1', firstName: 'Jane', lastName: 'Doe', email: 'jane.doe@example.com' }); userProfile = this._userProfile.asReadonly(); // Simulate API update updateUserProfile(updatedUser: UserData): Observable<boolean> { return of(true).pipe(delay(500), tap(() => { this._userProfile.set(updatedUser); console.log('User profile updated in service:', updatedUser); })); } } // ViewModel (Component) // src/app/components/user-profile/user-profile.component.ts import { Component, OnInit, inject } from '@angular/core'; import { UserProfileService, UserData } from '../../services/user-profile.service'; import { FormsModule } from '@angular/forms'; import { CommonModule } from '@angular/common'; @Component({ selector: 'app-user-profile', standalone: true, imports: [CommonModule, FormsModule], template: ` <h2>User Profile (MVVM Example)</h2> <div *ngIf="editMode; else viewMode"> <label>First Name: <input [(ngModel)]="localUser.firstName"></label><br> <label>Last Name: <input [(ngModel)]="localUser.lastName"></label><br> <label>Email: <input [(ngModel)]="localUser.email"></label><br> <button (click)="saveProfile()">Save</button> <button (click)="cancelEdit()">Cancel</button> </div> <ng-template #viewMode> <p><strong>Name:</strong> {{ userProfileService.userProfile().firstName }} {{ userProfileService.userProfile().lastName }}</p> <p><strong>Email:</strong> {{ userProfileService.userProfile().email }}</p> <button (click)="startEdit()">Edit Profile</button> </ng-template> ` }) export class UserProfileComponent implements OnInit { private userProfileService = inject(UserProfileService); editMode = false; localUser!: UserData; // Local copy for editing ngOnInit(): void { this.resetLocalUser(); } startEdit(): void { this.resetLocalUser(); // Always start with fresh data from service this.editMode = true; } saveProfile(): void { this.userProfileService.updateUserProfile(this.localUser).subscribe(() => { this.editMode = false; }); } cancelEdit(): void { this.resetLocalUser(); this.editMode = false; } private resetLocalUser(): void { this.localUser = { ...this.userProfileService.userProfile() }; // Clone for local modifications } } // View (HTML - Implicit in component template) // The template directly binds to properties and methods exposed by the component (ViewModel).
Service Locator
- Description: Provides a global point of access to services without coupling users to the concrete classes of the services. It’s often considered an anti-pattern when used excessively, as it hides dependencies and makes testing harder.
- Angular Alignment: Angular’s Dependency Injection system effectively replaces the need for a Service Locator in most cases. When you
injecta service, Angular’s DI container is acting as the Service Locator, but in a managed, testable, and compile-time checked way. Theinject()function available in Angular v16+ (and stable in v20) offers a more explicit way to interact with the injector. - Example (
inject()function):This demonstrates how// src/app/services/logger.service.ts import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class LoggerService { log(message: string): void { console.log(`[Logger]: ${message}`); } } // src/app/services/analytics.service.ts import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class AnalyticsService { trackEvent(eventName: string, properties?: any): void { console.log(`[Analytics] Tracking: ${eventName}`, properties); } } // src/app/app.component.ts (or any component/service/directive) import { Component, inject } from '@angular/core'; import { LoggerService } from './services/logger.service'; import { AnalyticsService } from './services/analytics.service'; @Component({ selector: 'app-root', standalone: true, template: ` <h2>Service Locator (Implicit via Angular DI)</h2> <button (click)="performAction()">Perform Action</button> ` }) export class AppComponent { // Using the inject() function to "locate" services private logger = inject(LoggerService); private analytics = inject(AnalyticsService); performAction(): void { this.logger.log('User performed an action.'); this.analytics.trackEvent('button_clicked', { button: 'Perform Action' }); } }inject()acts as a localized “service locator” that pulls from the nearest injector, which is exactly how Angular’s DI is intended to be used. Explicit Service Locators (e.g., creating anInjectorand manually callinginjector.get()) should be avoided unless absolutely necessary for specific advanced scenarios like creating components dynamically outside the component tree.
Feature Modules (vs. Standalone Components)
Description: Historically,
NgModules were Angular’s primary mechanism for grouping related components, services, and routes into cohesive, lazy-loadable units. They provided a clear structure for large applications.Angular v20 Context: While
NgModules still exist, Angular v20, building on v15+, strongly promotes Standalone Components, Directives, and Pipes. This new paradigm shifts the primary unit of organization from theNgModuleto the individual component.Benefits of Standalone: Simplified project structure, reduced boilerplate, better tree-shaking (smaller bundles), and a gentler learning curve. The concept of “feature modules” still applies, but now it refers more to a logical grouping of standalone components within a folder structure, often backed by lazy-loaded routes using
loadComponent.Example (Logical Feature Grouping with Standalone Components):
// src/app/features/products/product-list/product-list.component.ts import { Component, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ProductService } from '../../../services/product.service'; // Shared Service import { RouterLink } from '@angular/router'; @Component({ selector: 'app-product-list', standalone: true, imports: [CommonModule, RouterLink], template: ` <h3>Products List</h3> <ul> <li *ngFor="let product of productService.products()"> <a [routerLink]="['/products', product.id]">{{ product.name }}</a> </li> </ul> <button [routerLink]="['/products/add']">Add New Product</button> ` }) export class ProductListComponent { productService = inject(ProductService); } // src/app/features/products/product-detail/product-detail.component.ts import { Component, inject, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { CommonModule } from '@angular/common'; import { Product, ProductService } from '../../../services/product.service'; @Component({ selector: 'app-product-detail', standalone: true, imports: [CommonModule], template: ` <div *ngIf="product"> <h3>{{ product.name }} Details</h3> <p>ID: {{ product.id }}</p> <p>Price: \${{ product.price }}</p> </div> <p *ngIf="!product">Product not found.</p> ` }) export class ProductDetailComponent implements OnInit { private route = inject(ActivatedRoute); private productService = inject(ProductService); product: Product | undefined; ngOnInit(): void { this.route.paramMap.subscribe(params => { const productId = params.get('id'); this.product = this.productService.products().find(p => p.id === productId); }); } } // src/app/features/products/product-add/product-add.component.ts import { Component, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ProductService } from '../../../services/product.service'; import { Router } from '@angular/router'; @Component({ selector: 'app-product-add', standalone: true, imports: [FormsModule], template: ` <h3>Add New Product</h3> <form (ngSubmit)="onSubmit()"> <label>ID: <input type="text" [(ngModel)]="newProduct.id" name="id"></label><br> <label>Name: <input type="text" [(ngModel)]="newProduct.name" name="name"></label><br> <label>Price: <input type="number" [(ngModel)]="newProduct.price" name="price"></label><br> <button type="submit">Add Product</button> <button type="button" (click)="router.navigate(['/products'])">Cancel</button> </form> ` }) export class ProductAddComponent { private productService = inject(ProductService); router = inject(Router); newProduct = signal({ id: '', name: '', price: 0 }); onSubmit(): void { this.productService.addProduct(this.newProduct()); this.router.navigate(['/products']); } } // src/app/app.routes.ts (Lazy Loading a "Feature" of Standalone Components) import { Routes } from '@angular/router'; import { HomeComponent } from './components/home/home.component'; export const routes: Routes = [ { path: '', redirectTo: 'home', pathMatch: 'full' }, { path: 'home', component: HomeComponent }, { path: 'products', // This is a "feature module" from a routing perspective, but uses standalone components children: [ { path: '', loadComponent: () => import('./features/products/product-list/product-list.component').then(m => m.ProductListComponent) }, { path: 'add', loadComponent: () => import('./features/products/product-add/product-add.component').then(m => m.ProductAddComponent) }, { path: ':id', loadComponent: () => import('./features/products/product-detail/product-detail.component').then(m => m.ProductDetailComponent) } ] }, { path: '**', redirectTo: 'home' } ];
NgRx/Store, Akita, Elf, NGXS (State Management Libraries)
- Description: These are third-party libraries built on top of Angular (and RxJS for many of them) that provide comprehensive solutions for centralized state management in large-scale applications. They formalize and enhance various behavioral patterns (Command, Observer, State, Facade) into a cohesive architecture.
- Underlying Pattern Implementations:
- NgRx/Store (Redux-inspired): Implements a strict unidirectional data flow. Actions (Command) describe events, Reducers (State) pure functions update the state, Effects (Observer) handle side effects, and Selectors (Facade) query state.
- Akita: A state management pattern that uses
Stores(State, Observer) andQueries(Facade, Observer) to manage and expose reactive state, often considered simpler than NgRx for common use cases. - Elf: A modern, RxJS-powered state management library that focuses on simplicity, type safety, and composability, leveraging observables and providing clear patterns for state updates and queries.
- NGXS: Another Redux-inspired library that uses TypeScript classes to define state and actions, often seen as more “Angular-idiomatic” than NgRx by some developers due to its decorators and class-based structure.
- Why use them? For complex applications with shared, mutable state, these libraries offer predictability, debuggability, scalability, and better separation of concerns, preventing “prop drilling” and unmanageable component communication. They are a powerful application of many behavioral patterns in concert.
7. Guided Project: Building a “Task Manager” with Design Patterns
This section will guide the reader through building a simple Angular v20 application, demonstrating the practical application of several design patterns learned.
Project Overview
We will build a basic Task Management application. Users can add, edit, delete, and mark tasks as complete. The application will store tasks in-memory (for simplicity), demonstrating client-side state management.
Technologies
- Angular v20: The primary framework.
- TypeScript: For type-safe development.
- RxJS: For reactive programming, especially for observing task changes.
- Angular Signals: For modern, fine-grained reactivity and state management within components and services.
Step-by-Step Implementation
Setup: Angular CLI Project Creation
First, ensure you have Angular CLI v20 installed globally. If not:
npm install -g @angular/cli@20
Then create a new standalone Angular project:
ng new angular-task-manager --standalone --strict --style=css
cd angular-task-manager
Task Model: Define interfaces/classes
Define the Task interface that represents our task object.
// src/app/models/task.model.ts
export interface Task {
id: string;
title: string;
description?: string;
completed: boolean;
dueDate?: Date;
priority: 'low' | 'medium' | 'high';
}
Service Layer (Singleton, Facade): Create TaskService
This service will manage our task data. It will be a Singleton (providedIn: 'root') and act as a Facade over the underlying data operations (in this case, simple array manipulation, but it could abstract real API calls). It will use Angular Signals for its state, making it observable by components.
// src/app/services/task.service.ts
import { Injectable, signal, computed } from '@angular/core';
import { Task } from '../models/task.model';
import { v4 as uuidv4 } from 'uuid'; // npm install uuid @types/uuid
@Injectable({
providedIn: 'root' // Singleton Pattern
})
export class TaskService {
// Use a writable signal for the core task list state
private _tasks = signal<Task[]>([]);
// Expose a computed signal for read-only access (Facade/Observer Pattern)
tasks = computed(() => this._tasks());
constructor() {
// Initialize with some dummy data for demonstration
this._tasks.set([
{ id: uuidv4(), title: 'Learn Angular Signals', completed: false, priority: 'high', dueDate: new Date(2025, 8, 1) },
{ id: uuidv4(), title: 'Build Task Manager App', completed: false, priority: 'medium', dueDate: new Date(2025, 8, 15) },
{ id: uuidv4(), title: 'Write documentation', completed: true, priority: 'low', dueDate: new Date(2025, 7, 20) }
]);
}
addTask(task: Omit<Task, 'id'>): Task { // Facade: simplifies adding a task
const newTask: Task = { ...task, id: uuidv4(), completed: false };
this._tasks.update(currentTasks => [...currentTasks, newTask]);
console.log(`Task added: ${newTask.title}`);
return newTask;
}
updateTask(updatedTask: Task): void { // Facade: simplifies updating a task
this._tasks.update(currentTasks =>
currentTasks.map(task => (task.id === updatedTask.id ? updatedTask : task))
);
console.log(`Task updated: ${updatedTask.title}`);
}
deleteTask(id: string): void { // Facade: simplifies deleting a task
this._tasks.update(currentTasks => currentTasks.filter(task => task.id !== id));
console.log(`Task deleted: ID ${id}`);
}
getTaskById(id: string): Task | undefined {
return this._tasks().find(task => task.id === id);
}
toggleTaskCompletion(id: string): void {
this._tasks.update(currentTasks =>
currentTasks.map(task =>
task.id === id ? { ...task, completed: !task.completed } : task
)
);
}
}
Component Structure (Container/Presentational)
We’ll create two main types of components:
TaskListContainerComponent: A Container component responsible for fetching tasks fromTaskService, managing the list, and passing data down.TaskItemComponent: A Presentational component responsible for displaying a single task and emitting events for user interactions.TaskFormComponent: A Presentational component for adding/editing tasks.
// src/app/components/presentational/task-item/task-item.component.ts
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Task } from '../../../models/task.model';
@Component({
selector: 'app-task-item',
standalone: true,
imports: [CommonModule],
template: `
<div class="task-item" [class.completed]="task.completed">
<input
type="checkbox"
[checked]="task.completed"
(change)="toggleComplete.emit(task.id)"
/>
<span class="task-title">{{ task.title }}</span>
<span class="task-priority" [class]="task.priority">{{ task.priority.toUpperCase() }}</span>
<span *ngIf="task.dueDate" class="task-due-date">
(Due: {{ task.dueDate | date:'shortDate' }})
</span>
<div class="actions">
<button (click)="editTask.emit(task)">Edit</button>
<button (click)="deleteTask.emit(task.id)">Delete</button>
</div>
</div>
`,
styles: [`
.task-item {
display: flex;
align-items: center;
padding: 10px;
margin-bottom: 8px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #f9f9f9;
}
.task-item.completed {
text-decoration: line-through;
color: #888;
background-color: #e0e0e0;
}
.task-item input[type="checkbox"] {
margin-right: 10px;
}
.task-title {
flex-grow: 1;
font-weight: bold;
}
.task-priority {
padding: 2px 6px;
border-radius: 3px;
font-size: 0.8em;
margin-left: 10px;
color: white;
}
.task-priority.high { background-color: #e74c3c; }
.task-priority.medium { background-color: #f39c12; }
.task-priority.low { background-color: #2ecc71; }
.task-due-date {
font-size: 0.9em;
color: #666;
margin-left: 10px;
}
.actions button {
margin-left: 8px;
padding: 5px 10px;
border: none;
border-radius: 3px;
cursor: pointer;
background-color: #007bff;
color: white;
}
.actions button:hover {
opacity: 0.9;
}
.actions button:last-child {
background-color: #dc3545;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskItemComponent {
@Input() task!: Task;
@Output() toggleComplete = new EventEmitter<string>();
@Output() editTask = new EventEmitter<Task>();
@Output() deleteTask = new EventEmitter<string>();
}
// src/app/components/presentational/task-form/task-form.component.ts
import { Component, Output, EventEmitter, Input, OnInit, OnChanges, SimpleChanges, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Task } from '../../../models/task.model';
@Component({
selector: 'app-task-form',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="task-form-container">
<h3>{{ isEditing() ? 'Edit Task' : 'Add New Task' }}</h3>
<form (ngSubmit)="onSubmit()">
<label>
Title:
<input type="text" [(ngModel)]="taskForm.title" name="title" required />
</label>
<label>
Description:
<textarea [(ngModel)]="taskForm.description" name="description"></textarea>
</label>
<label>
Due Date:
<input type="date" [(ngModel)]="taskForm.dueDate" name="dueDate" />
</label>
<label>
Priority:
<select [(ngModel)]="taskForm.priority" name="priority">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</label>
<button type="submit">{{ isEditing() ? 'Update Task' : 'Add Task' }}</button>
<button type="button" (click)="cancel.emit()">Cancel</button>
</form>
</div>
`,
styles: [`
.task-form-container {
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
margin-top: 20px;
background-color: #fff;
}
.task-form-container label {
display: block;
margin-bottom: 10px;
font-weight: bold;
}
.task-form-container input[type="text"],
.task-form-container input[type="date"],
.task-form-container textarea,
.task-form-container select {
width: 100%;
padding: 8px;
margin-top: 5px;
margin-bottom: 10px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box; /* Include padding in element's total width and height */
}
.task-form-container button {
padding: 10px 15px;
margin-right: 10px;
border: none;
border-radius: 4px;
cursor: pointer;
background-color: #28a745;
color: white;
}
.task-form-container button[type="button"] {
background-color: #6c757d;
}
.task-form-container button:hover {
opacity: 0.9;
}
`]
})
export class TaskFormComponent implements OnChanges {
@Input() taskToEdit: Task | null = null;
@Output() saveTask = new EventEmitter<Task>();
@Output() cancel = new EventEmitter<void>();
// Use a signal for the form data to automatically reflect changes
taskForm = signal<Omit<Task, 'id' | 'completed'>>({
title: '',
description: '',
priority: 'medium',
dueDate: undefined
});
isEditing = signal(false);
ngOnChanges(changes: SimpleChanges): void {
if (changes['taskToEdit'] && this.taskToEdit) {
this.isEditing.set(true);
// Create a new object for the form to prevent accidental direct mutation
this.taskForm.set({
title: this.taskToEdit.title,
description: this.taskToEdit.description,
priority: this.taskToEdit.priority,
dueDate: this.taskToEdit.dueDate
? new Date(this.taskToEdit.dueDate).toISOString().split('T')[0] as any // Convert Date to YYYY-MM-DD for input[type=date]
: undefined
});
} else if (!this.taskToEdit && this.isEditing()) {
// If taskToEdit becomes null and we were editing, reset form
this.resetForm();
}
}
onSubmit(): void {
const submittedTask: Task = {
id: this.taskToEdit?.id || '', // Keep existing ID if editing
completed: this.taskToEdit?.completed || false, // Keep existing completion status
title: this.taskForm().title,
description: this.taskForm().description,
priority: this.taskForm().priority,
dueDate: this.taskForm().dueDate ? new Date(this.taskForm().dueDate) : undefined
};
this.saveTask.emit(submittedTask);
this.resetForm();
}
private resetForm(): void {
this.isEditing.set(false);
this.taskForm.set({
title: '',
description: '',
priority: 'medium',
dueDate: undefined
});
}
}
// src/app/components/container/task-list-container/task-list-container.component.ts
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TaskService } from '../../../services/task.service'; // Our Service/Facade
import { TaskItemComponent } from '../../presentational/task-item/task-item.component'; // Presentational Component
import { TaskFormComponent } from '../../presentational/task-form/task-form.component'; // Presentational Component
import { Task } from '../../../models/task.model';
import { BehaviorSubject, combineLatest, map, Observable } from 'rxjs'; // For sorting strategy
import { StrategyToggleComponent } from '../../presentational/strategy-toggle/strategy-toggle.component'; // For Strategy Pattern
import { SortStrategy, PRIORITY_SORT_STRATEGY, DUE_DATE_SORT_STRATEGY } from '../../../strategies/sort-strategy.interface';
import { InjectionToken } from '@angular/core';
// Define tokens for specific strategies to be injected by the container
export const SELECTED_SORT_STRATEGY = new InjectionToken<SortStrategy<Task>>('SELECTED_SORT_STRATEGY');
@Component({
selector: 'app-task-list-container',
standalone: true,
imports: [CommonModule, TaskItemComponent, TaskFormComponent, StrategyToggleComponent],
template: `
<div class="task-manager">
<h2>My Tasks (Container/Presentational)</h2>
<app-strategy-toggle
[currentStrategyName]="(currentSortStrategy$ | async)?.getName() || ''"
(strategyChange)="onSortStrategyChange($event)"
></app-strategy-toggle>
<div class="task-list">
<app-task-item
*ngFor="let task of (sortedTasks$ | async)"
[task]="task"
(toggleComplete)="onToggleTaskCompletion($event)"
(editTask)="onEditTask($event)"
(deleteTask)="onDeleteTask($event)"
></app-task-item>
<p *ngIf="(sortedTasks$ | async)?.length === 0">No tasks found. Add one!</p>
</div>
<button (click)="showAddTaskForm = true" *ngIf="!showAddTaskForm && !editingTask">Add New Task</button>
<app-task-form
*ngIf="showAddTaskForm || editingTask"
[taskToEdit]="editingTask"
(saveTask)="onSaveTask($event)"
(cancel)="onCancelForm()"
></app-task-form>
</div>
`,
styles: [`
.task-manager { max-width: 800px; margin: 20px auto; padding: 20px; border: 1px solid #eee; border-radius: 8px; background-color: #fcfcfc; }
.task-list { margin-top: 20px; }
`],
providers: [
// Provide strategies here or in app.config.ts if they are globally used
{ provide: PRIORITY_SORT_STRATEGY, useClass: class PrioritySort implements SortStrategy<Task> {
sort(data: Task[]): Task[] {
console.log('Sorting by priority...');
const priorityOrder: { [key: string]: number } = { 'high': 3, 'medium': 2, 'low': 1 };
return [...data].sort((a, b) => priorityOrder[b.priority] - priorityOrder[a.priority]);
}
getName(): string { return 'Priority'; }
}},
{ provide: DUE_DATE_SORT_STRATEGY, useClass: class DueDateSort implements SortStrategy<Task> {
sort(data: Task[]): Task[] {
console.log('Sorting by due date...');
return [...data].sort((a, b) => {
if (!a.dueDate && !b.dueDate) return 0;
if (!a.dueDate) return 1; // Undefined due date goes last
if (!b.dueDate) return -1;
return a.dueDate.getTime() - b.dueDate.getTime();
});
}
getName(): string { return 'Due Date'; }
}},
// Provide the currently selected strategy dynamically
{
provide: SELECTED_SORT_STRATEGY,
useFactory: (priorityStrategy: SortStrategy<Task>, dueDateStrategy: SortStrategy<Task>) => {
// This factory will be resolved by the component logic
// We'll manage the selection in the component directly for simplicity here
return priorityStrategy; // Default
},
deps: [PRIORITY_SORT_STRATEGY, DUE_DATE_SORT_STRATEGY]
}
]
})
export class TaskListContainerComponent implements OnInit {
private taskService = inject(TaskService);
private prioritySortStrategy = inject(PRIORITY_SORT_STRATEGY);
private dueDateSortStrategy = inject(DUE_DATE_SORT_STRATEGY);
showAddTaskForm = false;
editingTask: Task | null = null;
// RxJS Subject to hold the currently selected sorting strategy (Strategy Pattern Context)
private currentSortStrategySubject = new BehaviorSubject<SortStrategy<Task>>(this.prioritySortStrategy);
currentSortStrategy$ = this.currentSortStrategySubject.asObservable();
// Combine tasks from service and the selected sorting strategy
// Then map to sorted tasks. This is an application of Observer/Strategy patterns.
sortedTasks$: Observable<Task[]> = combineLatest([
this.taskService.tasks, // Angular Signal transformed implicitly to Observable for combineLatest
this.currentSortStrategySubject.asObservable()
]).pipe(
map(([tasks, strategy]) => strategy.sort(tasks))
);
ngOnInit(): void {
// Initial load/sort if needed, but the combineLatest already handles initial state.
}
onToggleTaskCompletion(id: string): void {
this.taskService.toggleTaskCompletion(id);
}
onDeleteTask(id: string): void {
if (confirm('Are you sure you want to delete this task?')) {
this.taskService.deleteTask(id);
if (this.editingTask?.id === id) {
this.editingTask = null; // Clear edit form if deleted
}
}
}
onEditTask(task: Task): void {
this.editingTask = task;
this.showAddTaskForm = false; // Hide add form if showing
}
onSaveTask(task: Task): void {
if (this.editingTask) {
this.taskService.updateTask({ ...this.editingTask, ...task }); // Merge changes, keep ID
} else {
this.taskService.addTask(task);
}
this.editingTask = null;
this.showAddTaskForm = false;
}
onCancelForm(): void {
this.showAddTaskForm = false;
this.editingTask = null;
}
onSortStrategyChange(strategyName: string): void {
if (strategyName === 'Priority') {
this.currentSortStrategySubject.next(this.prioritySortStrategy);
} else if (strategyName === 'Due Date') {
this.currentSortStrategySubject.next(this.dueDateSortStrategy);
}
}
}
State Management (Observer, State): Use RxJS BehaviorSubject or Signals
In TaskService, we used signal and computed for state management, which are fundamentally Observer-like. The TaskListContainerComponent observes these changes directly.
For the Strategy Pattern, we’ll add a StrategyToggleComponent and implement two sorting strategies.
// src/app/components/presentational/strategy-toggle/strategy-toggle.component.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-strategy-toggle',
standalone: true,
imports: [CommonModule],
template: `
<div class="strategy-toggle-container">
<span>Sort By: </span>
<button
[class.active]="currentStrategyName === 'Priority'"
(click)="strategyChange.emit('Priority')"
>
Priority
</button>
<button
[class.active]="currentStrategyName === 'Due Date'"
(click)="strategyChange.emit('Due Date')"
>
Due Date
</button>
</div>
`,
styles: [`
.strategy-toggle-container { margin-bottom: 20px; text-align: right; }
.strategy-toggle-container button {
padding: 8px 12px;
margin-left: 10px;
border: 1px solid #007bff;
border-radius: 4px;
background-color: white;
color: #007bff;
cursor: pointer;
}
.strategy-toggle-container button.active {
background-color: #007bff;
color: white;
}
`]
})
export class StrategyToggleComponent {
@Input() currentStrategyName: string = '';
@Output() strategyChange = new EventEmitter<string>();
}
// src/app/strategies/sort-strategy.interface.ts (Updated for Task data)
import { Task } from "../models/task.model";
import { InjectionToken } from "@angular/core";
export interface SortStrategy<T> {
sort(data: T[]): T[];
getName(): string;
}
// Define injection tokens for our strategies
export const PRIORITY_SORT_STRATEGY = new InjectionToken<SortStrategy<Task>>('PRIORITY_SORT_STRATEGY');
export const DUE_DATE_SORT_STRATEGY = new InjectionToken<SortStrategy<Task>>('DUE_DATE_SORT_STRATEGY');
// Implementations are in the TaskListContainerComponent's providers array for local scope,
// or could be separate files provided in root if globally reusable.
Note: For the purpose of this guided project, the concrete implementations of PrioritySortStrategy and DueDateSortStrategy are provided directly within the TaskListContainerComponent’s providers array. In a larger application, these would typically reside in their own files (e.g., src/app/strategies/priority-sort.strategy.ts) and be provided at the root or module level.
Command Pattern (Optional): Implement undo/redo for task deletions.
To implement undo/redo, we’ll need to encapsulate operations as commands and maintain a history. This adds complexity but demonstrates the pattern. For simplicity, we’ll focus on deleting.
// src/app/commands/command.interface.ts (already defined in section 5)
export interface Command {
execute(): void;
undo?(): void;
}
// src/app/commands/delete-task.command.ts (re-using from section 5, adjust if needed)
import { Command } from './command.interface';
import { TaskService } from '../services/task.service';
import { Task } from '../models/task.model';
export class DeleteTaskCommand implements Command {
private deletedTask: Task | undefined; // Store the task for undo
constructor(private taskService: TaskService, private taskId: string) {}
execute(): void {
this.deletedTask = this.taskService.getTaskById(this.taskId);
if (this.deletedTask) {
this.taskService.deleteTask(this.taskId);
console.log(`Command: Deleted task "${this.deletedTask.title}"`);
} else {
console.warn(`Command: Task with ID ${this.taskId} not found.`);
}
}
undo?(): void {
if (this.deletedTask) {
// Re-add the task (the service handles assigning a new ID if needed, but in our
// simple case, we need to pass the original ID to revert correctly)
this.taskService.addTask({
id: this.deletedTask.id, // Ensure original ID is used for undo
title: this.deletedTask.title,
description: this.deletedTask.description,
completed: this.deletedTask.completed,
dueDate: this.deletedTask.dueDate,
priority: this.deletedTask.priority
});
console.log(`Command: Undid deletion of task "${this.deletedTask.title}"`);
}
}
}
// src/app/services/command-history.service.ts (re-using from section 5)
import { Injectable } from '@angular/core';
import { Command } from '../commands/command.interface';
@Injectable({ providedIn: 'root' })
export class CommandHistoryService {
private history: Command[] = [];
private currentIndex: number = -1;
executeCommand(command: Command): void {
if (this.currentIndex < this.history.length - 1) {
this.history = this.history.slice(0, this.currentIndex + 1);
}
command.execute();
this.history.push(command);
this.currentIndex = this.history.length - 1;
console.log('CommandHistory: Executed command. History length:', this.history.length);
}
undo(): void {
if (this.currentIndex >= 0 && this.history[this.currentIndex]?.undo) {
const command = this.history[this.currentIndex];
command.undo!(); // Use non-null assertion as we've checked for `undo`
this.currentIndex--;
console.log('CommandHistory: Undo executed. Current index:', this.currentIndex);
} else {
console.warn('CommandHistory: Nothing to undo or command does not support undo.');
}
}
redo(): void {
if (this.currentIndex < this.history.length - 1) {
this.currentIndex++;
const command = this.history[this.currentIndex];
command.execute(); // Re-execute the command
console.log('CommandHistory: Redo executed. Current index:', this.currentIndex);
} else {
console.warn('CommandHistory: Nothing to redo.');
}
}
canUndo(): boolean {
return this.currentIndex >= 0 && !!this.history[this.currentIndex]?.undo;
}
canRedo(): boolean {
return this.currentIndex < this.history.length - 1;
}
}
Now, integrate the Command Pattern into TaskListContainerComponent:
// src/app/components/container/task-list-container/task-list-container.component.ts (Updated)
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TaskService } from '../../../services/task.service';
import { TaskItemComponent } from '../../presentational/task-item/task-item.component';
import { TaskFormComponent } from '../../presentational/task-form/task-form.component';
import { Task } from '../../../models/task.model';
import { BehaviorSubject, combineLatest, map, Observable } from 'rxjs';
import { StrategyToggleComponent } from '../../presentational/strategy-toggle/strategy-toggle.component';
import { SortStrategy, PRIORITY_SORT_STRATEGY, DUE_DATE_SORT_STRATEGY } from '../../../strategies/sort-strategy.interface';
import { CommandHistoryService } from '../../../services/command-history.service'; // NEW
import { DeleteTaskCommand } from '../../../commands/delete-task.command'; // NEW
@Component({
selector: 'app-task-list-container',
standalone: true,
imports: [CommonModule, TaskItemComponent, TaskFormComponent, StrategyToggleComponent],
template: `
<div class="task-manager">
<h2>My Tasks (Container/Presentational)</h2>
<app-strategy-toggle
[currentStrategyName]="(currentSortStrategy$ | async)?.getName() || ''"
(strategyChange)="onSortStrategyChange($event)"
></app-strategy-toggle>
<div class="task-list">
<app-task-item
*ngFor="let task of (sortedTasks$ | async)"
[task]="task"
(toggleComplete)="onToggleTaskCompletion($event)"
(editTask)="onEditTask($event)"
(deleteTask)="onDeleteTask($event)"
></app-task-item>
<p *ngIf="(sortedTasks$ | async)?.length === 0">No tasks found. Add one!</p>
</div>
<button (click)="showAddTaskForm = true" *ngIf="!showAddTaskForm && !editingTask">Add New Task</button>
<button (click)="commandHistory.undo()" [disabled]="!commandHistory.canUndo()">Undo Last Action</button> <!-- NEW -->
<button (click)="commandHistory.redo()" [disabled]="!commandHistory.canRedo()">Redo Last Action</button> <!-- NEW -->
<app-task-form
*ngIf="showAddTaskForm || editingTask"
[taskToEdit]="editingTask"
(saveTask)="onSaveTask($event)"
(cancel)="onCancelForm()"
></app-task-form>
</div>
`,
styles: [`
.task-manager { max-width: 800px; margin: 20px auto; padding: 20px; border: 1px solid #eee; border-radius: 8px; background-color: #fcfcfc; }
.task-list { margin-top: 20px; }
button { margin-right: 10px; padding: 8px 15px; border-radius: 4px; border: none; cursor: pointer; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
`],
providers: [
{ provide: PRIORITY_SORT_STRATEGY, useClass: class PrioritySort implements SortStrategy<Task> {
sort(data: Task[]): Task[] {
console.log('Sorting by priority...');
const priorityOrder: { [key: string]: number } = { 'high': 3, 'medium': 2, 'low': 1 };
return [...data].sort((a, b) => priorityOrder[b.priority] - priorityOrder[a.priority]);
}
getName(): string { return 'Priority'; }
}},
{ provide: DUE_DATE_SORT_STRATEGY, useClass: class DueDateSort implements SortStrategy<Task> {
sort(data: Task[]): Task[] {
console.log('Sorting by due date...');
return [...data].sort((a, b) => {
if (!a.dueDate && !b.dueDate) return 0;
if (!a.dueDate) return 1;
if (!b.dueDate) return -1;
return a.dueDate.getTime() - b.dueDate.getTime();
});
}
getName(): string { return 'Due Date'; }
}},
]
})
export class TaskListContainerComponent {
private taskService = inject(TaskService);
private commandHistory = inject(CommandHistoryService); // NEW
private prioritySortStrategy = inject(PRIORITY_SORT_STRATEGY);
private dueDateSortStrategy = inject(DUE_DATE_SORT_STRATEGY);
showAddTaskForm = false;
editingTask: Task | null = null;
private currentSortStrategySubject = new BehaviorSubject<SortStrategy<Task>>(this.prioritySortStrategy);
currentSortStrategy$ = this.currentSortStrategySubject.asObservable();
sortedTasks$: Observable<Task[]> = combineLatest([
this.taskService.tasks,
this.currentSortStrategySubject.asObservable()
]).pipe(
map(([tasks, strategy]) => strategy.sort(tasks))
);
onToggleTaskCompletion(id: string): void {
this.taskService.toggleTaskCompletion(id);
}
onDeleteTask(id: string): void {
if (confirm('Are you sure you want to delete this task? This action can be undone.')) {
const command = new DeleteTaskCommand(this.taskService, id); // Create Command
this.commandHistory.executeCommand(command); // Execute via History service
if (this.editingTask?.id === id) {
this.editingTask = null;
}
}
}
onEditTask(task: Task): void {
this.editingTask = task;
this.showAddTaskForm = false;
}
onSaveTask(task: Task): void {
if (this.editingTask) {
this.taskService.updateTask({ ...this.editingTask, ...task });
} else {
this.taskService.addTask(task);
}
this.editingTask = null;
this.showAddTaskForm = false;
}
onCancelForm(): void {
this.showAddTaskForm = false;
this.editingTask = null;
}
onSortStrategyChange(strategyName: string): void {
if (strategyName === 'Priority') {
this.currentSortStrategySubject.next(this.prioritySortStrategy);
} else if (strategyName === 'Due Date') {
this.currentSortStrategySubject.next(this.dueDateSortStrategy);
}
}
}
Run and Verify
- Install dependencies:
npm install uuid npm install @types/uuid --save-dev - Ensure
app.component.tshosts theTaskListContainerComponent:// src/app/app.component.ts import { Component } from '@angular/core'; import { TaskListContainerComponent } from './components/container/task-list-container/task-list-container.component'; @Component({ selector: 'app-root', standalone: true, imports: [TaskListContainerComponent], template: ` <app-task-list-container></app-task-list-container> ` }) export class AppComponent {} - Run the application:Open your browser to
ng serve -ohttp://localhost:4200.
Verify:
- You should see a list of tasks.
- Click “Add New Task” to see the form. Add a task.
- Click “Edit” on a task, modify it, and save.
- Click “Delete” on a task. Then try “Undo Last Action” and “Redo Last Action” to see the Command pattern in action.
- Toggle between “Priority” and “Due Date” sorting to observe the Strategy pattern.
- Observe how
TaskListContainerComponentmanages state and logic, whileTaskItemComponentandTaskFormComponentare purely for UI, demonstrating the Container/Presentational pattern. TheTaskServiceacts as a Singleton and Facade.
8. Best Practices and Anti-Patterns
Applying design patterns effectively requires judgment. Over-engineering can introduce unnecessary complexity, while ignoring patterns can lead to unmanageable code.
When to use and when not to use design patterns
When to Use:
- Recurring problems: When you encounter a design problem that has a well-known, proven solution.
- Complexity management: To simplify interactions between objects or manage complex state.
- Scalability and maintainability: To build applications that are easy to extend and debug.
- Team collaboration: To establish a common architectural vocabulary among developers.
- Testability: Patterns often naturally lead to more testable code by decoupling concerns.
When Not to Use (or use with caution):
- Over-engineering: Don’t force a pattern where a simpler solution suffices. Patterns introduce overhead, and if the problem isn’t complex enough, they can make code harder to understand.
- Unnecessary abstraction: Excessive layers of abstraction can obscure the underlying logic.
- Premature optimization: Don’t apply patterns for theoretical performance gains before profiling shows a real bottleneck. (e.g., Flyweight for small, non-numerous objects).
- Lack of understanding: Misapplying a pattern can be worse than not using it at all. Ensure your team understands the pattern’s intent and implications.
Common Anti-Patterns in Angular development to avoid
Anti-patterns are common responses to recurring problems that are ineffective and counterproductive.
- God Component/Service: A component or service that tries to do too much – manages too much state, handles too many responsibilities, or has too many dependencies. This makes it hard to understand, test, and maintain. (Violates Single Responsibility Principle).
- Solution: Break down into smaller, focused components/services following the Container/Presentational pattern or Facade.
- Prop Drilling: Passing data through many layers of components using
@Input()or@Output()when unrelated components deep in the hierarchy need access to common data.- Solution: Use a centralized state management solution (NgRx, Signals in services), a Mediator service, or Angular’s DI hierarchy for shared services.
- Direct DOM Manipulation (outside of directives): Directly manipulating the DOM using
document.querySelectororElementRef.nativeElementin components (unless necessary for third-party library integration or low-level operations). This bypasses Angular’s rendering and change detection, making applications brittle and hard to test.- Solution: Use Angular templates, directives, and data binding (
[property],(event),*ngIf,*ngFor).
- Solution: Use Angular templates, directives, and data binding (
anyAbuse: Overuse of theanytype in TypeScript, negating the benefits of type safety.- Solution: Define proper interfaces, types, and enums for data models and function signatures.
- Ignoring RxJS Subscription Management: Not unsubscribing from observables, leading to memory leaks and unexpected behavior.
- Solution: Use
asyncpipe,takeUntiloperator, or explicitlyunsubscribe()inngOnDestroy.
- Solution: Use
- Logic in Templates: Embedding complex business logic directly in Angular templates.
- Solution: Move logic into component methods, pipes, or services. Templates should primarily focus on presentation.
Balancing complexity and simplicity
The goal of using design patterns is not to make code more complex, but to manage inherent complexity more effectively. The key is balance:
- Start Simple: Begin with the simplest solution that meets the requirements.
- Refactor When Needed: Introduce patterns as complexity arises or when requirements evolve, not upfront speculatively. This aligns with the “Rule of Three” (refactor after seeing similar code/logic three times).
- Understand Trade-offs: Every pattern has benefits and drawbacks. Be aware of the overhead and choose patterns that truly solve a problem in your specific context.
- Prioritize Readability: Well-structured code with patterns should be easier to read and understand than spaghetti code. If a pattern makes it harder, you might be misapplying it.
9. Conclusion
Design patterns are indispensable tools in the arsenal of any skilled software developer, and their application within the Angular ecosystem yields significant benefits. By understanding and strategically applying creational, structural, and behavioral patterns, Angular developers can build applications that are not only functional but also highly maintainable, scalable, reusable, and robust.
Angular’s inherent architecture, with its powerful Dependency Injection, component-based structure, and reactive programming paradigms (RxJS and Signals), naturally embraces and encourages the use of these patterns. From the Singleton nature of root-provided services to the Observer pattern at the heart of its reactivity, and the power of directives as Decorators, Angular itself is a testament to effective pattern application.
The adoption of standalone components in Angular v20 further streamlines development, making pattern implementation often more direct and less tied to the boilerplate of NgModules, while still maintaining modularity. Modern reactivity with Signals offers new, clearer ways to manage state and interactions, aligning with the Observer and State patterns.
Continuous learning and a pragmatic approach are essential. Do not blindly apply patterns; instead, understand the problems they solve and use them judiciously to address specific challenges in your application. By integrating design patterns into your daily Angular development workflow, you empower yourself and your team to write cleaner, more effective, and future-proof code.
Embrace the power of patterns, build with intent, and watch your Angular applications flourish into elegant, resilient systems.
10. Bonus Section: Further Resources
To deepen your understanding of design patterns and Angular architecture, explore the following resources:
Recommended Books:
- “Design Patterns: Elements of Reusable Object-Oriented Software” by Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides (Gang of Four - GoF): The classic book that codified many of the patterns discussed. While not Angular-specific, its foundational knowledge is invaluable.
- “Learning Angular” (latest edition): Look for the most recent edition available. Books specifically on Angular often cover architectural best practices and patterns within the framework.
- “Effective Java” by Joshua Bloch: While Java-focused, it provides excellent insights into good software design principles, many of which are language-agnostic and apply to TypeScript/Angular.
Online Courses:
- Udemy, Coursera, Pluralsight: Search for “Angular Advanced Concepts,” “Angular Architecture,” “Angular Design Patterns,” “RxJS Masterclass,” or “Software Design Patterns.” Look for courses from reputable instructors with high ratings and recent updates.
- Example search terms:
Angular design patterns udemy,NgRx course pluralsight,Reactive programming RxJS coursera.
- Example search terms:
- Official Angular Documentation: Always refer to the official Angular.dev documentation for the most accurate and up-to-date information on the framework’s features and best practices.
Blogs/Articles:
- Medium: Many experienced Angular developers and architects share their insights on Medium. Use search terms like “Angular design patterns,” “Angular architecture best practices,” “Angular state management,” or “Angular clean code.”
- Popular Angular publications on Medium:
inDepth.dev,Angular inDepth.
- Popular Angular publications on Medium:
- Dev.to: A vibrant community for developers. Search for
Angularanddesign patterns,architecture, orbest practices. - Angular Official Blog: Stay updated with the latest news, features, and guidance directly from the Angular team.
- ThoughtWorks Technology Radar: Provides insights into emerging technologies and techniques, including architectural patterns.
Community Forums/Websites:
- Stack Overflow: For specific questions and troubleshooting. Tag your questions with
angularanddesign-patterns. - Angular GitHub Repository: Explore the source code, issues, and discussions for deeper insights into the framework’s design.
- Official Angular Discord/Forums: Engage directly with the Angular community to ask questions and share knowledge.
Relevant npm Packages:
- NgRx: The most popular state management library for Angular, implementing a Redux-like architecture.
@ngrx/store,@ngrx/effects,@ngrx/entity,@ngrx/component-store
- NGXS: Another powerful state management solution for Angular.
@ngxs/store,@ngxs/devtools-plugin
- Akita: A simpler, more opinionated state management solution using RxJS.
- Elf: A new, type-safe, and reactive state management solution.
- uuid: A widely used library for generating unique IDs, helpful for models in state management. (
npm install uuidand@types/uuid). - Angular CDK (Component Dev Kit): Provides behavior primitives that can be used to build components, often demonstrating patterns like Adapter (e.g.,
Scrollablemodule for virtual scrolling uses Flyweight principles implicitly).
Remember to always consider the context of your project and team when choosing and applying design patterns. Happy coding!