This closing module synthesizes every layer from Modules 1–5 — standalone architecture, typed component binding, dependency injection, and Signals/RxJS — into a strongly typed router configuration and a single cohesive capstone feature: a real-time Enterprise Operations Board.


Strongly Typed Routing with Lazy Loading

loadComponent lazy-loads a single standalone component's JavaScript chunk only when its route is navigated to — the component, and everything it exclusively imports, is excluded from the initial bundle entirely. loadChildren does the same for an entire sub-route tree, typically exported as a Routes array from a feature's own .routes.ts file.

src/app/app.routes.tstypescript
import { Routes } from '@angular/router';
import { requiresActiveSessionGuard } from '@core/guards/requires-active-session.guard';

export const routes: Routes = [
  {
    path: '',
    pathMatch: 'full',
    redirectTo: 'operations',
  },
  {
    path: 'login',
    // loadComponent: fetches login.component.ts's chunk only when '/login' is visited
    loadComponent: () => import('./features/auth/login/login.component').then((m) => m.LoginComponent),
  },
  {
    path: 'operations',
    canActivate: [requiresActiveSessionGuard],
    // loadChildren: fetches the ENTIRE operations-board route tree as one chunk,
    // only reachable past the session guard below
    loadChildren: () =>
      import('./features/operations-board/operations-board.routes').then((m) => m.OPERATIONS_BOARD_ROUTES),
  },
  {
    path: '**',
    loadComponent: () => import('./shared/components/not-found/not-found.component').then((m) => m.NotFoundComponent),
  },
];
src/app/features/operations-board/operations-board.routes.tstypescript
import { Routes } from '@angular/router';

export const OPERATIONS_BOARD_ROUTES: Routes = [
  {
    path: '',
    loadComponent: () =>
      import('./operations-board.component').then((m) => m.OperationsBoardComponent),
  },
  {
    path: 'task/:taskId',
    // withComponentInputBinding() from Module 1 means `taskId` binds
    // directly to a matching @Input() on TaskDetailComponent — no
    // manual ActivatedRoute.paramMap subscription required.
    loadComponent: () =>
      import('./task-detail/task-detail.component').then((m) => m.TaskDetailComponent),
  },
];

An Enterprise-Grade Async Route Guard

src/app/core/guards/requires-active-session.guard.tstypescript
import { inject } from '@angular/core';
import type { CanActivateFn } from '@angular/router';
import { Router } from '@angular/router';
import { catchError, map, of } from 'rxjs';
import { SessionService } from '../services/session.service';

// CanActivateFn may return boolean, UrlTree, or an Observable/Promise
// of either — the router awaits it before completing navigation.
export const requiresActiveSessionGuard: CanActivateFn = (route, state) => {
  const session = inject(SessionService);
  const router = inject(Router);

  return session.verifyToken$().pipe(
    map((isValid) => {
      if (isValid) return true;
      // Returning a UrlTree redirects without a second, separate navigation call.
      return router.createUrlTree(['/login'], { queryParams: { redirectTo: state.url } });
    }),
    catchError(() => of(router.createUrlTree(['/login'])))
  );
};

Capstone: Real-Time Enterprise Operations Board

The capstone combines a standalone layout wrapper, a Signal-backed reactive data grid fed by an HTTP service (Module 4), and a fully validated Reactive Form for creating new tasks — with isolated, typed error handling throughout.

features/operations-board/operations-board-layout.component.tstypescript
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';

@Component({
  selector: 'app-operations-board-layout',
  standalone: true,
  imports: [RouterOutlet],
  template: `
    <div class="layout">
      <header class="layout__header">
        <h1>Enterprise Operations Board</h1>
      </header>
      <main class="layout__content">
        <router-outlet />
      </main>
    </div>
  `,
  styles: [`
    .layout { display: flex; flex-direction: column; min-height: 100vh; }
    .layout__header { padding: 1rem 1.5rem; border-bottom: 1px solid #e5e7eb; }
    .layout__content { flex: 1; padding: 1.5rem; }
  `],
})
export class OperationsBoardLayoutComponent {}
features/operations-board/operations-board.component.tstypescript
import { Component, inject, signal, computed } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, FormBuilder, Validators, type FormGroup } from '@angular/forms';
import { catchError, finalize, of, tap } from 'rxjs';
import { OperationsService } from './services/operations.service';
import type { OperationTask, TaskStatus } from './models/operation-task.model';

interface CreateTaskForm {
  title: string;
  owner: string;
}

