Files
2025-11-29 18:24:55 +08:00

17 KiB

Enterprise Angular Patterns

Proven architectural patterns for building scalable Angular applications in enterprise environments with teams of 5-100+ developers.


Core Principles

  1. Separation of Concerns - Each piece of code has one responsibility
  2. Single Source of Truth - State lives in one place
  3. Consistency - Follow patterns religiously
  4. Scalability - Design for 10x growth
  5. Maintainability - Code should be easy to change

Pattern 1: Core-Shared-Features Structure

Overview

Organize code into three main categories based on scope and reusability.

The Three Folders

src/app/
├── core/          # App-wide singletons (loaded once)
├── shared/        # Reusable components/utilities
└── features/      # Feature modules (lazy loaded)

Core Module Rules

What belongs in core:

  • Singleton services (AuthService, ApiService, CacheService)
  • HTTP interceptors (auth, error handling, retry)
  • Route guards (authentication, authorization)
  • Global error handlers
  • App-wide models and interfaces
  • Constants and configuration

What does NOT belong:

  • UI components
  • Feature-specific services
  • Reusable utilities (those go in shared)

Example:

// core/services/auth.service.ts
@Injectable({ providedIn: 'root' })
export class AuthService {
  private currentUser$ = new BehaviorSubject<User | null>(null);
  
  login(credentials: Credentials): Observable<User> {
    return this.http.post<User>('/api/auth/login', credentials).pipe(
      tap(user => this.currentUser$.next(user))
    );
  }
  
  getCurrentUser(): Observable<User | null> {
    return this.currentUser$.asObservable();
  }
}

Shared Module Rules

What belongs in shared:

  • Dumb/presentational components (buttons, cards, modals)
  • Custom directives (tooltips, permissions, auto-focus)
  • Custom pipes (formatting, filtering)
  • Utility functions (date helpers, validators)
  • Common interfaces used across features

What does NOT belong:

  • Business logic
  • HTTP calls
  • Feature-specific components

Example:

// shared/components/data-table/data-table.component.ts
@Component({
  selector: 'app-data-table',
  standalone: true,
  template: `
    <table>
      <thead>
        <tr>
          @for (column of columns(); track column.key) {
            <th>{{ column.label }}</th>
          }
        </tr>
      </thead>
      <tbody>
        @for (row of data(); track row.id) {
          <tr>
            @for (column of columns(); track column.key) {
              <td>{{ row[column.key] }}</td>
            }
          </tr>
        }
      </tbody>
    </table>
  `
})
export class DataTableComponent {
  columns = input.required<Column[]>();
  data = input.required<any[]>();
}

Features Module Rules

What belongs in features:

  • Feature-specific components (smart + dumb)
  • Feature-specific services
  • Feature-specific models
  • Feature routing configuration

Structure:

features/
└── products/
    ├── components/           # Feature components
    │   ├── product-list/
    │   ├── product-detail/
    │   └── product-form/
    ├── services/             # Feature services
    │   └── product.service.ts
    ├── models/               # Feature models
    │   └── product.interface.ts
    ├── products.routes.ts    # Feature routes
    └── products.component.ts # Container component

Pattern 2: Smart and Dumb Components

Overview

Separate components that manage data (smart) from components that display data (dumb).

Smart Components (Containers)

Characteristics:

  • Communicate with services
  • Manage state
  • Handle business logic
  • Usually top-level feature components

Example:

// features/products/product-list.component.ts
@Component({
  selector: 'app-product-list',
  template: `
    <app-search-bar (search)="handleSearch($event)" />
    
    @if (loading()) {
      <app-loading-spinner />
    } @else if (error()) {
      <app-error-message [error]="error()" />
    } @else {
      @for (product of products(); track product.id) {
        <app-product-card 
          [product]="product"
          (edit)="handleEdit($event)"
          (delete)="handleDelete($event)"
        />
      }
    }
  `
})
export class ProductListComponent {
  private productService = inject(ProductService);
  
