| |

Angular 131 🅰️ Capstone — Full Application from Scratch

⏱️ Estimated read: 20 min | |

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

FolderPurpose
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

CommandPurpose
ng new taskboard --style=css --routing=trueCreate the workspace
ng serveDevelopment server
ng build --configuration productionProduction build
ng testRun tests
ng lintLint the code

Core Services

ServicePurpose
AuthServiceToken management, login, logout
TaskServiceHTTP operations
TaskFacadeState management for tasks

Guards and Interceptors

FunctionPurpose
authGuardProtect the shell
authInterceptorAttach token, handle 401

State Management

StateWhere
LocalComponent signals
SharedService signals
ServerService with HTTP and cache
Derivedcomputed() signals

Testing

Test TypeTarget
UnitFacade
ComponentPresentational components
IntegrationSmart 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

PitfallWhy It HappensFix
State inconsistencyState duplicated in componentsMove shared state to a service
Untestable componentsComponents fetch their own dataUse smart/presentational split
Feature entanglementNo boundary enforcementUse tags and lint rules
Large initial bundleNo lazy loadingUse loadChildren
Missing error handlingSubscribe without error callbackAdd error handler
Token not attachedInterceptor not registeredAdd to provideHttpClient
Auth guard bypassedGuard not on the shell routeAdd canActivate to the shell
Form validation missingValidators not configuredAdd 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

ItemValue
Workspaceng new taskboard --style=css --routing=true
Structurecore/, shared/, features/
StateSignals in services
FacadeHides complexity behind a clean API
ComponentsSmart (fetch) and presentational (render)
RoutingFeature routes, lazy loaded
FormsReactive with validation
AuthService, guard, interceptor
HTTPHttpClient with interceptor
TestingUnit, component, integration
Buildng 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/, and features/ 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 authGuard prevents unauthenticated access. The authInterceptor attaches 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 production produces 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.


Was this helpful?


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!