@Component({
  selector: 'app-operations-board',
  standalone: true,
  imports: [ReactiveFormsModule],
  templateUrl: './operations-board.component.html',
  styleUrl: './operations-board.component.css',
})
export class OperationsBoardComponent {
  private readonly operationsService = inject(OperationsService);
  private readonly fb = inject(FormBuilder);

  // ── Real-time data grid, HTTP-backed, exposed as a signal ──
  private readonly refreshTrigger = signal(0);
  readonly statusFilter = signal<TaskStatus | 'all'>('all');
  readonly loadError = signal<string | null>(null);
  readonly isLoading = signal(false);

  readonly tasks = toSignal(
    this.operationsService.getTasks().pipe(
      tap(() => this.loadError.set(null)),
      catchError((err: unknown) => {
        this.loadError.set('Unable to load operations tasks. Please retry.');
        console.error(err);
        return of<OperationTask[]>([]);
      })
    ),
    { initialValue: [] as OperationTask[] }
  );

  readonly visibleTasks = computed(() => {
    const filterValue = this.statusFilter();
    const allTasks = this.tasks();
    return filterValue === 'all' ? allTasks : allTasks.filter((t) => t.status === filterValue);
  });

  // ── Reactive Form: strongly typed FormGroup with Validators ──
  readonly createTaskForm: FormGroup<{
    title: import('@angular/forms').FormControl<string>;
    owner: import('@angular/forms').FormControl<string>;
  }> = this.fb.nonNullable.group({
    title: this.fb.nonNullable.control('', [Validators.required, Validators.minLength(3), Validators.maxLength(120)]),
    owner: this.fb.nonNullable.control('', [Validators.required, Validators.email]),
  });

  readonly submitError = signal<string | null>(null);
  readonly isSubmitting = signal(false);

  get titleErrorMessage(): string | null {
    const control = this.createTaskForm.controls.title;
    if (!control.touched || control.valid) return null;
    if (control.hasError('required')) return 'Title is required.';
    if (control.hasError('minlength')) return 'Title must be at least 3 characters.';
    if (control.hasError('maxlength')) return 'Title cannot exceed 120 characters.';
    return 'Invalid title.';
  }

  get ownerErrorMessage(): string | null {
    const control = this.createTaskForm.controls.owner;
    if (!control.touched || control.valid) return null;
    if (control.hasError('required')) return 'Owner email is required.';
    if (control.hasError('email')) return 'Enter a valid email address.';
    return 'Invalid owner.';
  }

  onStatusFilterChange(event: Event): void {
    const select = event.target as HTMLSelectElement;
    this.statusFilter.set(select.value as TaskStatus | 'all');
  }

  onCreateTask(): void {
    if (this.createTaskForm.invalid) {
      this.createTaskForm.markAllAsTouched();
      return;
    }

    this.isSubmitting.set(true);
    this.submitError.set(null);
    const request: CreateTaskForm = this.createTaskForm.getRawValue();

    this.operationsService
      .createTask(request)
      .pipe(
        tap(() => {
          this.createTaskForm.reset({ title: '', owner: '' });
          this.refreshTrigger.update((n) => n + 1);
        }),
        catchError((err: unknown) => {
          this.submitError.set('Failed to create task. Please try again.');
          console.error(err);
          return of(null);
        }),
        finalize(() => this.isSubmitting.set(false))
      )
      .subscribe();
  }

  onMarkComplete(taskId: string): void {
    this.operationsService.updateStatus(taskId, 'complete').pipe(
      catchError((err: unknown) => {
        this.loadError.set('Failed to update task status.');
        console.error(err);
        return of(null);
      })
    ).subscribe();
  }
}
features/operations-board/operations-board.component.htmlhtml
<section class="board">

  <!-- Filter controls -->
  <div class="board__toolbar">
    <select (change)="onStatusFilterChange($event)" aria-label="Filter by status">
      <option value="all">All statuses</option>
      <option value="queued">Queued</option>
      <option value="in-progress">In Progress</option>
      <option value="blocked">Blocked</option>
      <option value="complete">Complete</option>
    </select>
  </div>

  <p *ngIf="loadError()" class="board__error" role="alert">{{ loadError() }}</p>

  <!-- Reactive data grid, driven entirely by signals -->
  <table class="board__grid">
    <thead>
      <tr><th>Title</th><th>Owner</th><th>Status</th><th>Updated</th><th></th></tr>
    </thead>
    <tbody>
      <tr *ngFor="let task of visibleTasks()">
        <td>{{ task.title }}</td>
        <td>{{ task.owner }}</td>
        <td><span class="status-pill" [attr.data-status]="task.status">{{ task.status }}</span></td>
        <td>{{ task.updatedAt | date: 'short' }}</td>
        <td>
          <button type="button" *ngIf="task.status !== 'complete'" (click)="onMarkComplete(task.id)">
            Mark Complete
          </button>
        </td>
      </tr>
    </tbody>
  </table>

  <!-- Reactive Form: create new task -->
  <form class="board__form" [formGroup]="createTaskForm" (ngSubmit)="onCreateTask()">
    <div class="field">
      <label for="title">Task title</label>
      <input id="title" type="text" formControlName="title" />
      <small class="field__error" *ngIf="titleErrorMessage">{{ titleErrorMessage }}</small>
    </div>

    <div class="field">
      <label for="owner">Owner email</label>
      <input id="owner" type="email" formControlName="owner" />
      <small class="field__error" *ngIf="ownerErrorMessage">{{ ownerErrorMessage }}</small>
    </div>

    <p *ngIf="submitError()" class="board__error" role="alert">{{ submitError() }}</p>

    <button type="submit" [disabled]="isSubmitting()">
      {{ isSubmitting() ? 'Creating...' : 'Create Task' }}
    </button>
  </form>