  products = signal<Product[]>([]);
  loading = signal(false);
  error = signal<string | null>(null);
  
  ngOnInit() {
    this.loadProducts();
  }
  
  loadProducts() {
    this.loading.set(true);
    this.productService.getProducts().pipe(
      takeUntilDestroyed()
    ).subscribe({
      next: products => {
        this.products.set(products);
        this.loading.set(false);
      },
      error: err => {
        this.error.set(err.message);
        this.loading.set(false);
      }
    });
  }
  
  handleEdit(id: string) {
    this.router.navigate(['/products', id, 'edit']);
  }
  
  handleDelete(id: string) {
    if (confirm('Delete this product?')) {
      this.productService.delete(id).subscribe();
    }
  }
}

Dumb Components (Presentational)

Characteristics:

  • Receive data via @Input or input()
  • Emit events via @Output or output()
  • No service dependencies
  • Highly reusable
  • Easy to test

Example:

// shared/components/product-card.component.ts
@Component({
  selector: 'app-product-card',
  standalone: true,
  imports: [CurrencyPipe],
  template: `
    <div class="card">
      <img [src]="product().image" [alt]="product().name" />
      <h3>{{ product().name }}</h3>
      <p>{{ product().price | currency }}</p>
      <div class="actions">
        <button (click)="edit.emit(product().id)">Edit</button>
        <button (click)="delete.emit(product().id)">Delete</button>
      </div>
    </div>
  `
})
export class ProductCardComponent {
  product = input.required<Product>();
  edit = output<string>();
  delete = output<string>();
}

Pattern 3: Service Layer Architecture

Overview

Organize services by responsibility: data access, business logic, and state management.

Data Services

Purpose: HTTP communication only

// core/services/api.service.ts
@Injectable({ providedIn: 'root' })
export class ApiService {
  private http = inject(HttpClient);
  private baseUrl = environment.apiUrl;
  
  get<T>(endpoint: string): Observable<T> {
    return this.http.get<T>(`${this.baseUrl}/${endpoint}`);
  }
  
  post<T>(endpoint: string, data: any): Observable<T> {
    return this.http.post<T>(`${this.baseUrl}/${endpoint}`, data);
  }
}

Business Services

Purpose: Business logic and domain operations

// features/products/services/product.service.ts
@Injectable({ providedIn: 'root' })
export class ProductService {
  private api = inject(ApiService);
  
  getProducts(): Observable<Product[]> {
    return this.api.get<Product[]>('products').pipe(
      map(products => this.enrichProducts(products))
    );
  }
  
  private enrichProducts(products: Product[]): Product[] {
    return products.map(p => ({
      ...p,
      displayPrice: this.formatPrice(p.price),
      inStock: p.quantity > 0
    }));
  }
  
  private formatPrice(price: number): string {
    return new Intl.NumberFormat('en-US', {
      style: 'currency',
      currency: 'USD'
    }).format(price);
  }
}

State Services

Purpose: Manage application state

// features/cart/services/cart-state.service.ts
@Injectable({ providedIn: 'root' })
export class CartStateService {
  private itemsSubject = new BehaviorSubject<CartItem[]>([]);
  
  // Public observable
  items$ = this.itemsSubject.asObservable();
  
  // Computed values
  total$ = this.items$.pipe(
    map(items => items.reduce((sum, item) => sum + item.price * item.quantity, 0))
  );
  
  itemCount$ = this.items$.pipe(
    map(items => items.reduce((count, item) => count + item.quantity, 0))
  );
  
  addItem(item: CartItem) {
    const current = this.itemsSubject.value;
    this.itemsSubject.next([...current, item]);
  }
  
  removeItem(id: string) {
    const current = this.itemsSubject.value;
    this.itemsSubject.next(current.filter(item => item.id !== id));
  }
  
  clear() {
    this.itemsSubject.next([]);
  }
}

Pattern 4: Facade Pattern

Overview

Create a single entry point for complex subsystems.

Use Case

