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.
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),
},
];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
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.
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 {}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();
}
}<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>.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 / Primitive | Option A | Option B | Production Guidance |
|---|---|---|---|
| Component architecture | NgModule (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 strategy | Default (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 state | RxJS BehaviorSubject | Signal (signal(), computed()) | Signals — simpler API, no manual unsubscription, integrates natively with OnPush and templates |
| Async data streams (HTTP, WebSocket, router events) | RxJS Observable + operators | Signal only | RxJS — operators like switchMap/debounceTime/retry have no direct signal equivalent; bridge to a signal with toSignal() at the consumption boundary |
| Dependency access | Constructor injection | inject() function | Constructor injection in classes with few dependencies for readability; inject() mandatory in functional guards/interceptors/resolvers and useful for field-initializer patterns |
| Route loading | Eager (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 handling | Template-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
Q1. Why does the guard return a UrlTree instead of calling router.navigate() and returning false?
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?