</section>
features/operations-board/operations-board.component.csscss
.board { display: flex; flex-direction: column; gap: 1.5rem; max-width: 960px; }
.board__toolbar { display: flex; justify-content: flex-end; }
.board__grid { width: 100%; border-collapse: collapse; }
.board__grid th, .board__grid td { text-align: left; padding: 0.6rem 0.75rem; border-bottom: 1px solid #e5e7eb; }
.status-pill { padding: 0.2rem 0.6rem; border-radius: 999px; font-size: 0.75rem; background: #f3f4f6; }
.status-pill[data-status='complete'] { background: #dcfce7; color: #166534; }
.status-pill[data-status='blocked'] { background: #fee2e2; color: #991b1b; }
.board__form { display: flex; flex-direction: column; gap: 1rem; max-width: 420px; }
.field { display: flex; flex-direction: column; gap: 0.25rem; }
.field__error { color: #b91c1c; font-size: 0.8rem; }
.board__error { color: #b91c1c; font-weight: 600; }

What the Capstone Demonstrates from Every Prior Module

  • Module 2 (Standalone): every piece is a standalone component importing exactly what it needs — RouterOutlet, ReactiveFormsModule — with no NgModule wiring
  • Module 3 (Binding): all four binding types appear — interpolation for the grid, property binding for [disabled]/[formGroup], event binding for (click)/(ngSubmit), and formControlName as the Reactive Forms equivalent of two-way binding
  • Module 4 (DI): OperationsService is injected via inject() and consumed as a typed, root-scoped singleton
  • Module 5 (Signals + RxJS): toSignal() bridges the HTTP Observable into a signal; computed() derives the filtered view; the create-task flow stays in RxJS because it's a one-shot async action with catchError/finalize semantics that map cleanly onto Observables

Production Architecture Cheat Sheet

Paradigm / PrimitiveOption AOption BProduction Guidance
Component architectureNgModule (declarations/imports/exports)Standalone (standalone: true, per-component imports)Standalone for all new code; keep NgModules only in legacy areas not yet migrated (Module 2)
Change detection strategyDefault (checks the whole tree on every zone.js tick)OnPush (checks only on @Input reference change, event, or async/signal trigger)OnPush by default on every component; pair with immutable state updates and signals/async pipe for correctness
Local/UI stateRxJS BehaviorSubjectSignal (signal(), computed())Signals — simpler API, no manual unsubscription, integrates natively with OnPush and templates
Async data streams (HTTP, WebSocket, router events)RxJS Observable + operatorsSignal onlyRxJS — operators like switchMap/debounceTime/retry have no direct signal equivalent; bridge to a signal with toSignal() at the consumption boundary
Dependency accessConstructor injectioninject() functionConstructor injection in classes with few dependencies for readability; inject() mandatory in functional guards/interceptors/resolvers and useful for field-initializer patterns
Route loadingEager (imported directly in route config)Lazy (loadComponent / loadChildren)Lazy-load every feature route; eager-load only the shell/layout that must render before first paint
Form handlingTemplate-driven ([(ngModel)])Reactive Forms (FormBuilder, FormGroup, Validators)Reactive Forms for anything with validation logic, dynamic fields, or unit-testable form state — Template-driven only for the simplest single-field cases

Module 6 Checkpoint

  1. Q1. Why does the guard return a UrlTree instead of calling router.navigate() and returning false?

  2. Q2. In the capstone, why does the create-task flow stay in RxJS (via catchError/finalize) rather than being converted to a signal like the task list?