When a feature has multiple related services that components need to interact with.

Example:

// features/checkout/services/checkout.facade.ts
@Injectable({ providedIn: 'root' })
export class CheckoutFacade {
  private cartService = inject(CartService);
  private paymentService = inject(PaymentService);
  private shippingService = inject(ShippingService);
  private orderService = inject(OrderService);
  
  // Expose simplified API
  cart$ = this.cartService.items$;
  total$ = this.cartService.total$;
  shippingMethods$ = this.shippingService.getMethods();
  
  processCheckout(data: CheckoutData): Observable<Order> {
    return this.validateCart().pipe(
      switchMap(() => this.calculateShipping(data.shippingMethod)),
      switchMap(shipping => this.processPayment(data.payment, shipping)),
      switchMap(payment => this.createOrder({ ...data, payment })),
      tap(() => this.cartService.clear())
    );
  }
  
  private validateCart(): Observable<boolean> {
    return this.cart$.pipe(
      take(1),
      map(items => items.length > 0),
      tap(valid => { if (!valid) throw new Error('Cart is empty'); })
    );
  }
  
  private calculateShipping(method: string): Observable<number> {
    return this.shippingService.calculate(method);
  }
  
  private processPayment(payment: PaymentInfo, shipping: number): Observable<PaymentResult> {
    return this.total$.pipe(
      take(1),
      switchMap(total => this.paymentService.charge({
        ...payment,
        amount: total + shipping
      }))
    );
  }
  
  private createOrder(data: OrderData): Observable<Order> {
    return this.orderService.create(data);
  }
}

// Component uses facade instead of multiple services
@Component({...})
export class CheckoutComponent {
  private facade = inject(CheckoutFacade);
  
  cart$ = this.facade.cart$;
  total$ = this.facade.total$;
  
  checkout(data: CheckoutData) {
    this.facade.processCheckout(data).subscribe({
      next: order => this.router.navigate(['/order-confirmation', order.id]),
      error: err => this.showError(err)
    });
  }
}

Pattern 5: Error Handling Strategy

Global Error Handler

// core/handlers/global-error.handler.ts
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
  private logger = inject(LoggerService);
  private notification = inject(NotificationService);
  
  handleError(error: Error | HttpErrorResponse) {
    if (error instanceof HttpErrorResponse) {
      // Server error
      this.handleHttpError(error);
    } else {
      // Client error
      this.handleClientError(error);
    }
  }
  
  private handleHttpError(error: HttpErrorResponse) {
    const message = this.getErrorMessage(error);
    this.notification.showError(message);
    this.logger.error('HTTP Error', { error, url: error.url });
  }
  
  private handleClientError(error: Error) {
    this.notification.showError('An unexpected error occurred');
    this.logger.error('Client Error', { error, stack: error.stack });
  }
  
  private getErrorMessage(error: HttpErrorResponse): string {
    if (error.status === 0) {
      return 'No internet connection';
    } else if (error.status === 401) {
      return 'Session expired. Please login again.';
    } else if (error.status === 403) {
      return 'You do not have permission to perform this action';
    } else if (error.status >= 500) {
      return 'Server error. Please try again later.';
    }
    return error.error?.message || 'An error occurred';
  }
}

HTTP Error Interceptor

// core/interceptors/error.interceptor.ts
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  return next(req).pipe(
    catchError((error: HttpErrorResponse) => {
      if (error.status === 401) {
        // Redirect to login
        inject(Router).navigate(['/login']);
      }
      return throwError(() => error);
    })
  );
};

Pattern 6: Feature Flags

Overview

Control feature visibility without deploying new code.

// core/services/feature-flag.service.ts
@Injectable({ providedIn: 'root' })
export class FeatureFlagService {
  private flags = signal<Record<string, boolean>>({
    'new-dashboard': false,
    'beta-checkout': true,
    'admin-analytics': false
  });
  
  isEnabled(feature: string): boolean {
    return this.flags()[feature] ?? false;
  }
  
