Angular 131 🅰️ Capstone — Full Application from Scratch
This chapter builds a complete Angular application from an empty directory to a deployed, tested, documented production build. It is the capstone of the series, and it uses every technique covered in the preceding chapters: the CLI and Cargo-style project setup, standalone components and the modern build system, signals for state, the new control flow syntax, routing with lazy loading, HTTP with interceptors, forms with validation, guards for authentication, feature-based architecture with facades, error handling, testing, and deployment. Each step produces a working increment, and each increment is verified before the next begins.
The application is a task management dashboard. It has an authentication flow, a task list with filtering and pagination, a task detail view, a form for creating and editing tasks, a settings page, and a shell layout with navigation. It is small enough to build in a single chapter but complete enough to demonstrate every pattern that a real Angular application uses.
This chapter is structured as a build session rather than a reference. Each part adds files, runs commands, and verifies the result. By the end, the application builds, tests pass, and the production bundle is ready to deploy.
Key point: Build incrementally. Create the workspace, add the shell, add routing, add a feature with its facade, add forms and validation, add authentication and guards, add HTTP with interceptors, add tests, and add deployment configuration. Verify the build and tests after each part. The result is a production-ready application that demonstrates the full Angular architecture.
Why build a capstone
The integration problem. Individual chapters teach individual features. A capstone shows how the features combine: how routing interacts with guards, how facades interact with signals, how forms interact with HTTP, and how the build system interacts with everything. The integration is where the real learning happens.
The decision problem. Every chapter presented choices: signals or RxJS, standalone or NgModule, feature folders or flat structure. The capstone makes the choices and shows the consequences. It is a reference implementation that can be adapted to other applications.
The verification problem. A capstone that builds, tests, and deploys is proof that the patterns work together. It is not a diagram; it is a working application.
The onboarding problem. A new developer can read the capstone to understand how the team builds Angular applications. It is the reference for conventions, structure, and tooling.
The completeness problem. The capstone covers the full lifecycle: setup, architecture, features, state, forms, authentication, HTTP, testing, and deployment. Nothing is left as an exercise.
a. Project setup
The application starts with the Angular CLI. The workspace is created with the modern builder, standalone components by default, and strict TypeScript.
ng new taskboard --style=css --ssr=false --routing=true
cd taskboard
The generated project uses the application builder, standalone components, and the new control flow syntax. The app.config.ts file contains the application’s providers.
The package.json scripts are updated to include linting, testing, and formatting:
{
"scripts": {
"start": "ng serve",
"build": "ng build",
"test": "ng test",
"lint": "ng lint",
"format": "prettier --write \"src/**/*.{ts,html,css,json}\""
}
}
ESLint and Prettier are configured for the project, and the angular.json file is adjusted to enable strictTemplates and the production budget.
The folder structure follows the feature-based pattern:
src/app/
├── core/
│ ├── services/
│ ├── interceptors/
│ ├── guards/
│ └── models/
├── shared/
│ ├── components/
│ ├── directives/
│ └── pipes/
├── features/
│ ├── auth/
│ ├── tasks/
│ └── settings/
├── app.routes.ts
├── app.config.ts
└── app.component.ts
b. The shell and navigation
The shell is the layout that surrounds every page. It has a header with the application name and the user’s menu, a sidebar with navigation links, and a main content area with the router outlet.
@Component({
selector: 'app-shell',
imports: [RouterOutlet, RouterLink, RouterLinkActive],
template: `
<header class="app-header">
<span class="app-title">Taskboard</span>
<nav class="app-nav">
<a routerLink="/tasks" routerLinkActive="active">Tasks</a>
<a routerLink="/settings" routerLinkActive="active">Settings</a>
</nav>
<button (click)="logout()">Sign out</button>
</header>
<main class="app-main">
<router-outlet />
</main>
`,
})
export class ShellComponent {
private readonly auth = inject(AuthFacade);
logout(): void {
this.auth.logout();
}
}
The shell is a standalone component. It imports RouterOutlet, RouterLink, and RouterLinkActive. The routerLinkActive directive adds a class to the active link, and the @if and @for blocks are used in the feature components that follow.
c. Routing with lazy loading
The application’s routes are defined in app.routes.ts. Each feature owns its routes, and the application references them with loadChildren.
export const routes: Routes = [
{
path: 'login',
loadComponent: () =>
import('./features/auth/login.component').then((m) => m.LoginComponent),
},
{
path: '',
component: ShellComponent,
canActivate: [authGuard],
children: [
{ path: '', redirectTo: 'tasks', pathMatch: 'full' },
{
path: 'tasks',
loadChildren: () =>
import('./features/tasks/tasks.routes').then((m) => m.TASKS_ROUTES),
},
{
path: 'settings',
loadComponent: () =>
import('./features/settings/settings.component').then(
(m) => m.SettingsComponent
),
},
],
},
{ path: '**', redirectTo: '' },
];
The authGuard protects the shell and its children. The login route is outside the shell, so an unauthenticated user can reach it. The ** route redirects unknown paths to the shell.
d. The tasks feature
The tasks feature is the core of the application. It has a list view with filtering and pagination, a detail view, and a form for creating and editing tasks.
The feature folder contains the components, the service, the facade, and the routes:
features/tasks/
├── components/
│ ├── task-list.component.ts
│ ├── task-card.component.ts
│ ├── task-detail.component.ts
│ └── task-form.component.ts
├── services/
│ └── task.service.ts
├── models/
│ └── task.model.ts
├── tasks.facade.ts
└── tasks.routes.ts
The Task model:
export interface Task {
id: string;
title: string;
description: string;
status: 'todo' | 'in-progress' | 'done';
priority: 'low' | 'medium' | 'high';
dueDate: string | null;
createdAt: string;
}
The TaskService handles HTTP:
@Injectable({ providedIn: 'root' })
export class TaskService {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/tasks';
getAll(): Observable<Task[]> {
return this.http.get<Task[]>(this.baseUrl);
}
getById(id: string): Observable<Task> {
return this.http.get<Task>(`${this.baseUrl}/${id}`);
}
create(task: Partial<Task>): Observable<Task> {
return this.http.post<Task>(this.baseUrl, task);
}
update(id: string, task: Partial<Task>): Observable<Task> {
return this.http.put<Task>(`${this.baseUrl}/${id}`, task);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
}
The TaskFacade holds the state and exposes it as signals:
@Injectable({ providedIn: 'root' })
export class TaskFacade {
private readonly taskService = inject(TaskService);
private readonly tasks = signal<Task[]>([]);
private readonly loading = signal(false);
private readonly error = signal<string | null>(null);
readonly tasks$ = this.tasks.asReadonly();
readonly loading$ = this.loading.asReadonly();
readonly error$ = this.error.asReadonly();
readonly filteredTasks = computed(() => {
const query = this.filterQuery();
return this.tasks().filter((task) =>
task.title.toLowerCase().includes(query.toLowerCase())
);
});
private readonly filterQuery = signal('');
load(): void {
this.loading.set(true);
this.error.set(null);
this.taskService.getAll().subscribe({
next: (tasks) => {
this.tasks.set(tasks);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message);
this.loading.set(false);
},
});
}
setFilter(query: string): void {
this.filterQuery.set(query);
}
create(task: Partial<Task>): void {
this.taskService.create(task).subscribe((created) => {
this.tasks.update((current) => [...current, created]);
});
}
update(id: string, task: Partial<Task>): void {
this.taskService.update(id, task).subscribe((updated) => {
this.tasks.update((current) =>
current.map((t) => (t.id === id ? updated : t))
);
});
}
delete(id: string): void {
this.taskService.delete(id).subscribe(() => {
this.tasks.update((current) => current.filter((t) => t.id !== id));
});
}
}
The TaskListComponent is the smart component. It uses the facade and renders the list with the new control flow syntax.
@Component({
selector: 'app-task-list',
imports: [TaskCardComponent],
template: `
<div class="toolbar">
<input
type="search"
placeholder="Filter tasks"
(input)="onFilter($event)" />
<a routerLink="/tasks/new" class="btn">New task</a>
</div>
@if (facade.loading$()) {
<p>Loading...</p>
} @else if (facade.error$()) {
<p class="error">{{ facade.error$() }}</p>
} @else {
@for (task of facade.filteredTasks(); track task.id) {
<app-task-card
[task]="task"
(toggle)="onToggle($event)"
(delete)="onDelete($event)" />
} @empty {
<p>No tasks match your filter.</p>
}
}
`,
})
export class TaskListComponent implements OnInit {
readonly facade = inject(TaskFacade);
ngOnInit(): void {
this.facade.load();
}
onFilter(event: Event): void {
const value = (event.target as HTMLInputElement).value;
this.facade.setFilter(value);
}
onToggle(id: string): void {
const task = this.facade.tasks$().find((t) => t.id === id);
if (task) {
this.facade.update(id, {
status: task.status === 'done' ? 'todo' : 'done',
});
}
}
onDelete(id: string): void {
this.facade.delete(id);
}
}
The TaskCardComponent is presentational. It receives the task and emits events.
@Component({
selector: 'app-task-card',
template: `
<div class="card" [class.done]="task().status === 'done'">
<h3>{{ task().title }}</h3>
<p>{{ task().description }}</p>
<span class="status">{{ task().status }}</span>
<span class="priority">{{ task().priority }}</span>
<button (click)="toggle.emit(task().id)">Toggle</button>
<button (click)="delete.emit(task().id)">Delete</button>
</div>
`,
})
export class TaskCardComponent {
readonly task = input.required<Task>();
readonly toggle = output<string>();
readonly delete = output<string>();
}
The TaskFormComponent handles creation and editing with validation.
@Component({
selector: 'app-task-form',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<label>
Title
<input formControlName="title" />
</label>
@if (form.controls.title.invalid && form.controls.title.touched) {
<p class="error">Title is required</p>
}
<label>
Description
<textarea formControlName="description"></textarea>
</label>
<label>
Priority
<select formControlName="priority">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</label>
<button type="submit" [disabled]="form.invalid">Save</button>
</form>
`,
})
export class TaskFormComponent {
private readonly facade = inject(TaskFacade);
private readonly router = inject(Router);
readonly form = new FormGroup({
title: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(3)],
}),
description: new FormControl('', { nonNullable: true }),
priority: new FormControl<'low' | 'medium' | 'high'>('medium', {
nonNullable: true,
}),
});
onSubmit(): void {
if (this.form.invalid) return;
this.facade.create(this.form.getRawValue());
this.router.navigate(['/tasks']);
}
}
The feature’s routes:
export const TASKS_ROUTES: Routes = [
{ path: '', component: TaskListComponent },
{ path: 'new', component: TaskFormComponent },
{ path: ':id', component: TaskDetailComponent },
{ path: ':id/edit', component: TaskFormComponent },
];
e. Authentication and guards
The authentication feature has a login form, a service that holds the token, and a guard that protects the shell.
The AuthService manages the token:
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
private readonly token = signal<string | null>(null);
readonly token$ = this.token.asReadonly();
readonly isAuthenticated = computed(() => this.token() !== null);
login(credentials: { email: string; password: string }): Observable<void> {
return this.http
.post<{ token: string }>('/api/auth/login', credentials)
.pipe(
tap((response) => {
this.token.set(response.token);
localStorage.setItem('token', response.token);
}),
map(() => void 0)
);
}
logout(): void {
this.token.set(null);
localStorage.removeItem('token');
}
restore(): void {
const stored = localStorage.getItem('token');
if (stored) {
this.token.set(stored);
}
}
}
The authGuard checks the authentication state:
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isAuthenticated()) {
return true;
}
return router.parseUrl('/login');
};
The LoginComponent is the form that authenticates the user:
@Component({
selector: 'app-login',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<label>
Email
<input type="email" formControlName="email" />
</label>
<label>
Password
<input type="password" formControlName="password" />
</label>
@if (error()) {
<p class="error">{{ error() }}</p>
}
<button type="submit" [disabled]="form.invalid">Sign in</button>
</form>
`,
})
export class LoginComponent {
private readonly auth = inject(AuthService);
private readonly router = inject(Router);
readonly error = signal<string | null>(null);
readonly form = new FormGroup({
email: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.email],
}),
password: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(8)],
}),
});
onSubmit(): void {
if (this.form.invalid) return;
this.auth.login(this.form.getRawValue()).subscribe({
next: () => this.router.navigate(['/tasks']),
error: (err) => this.error.set(err.message),
});
}
}
f. HTTP interceptors
The authentication interceptor attaches the token to every outgoing request and handles 401 responses by redirecting to login.
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const router = inject(Router);
const token = auth.token$();
const authReq = token
? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
: req;
return next(authReq).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
auth.logout();
router.navigate(['/login']);
}
return throwError(() => error);
})
);
};
The interceptor is registered in app.config.ts:
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
],
};
g. Testing
The application has three kinds of tests: unit tests for the facade, component tests for the presentational components, and integration tests for the smart components.
The facade test verifies the state transitions:
describe('TaskFacade', () => {
let facade: TaskFacade;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
TaskFacade,
provideHttpClient(),
provideHttpClientTesting(),
],
});
facade = TestBed.inject(TaskFacade);
httpMock = TestBed.inject(HttpTestingController);
});
it('loads tasks', () => {
facade.load();
const req = httpMock.expectOne('/api/tasks');
req.flush([{ id: '1', title: 'Test', status: 'todo' }]);
expect(facade.tasks$().length).toBe(1);
expect(facade.loading$()).toBe(false);
});
});
The component test verifies the presentational component:
describe('TaskCardComponent', () => {
it('renders the task title', () => {
const fixture = TestBed.createComponent(TaskCardComponent);
fixture.componentRef.setInput('task', {
id: '1',
title: 'Test task',
status: 'todo',
priority: 'medium',
description: '',
dueDate: null,
createdAt: '',
});
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h3')?.textContent).toContain('Test task');
});
});
The integration test verifies that the smart component loads and renders the list:
describe('TaskListComponent', () => {
it('renders tasks after loading', () => {
const fixture = TestBed.createComponent(TaskListComponent);
const httpMock = TestBed.inject(HttpTestingController);
fixture.detectChanges();
const req = httpMock.expectOne('/api/tasks');
req.flush([
{ id: '1', title: 'Task 1', status: 'todo', priority: 'low', description: '', dueDate: null, createdAt: '' },
]);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Task 1');
});
});
Complete Example Session
# ============================================
# PART 1: CREATE THE WORKSPACE
# ============================================
ng new taskboard --style=css --ssr=false --routing=true
cd taskboard
npm run lint
npm test
# ============================================
# PART 2: CREATE THE FOLDER STRUCTURE
# ============================================
mkdir -p src/app/core/{services,interceptors,guards,models}
mkdir -p src/app/shared/{components,directives,pipes}
mkdir -p src/app/features/{auth,tasks,settings}
mkdir -p src/app/features/tasks/{components,services,models}
// ============================================
// PART 3: TASK MODEL
// ============================================
export interface Task {
id: string;
title: string;
description: string;
status: 'todo' | 'in-progress' | 'done';
priority: 'low' | 'medium' | 'high';
dueDate: string | null;
createdAt: string;
}
// ============================================
// PART 4: TASK SERVICE
// ============================================
@Injectable({ providedIn: 'root' })
export class TaskService {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/tasks';
getAll(): Observable<Task[]> {
return this.http.get<Task[]>(this.baseUrl);
}
create(task: Partial<Task>): Observable<Task> {
return this.http.post<Task>(this.baseUrl, task);
}
update(id: string, task: Partial<Task>): Observable<Task> {
return this.http.put<Task>(`${this.baseUrl}/${id}`, task);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
}
// ============================================
// PART 5: TASK FACADE
// ============================================
@Injectable({ providedIn: 'root' })
export class TaskFacade {
private readonly taskService = inject(TaskService);
private readonly tasks = signal<Task[]>([]);
private readonly filterQuery = signal('');
readonly tasks$ = this.tasks.asReadonly();
readonly loading$ = signal(false);
readonly error$ = signal<string | null>(null);
readonly filteredTasks = computed(() => {
const query = this.filterQuery().toLowerCase();
return this.tasks().filter((t) =>
t.title.toLowerCase().includes(query)
);
});
load(): void {
this.loading$.set(true);
this.taskService.getAll().subscribe({
next: (tasks) => {
this.tasks.set(tasks);
this.loading$.set(false);
},
error: (err) => {
this.error$.set(err.message);
this.loading$.set(false);
},
});
}
setFilter(query: string): void {
this.filterQuery.set(query);
}
create(task: Partial<Task>): void {
this.taskService.create(task).subscribe((created) => {
this.tasks.update((current) => [...current, created]);
});
}
}
// ============================================
// PART 6: TASK LIST COMPONENT
// ============================================
@Component({
selector: 'app-task-list',
imports: [TaskCardComponent, RouterLink],
template: `
<div class="toolbar">
<input type="search" placeholder="Filter tasks" (input)="onFilter($event)" />
<a routerLink="/tasks/new" class="btn">New task</a>
</div>
@if (facade.loading$()) {
<p>Loading...</p>
} @else if (facade.error$()) {
<p class="error">{{ facade.error$() }}</p>
} @else {
@for (task of facade.filteredTasks(); track task.id) {
<app-task-card [task]="task" (toggle)="onToggle($event)" (delete)="onDelete($event)" />
} @empty {
<p>No tasks match your filter.</p>
}
}
`,
})
export class TaskListComponent implements OnInit {
readonly facade = inject(TaskFacade);
ngOnInit(): void {
this.facade.load();
}
onFilter(event: Event): void {
this.facade.setFilter((event.target as HTMLInputElement).value);
}
onToggle(id: string): void { /* ... */ }
onDelete(id: string): void { this.facade.delete(id); }
}
// ============================================
// PART 7: AUTH SERVICE AND GUARD
// ============================================
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly token = signal<string | null>(null);
readonly isAuthenticated = computed(() => this.token() !== null);
login(credentials: { email: string; password: string }): Observable<void> { /* ... */ }
logout(): void { this.token.set(null); }
}
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.isAuthenticated() ? true : router.parseUrl('/login');
};
// ============================================
// PART 8: HTTP INTERCEPTOR
// ============================================
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const token = auth.token$();
const authReq = token
? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
: req;
return next(authReq);
};
// ============================================
// PART 9: ROUTES AND CONFIG
// ============================================
export const routes: Routes = [
{ path: 'login', loadComponent: () => import('./features/auth/login.component').then(m => m.LoginComponent) },
{
path: '',
component: ShellComponent,
canActivate: [authGuard],
children: [
{ path: '', redirectTo: 'tasks', pathMatch: 'full' },
{ path: 'tasks', loadChildren: () => import('./features/tasks/tasks.routes').then(m => m.TASKS_ROUTES) },
],
},
];
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
],
};
// ============================================
// PART 10: TESTS AND BUILD
// ============================================
// Unit test the facade
// Component test the card
// Integration test the list
ng test
ng build --configuration production
These ten parts cover workspace creation, folder structure, the task model, the task service, the task facade, the task list component, authentication and guards, the HTTP interceptor, routes and configuration, and tests and build.
Quick Reference
Project Structure
| Folder | Purpose |
|---|---|
core/ | Singletons: auth, interceptors, guards |
shared/ | Reusable UI, directives, pipes |
features/auth/ | Login and authentication |
features/tasks/ | Task list, detail, form |
features/settings/ | Settings page |
Build Commands
| Command | Purpose |
|---|---|
ng new taskboard --style=css --routing=true | Create the workspace |
ng serve | Development server |
ng build --configuration production | Production build |
ng test | Run tests |
ng lint | Lint the code |
Core Services
| Service | Purpose |
|---|---|
AuthService | Token management, login, logout |
TaskService | HTTP operations |
TaskFacade | State management for tasks |
Guards and Interceptors
| Function | Purpose |
|---|---|
authGuard | Protect the shell |
authInterceptor | Attach token, handle 401 |
State Management
| State | Where |
|---|---|
| Local | Component signals |
| Shared | Service signals |
| Server | Service with HTTP and cache |
| Derived | computed() signals |
Testing
| Test Type | Target |
|---|---|
| Unit | Facade |
| Component | Presentational components |
| Integration | Smart components |
Best Practices
✅ Do This:
// Organize by feature
features/tasks/{components,services,models}
// Separate smart and presentational
// Smart: injects facade, fetches state
// Presentational: inputs and outputs
// Use signals for state
readonly tasks$ = this.tasks.asReadonly();
// Use facades
private readonly facade = inject(TaskFacade);
// Use the new control flow syntax
@for (task of facade.filteredTasks(); track task.id) { }
// Lazy load features
loadChildren: () => import('./features/tasks/tasks.routes')
// Test each layer
// Facade: unit test
// Card: component test
// List: integration test
❌ Don’t Do This:
// Fetch data in a presentational component
private http = inject(HttpClient); // ❌
// Put state in components that need to share it
readonly tasks = signal<Task[]>([]); // ❌ if shared
// Import from another feature's internals
import { TaskService } from '../tasks/services/task.service'; // ❌
// Skip the facade
// Component injects three services directly // ❌
// Forget error handling
this.taskService.getAll().subscribe((tasks) => this.tasks.set(tasks)); // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| State inconsistency | State duplicated in components | Move shared state to a service |
| Untestable components | Components fetch their own data | Use smart/presentational split |
| Feature entanglement | No boundary enforcement | Use tags and lint rules |
| Large initial bundle | No lazy loading | Use loadChildren |
| Missing error handling | Subscribe without error callback | Add error handler |
| Token not attached | Interceptor not registered | Add to provideHttpClient |
| Auth guard bypassed | Guard not on the shell route | Add canActivate to the shell |
| Form validation missing | Validators not configured | Add validators to FormControl |
Real-World Examples
1. Smart Component
export class TaskListComponent implements OnInit {
readonly facade = inject(TaskFacade);
ngOnInit(): void { this.facade.load(); }
}
2. Presentational Component
export class TaskCardComponent {
readonly task = input.required<Task>();
readonly toggle = output<string>();
}
3. State Service
@Injectable({ providedIn: 'root' })
export class TaskFacade {
private readonly tasks = signal<Task[]>([]);
readonly tasks$ = this.tasks.asReadonly();
}
4. Facade Method
create(task: Partial<Task>): void {
this.taskService.create(task).subscribe((created) => {
this.tasks.update((current) => [...current, created]);
});
}
5. Auth Guard
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
return auth.isAuthenticated() ? true : inject(Router).parseUrl('/login');
};
6. HTTP Interceptor
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(AuthService).token$();
return next(token
? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
: req);
};
7. Lazy Loaded Route
{
path: 'tasks',
loadChildren: () => import('./features/tasks/tasks.routes')
.then((m) => m.TASKS_ROUTES),
}
8. Control Flow
@for (task of facade.filteredTasks(); track task.id) {
<app-task-card [task]="task" />
} @empty {
<p>No tasks</p>
}
9. Reactive Form
readonly form = new FormGroup({
title: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(3)],
}),
});
10. Test
const req = httpMock.expectOne('/api/tasks');
req.flush([{ id: '1', title: 'Test', status: 'todo' }]);
expect(facade.tasks$().length).toBe(1);
Visual
Application Architecture
┌──────────────────────────────────────────────────────────────┐
│ APP CONFIG │
│ ├── provideRouter(routes) │
│ └── provideHttpClient(withInterceptors([authInterceptor])) │
│ │
│ ROUTES │
│ ├── /login → LoginComponent │
│ └── / → ShellComponent (canActivate: authGuard) │
│ ├── /tasks → TASKS_ROUTES (lazy) │
│ └── /settings → SettingsComponent (lazy) │
└──────────────────────────────────────────────────────────────┘
Feature Structure
┌──────────────────────────────────────────────────────────────┐
│ features/tasks/ │
│ ├── components/ │
│ │ ├── task-list (smart, injects facade) │
│ │ ├── task-card (presentational) │
│ │ ├── task-detail │
│ │ └── task-form │
│ ├── services/ │
│ │ └── task.service (HTTP) │
│ ├── models/ │
│ │ └── task.model │
│ ├── tasks.facade.ts (state) │
│ └── tasks.routes.ts │
└──────────────────────────────────────────────────────────────┘
Data Flow
┌──────────────────────────────────────────────────────────────┐
│ COMPONENT │
│ │ │
│ ▼ reads signal │
│ FACADE │
│ ├── tasks$ = signal │
│ ├── loading$ = signal │
│ └── filteredTasks = computed │
│ │ │
│ ▼ calls method │
│ SERVICE │
│ └── HttpClient → /api/tasks │
│ │ │
│ ▼ response │
│ FACADE updates signal │
│ │ │
│ ▼ │
│ COMPONENT re-renders │
└──────────────────────────────────────────────────────────────┘
Authentication Flow
┌──────────────────────────────────────────────────────────────┐
│ User visits /tasks │
│ │ │
│ ▼ │
│ authGuard checks isAuthenticated() │
│ │ │
│ ├── true ──▶ ShellComponent renders │
│ │ │
│ └── false ──▶ redirect to /login │
│ │ │
│ ▼ │
│ LoginComponent │
│ │ │
│ ▼ │
│ AuthService.login() │
│ │ │
│ ▼ │
│ token stored, navigate to /tasks │
│ │ │
│ ▼ │
│ authInterceptor attaches token │
│ to every request │
└──────────────────────────────────────────────────────────────┘
Testing Pyramid
┌──────────────────────────────────────────────────────────────┐
│ INTEGRATION TESTS │
│ └── Smart components │
│ │
│ COMPONENT TESTS │
│ └── Presentational components │
│ │
│ UNIT TESTS │
│ └── Facades, services, pure functions │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Workspace | ng new taskboard --style=css --routing=true |
| Structure | core/, shared/, features/ |
| State | Signals in services |
| Facade | Hides complexity behind a clean API |
| Components | Smart (fetch) and presentational (render) |
| Routing | Feature routes, lazy loaded |
| Forms | Reactive with validation |
| Auth | Service, guard, interceptor |
| HTTP | HttpClient with interceptor |
| Testing | Unit, component, integration |
| Build | ng build --configuration production |
Key takeaways:
- Build incrementally. Create the workspace, add the shell, add routing, add a feature, add forms, add authentication, add HTTP, add tests, and add deployment. Verify after each step.
- Organize by feature. The
core/,shared/, andfeatures/folders separate singletons, reusable UI, and application functionality. Each feature contains its components, services, models, facade, and routes. - Separate smart and presentational components. Smart components inject facades and fetch state. Presentational components receive inputs and emit outputs. This makes rendering testable and reusable.
- Use signals for state. Local state lives in the component. Shared state lives in a service that exposes it as a read-only signal and provides methods to update it.
- Use facades to hide complexity. A facade injects the services a feature needs and exposes a simplified API. Components inject the facade, not the underlying services.
- Protect routes with guards and interceptors. The
authGuardprevents unauthenticated access. TheauthInterceptorattaches the token and handles 401 responses. - Test each layer. Unit tests verify facades and services. Component tests verify presentational components. Integration tests verify smart components.
- Build for production.
ng build --configuration productionproduces the optimized bundle. The application is ready to deploy.
Remember: This capstone demonstrates that Angular applications are built from composable patterns. The workspace provides the structure, the features provide the functionality, the facade provides the API, the signals provide the state, the guards provide the protection, and the tests provide the confidence. Each pattern was introduced in an earlier chapter, and the capstone shows how they fit together. The result is an application that builds, tests, and deploys — a reference implementation that can be adapted to any domain. The patterns are not rules imposed by Angular; they are the structures that production teams have found to work, and the capstone is the proof that they work together.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!