  enable(feature: string) {
    this.flags.update(flags => ({ ...flags, [feature]: true }));
  }
  
  disable(feature: string) {
    this.flags.update(flags => ({ ...flags, [feature]: false }));
  }
}

// Usage in component
@Component({
  template: `
    @if (showNewDashboard()) {
      <app-new-dashboard />
    } @else {
      <app-old-dashboard />
    }
  `
})
export class DashboardComponent {
  private featureFlags = inject(FeatureFlagService);
  showNewDashboard = computed(() => this.featureFlags.isEnabled('new-dashboard'));
}

// Usage in routes
{
  path: 'beta',
  loadComponent: () => import('./beta.component'),
  canActivate: [() => inject(FeatureFlagService).isEnabled('beta-features')]
}

Pattern 7: Caching Strategy

Service-Level Cache

// core/services/cache.service.ts
@Injectable({ providedIn: 'root' })
export class CacheService {
  private cache = new Map<string, { data: any; timestamp: number }>();
  private TTL = 5 * 60 * 1000; // 5 minutes
  
  get<T>(key: string): T | null {
    const cached = this.cache.get(key);
    if (!cached) return null;
    
    if (Date.now() - cached.timestamp > this.TTL) {
      this.cache.delete(key);
      return null;
    }
    
    return cached.data as T;
  }
  
  set(key: string, data: any) {
    this.cache.set(key, { data, timestamp: Date.now() });
  }
  
  clear(key?: string) {
    if (key) {
      this.cache.delete(key);
    } else {
      this.cache.clear();
    }
  }
}

// Usage in service
@Injectable({ providedIn: 'root' })
export class ProductService {
  private cache = inject(CacheService);
  private api = inject(ApiService);
  
  getProducts(): Observable<Product[]> {
    const cached = this.cache.get<Product[]>('products');
    if (cached) {
      return of(cached);
    }
    
    return this.api.get<Product[]>('products').pipe(
      tap(products => this.cache.set('products', products))
    );
  }
}

Pattern 8: Loading States

Unified Loading Pattern

// core/models/loading-state.interface.ts
export interface LoadingState<T> {
  loading: boolean;
  data: T | null;
  error: string | null;
}

// Feature service
@Injectable({ providedIn: 'root' })
export class ProductService {
  private state = signal<LoadingState<Product[]>>({
    loading: false,
    data: null,
    error: null
  });
  
  state$ = computed(() => this.state());
  
  loadProducts() {
    this.state.update(s => ({ ...s, loading: true, error: null }));
    
    this.api.get<Product[]>('products').subscribe({
      next: data => this.state.set({ loading: false, data, error: null }),
      error: err => this.state.set({ loading: false, data: null, error: err.message })
    });
  }
}

// Component
@Component({
  template: `
    @if (state().loading) {
      <app-loading-spinner />
    } @else if (state().error) {
      <app-error-message [message]="state().error" />
    } @else if (state().data) {
      @for (product of state().data; track product.id) {
        <app-product-card [product]="product" />
      }
    }
  `
})
export class ProductListComponent {
  private service = inject(ProductService);
  state = this.service.state$;
  
  ngOnInit() {
    this.service.loadProducts();
  }
}

Team Structure Patterns

Pattern 1: Feature Teams

  • Each team owns complete features
  • Vertical slice (UI + API + DB)
  • Autonomous deployment
  • Best for: Medium to large teams (10-50+)

Pattern 2: Layer Teams

  • Frontend team, backend team
  • Horizontal slice
  • Coordinated deployment
  • Best for: Small teams (5-10)

Pattern 3: Component Teams

  • Shared component library team
  • Feature teams consume components
  • Hybrid approach
  • Best for: Large organizations (50+)

Summary

Enterprise patterns ensure:

  • Consistent codebase across large teams
  • Predictable structure for new developers
  • Separation of concerns
  • Testable, maintainable code
  • Scalability from day one

Key Takeaway: Patterns create consistency. Consistency enables scale.