Angular Architect
Load Order
Read shared-kernel/SKILL.md first.
Core Competencies
Modern Angular (17+)
- Standalone components by default — NgModules only when integrating with legacy code
- Signals (
signal, computed, effect) as primary reactive primitive
- New control flow:
@if, @for with track, @switch, @defer
inject() function instead of constructor injection in most cases
- Functional guards and resolvers (
CanActivateFn, ResolveFn)
- Deferrable views (
@defer) for lazy-loading below-the-fold UI
Signals vs RxJS — When to Use What
| Use Case |
Primitive |
| Component-local state |
signal() |
| Derived state |
computed() |
| Side effects from state change |
effect() |
| HTTP request |
HttpClient returns Observable — bridge with toSignal() |
| User input streams (debounce, throttle) |
RxJS (fromEvent, debounceTime) |
| WebSocket / SSE |
RxJS |
| Complex event coordination |
RxJS operators |
Bridge: toSignal(observable$) and toObservable(signalRef).
Change Detection Strategy
OnPush everywhere — the cost of not using it compounds
- Signal reads inside a template mark that template for re-check automatically
- With OnPush: component re-renders only when inputs change (reference) or signals it reads update
- Avoid mutation — always produce new references (spread, immer, or signal
.update())
Reactive Forms (Typed)
import { FormBuilder, Validators } from '@angular/forms';
const form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(12)]],
});
// form is FormGroup<{ email: FormControl<string | null>; password: FormControl<string | null> }>
- Never use template-driven forms in production
- Always strongly typed — no
FormGroup<any>
- Validators composed, never inline logic in templates
Lazy Loading
// app.routes.ts
export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () => import('./dashboard/dashboard.component').then(m => m.DashboardComponent),
canActivate: [authGuard],
},
];
loadComponent for standalone components
loadChildren only for legacy NgModule features
- Functional guards via
CanActivateFn, not class-based
SSR + Hydration
provideClientHydration() in app.config.ts enables full app hydration
@defer (on viewport) for incremental hydration of below-the-fold content
- Avoid
document / window access in universal code — guard with isPlatformBrowser
Version Verification (Required First Step)
Before writing Angular code:
- Read
package.json for @angular/core version
- Confirm Angular CLI version matches (
ng version)
- Check
angular.json for builder: @angular-devkit/build-angular:application (esbuild, modern) or browser (webpack, legacy)
- Determine project posture: fully standalone, hybrid, or NgModule-based
- Check for Nx workspace (
nx.json present) — Nx changes file layout and commands
Common Failure Modes
| Symptom |
Root Cause |
| Memory leak over time |
.subscribe() in component without takeUntilDestroyed() or async pipe |
| Signal value not updating |
Used .set() with same reference (mutated object) — use spread or new object |
ExpressionChangedAfterItHasBeenCheckedError |
State changed during the same CD cycle — move to effect() or ngAfterViewInit with care |
| OnPush component not refreshing |
Parent passed mutated object with same reference |
| Infinite loop in effect |
effect() writes to a signal it also reads — split into two signals |
@for performance degraded |
Missing track expression — always provide track item.id |
| SSR hydration mismatch |
Browser-only API used during SSR, or non-deterministic value in template |
| Change detection running constantly |
Event listener triggering CD without OnPush + signal boundary |
Non-Negotiables
strict: true in tsconfig.json
strictTemplates: true in Angular compiler options
- No
any in component public API (inputs, outputs, exposed methods)
- Every HTTP call goes through a service — never in a component
- Every
@for block has a track expression
- Every subscription uses
takeUntilDestroyed() or the async pipe
- Every route has a guard appropriate to its access level
- No logic in templates beyond boolean checks and simple property access
Deliverables
Standalone Component (Modern Shape)
import { Component, inject, signal, computed, input, output } from '@angular/core';
import { UserService } from './user.service';
@Component({
selector: 'app-user-card',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (user(); as u) {
<article>
<h2>{{ u.displayName }}</h2>
<p>{{ subtitle() }}</p>
<button type="button" (click)="edit.emit(u.id)">Edit</button>
</article>
} @else {
<p>Loading…</p>
}
`,
})
export class UserCardComponent {
userId = input.required<string>();
edit = output<string>();
private userService = inject(UserService);
user = computed(() => this.userService.userById(this.userId()));
subtitle = computed(() => {
const u = this.user();
return u ? `${u.role} · Joined ${u.joinedAt.toLocaleDateString()}` : '';
});
}
Service with Signal Store Pattern
import { Injectable, computed, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
private _users = signal<User[]>([]);
private _loading = signal(false);
private _error = signal<string | null>(null);
readonly users = this._users.asReadonly();
readonly loading = this._loading.asReadonly();
readonly error = this._error.asReadonly();
readonly userCount = computed(() => this._users().length);
userById(id: string) {
return computed(() => this._users().find(u => u.id === id));
}
async load(): Promise<void> {
this._loading.set(true);
this._error.set(null);
try {
const data = await firstValueFrom(this.http.get<User[]>('/api/users'));
this._users.set(data);
} catch (err) {
this._error.set(err instanceof Error ? err.message : 'Unknown error');
} finally {
this._loading.set(false);
}
}
}
Functional Guard
import { CanActivateFn, Router } from '@angular/router';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isAuthenticated()) return true;
router.navigate(['/login']);
return false;
};
Performance Checklist
- OnPush change detection on every component
@defer for below-the-fold content
- Route-level lazy loading for every feature area
@for uses track with a stable identifier
- Images use
NgOptimizedImage directive
- Bundle analyzed with
source-map-explorer or Nx webpack-bundle-analyzer
- Lighthouse score measured in CI
Testing Standard
- Unit tests with Jest or Karma + Jasmine (Jest preferred for speed)
- Component tests with Angular Testing Library — test behavior, not implementation
- E2E with Playwright or Cypress
- Mock
HttpClient with provideHttpClientTesting() — never hit real network
Reference Links to Verify
1---2name: angular-architect3description: Use for Angular architecture and implementation — standalone components (Angular 17+), signals, computed, effect, RxJS, NgRx, NgRx Signal Store, dependency injection with inject(), lazy loading with loadComponent and functional guards, SSR with Angular Universal and hydration, change detection strategy (OnPush, signal-driven), reactive and typed forms, control flow (@if, @for, @switch), deferred views (@defer), and Nx monorepo layout. Triggers on mentions of Angular, ng, RxJS, NgRx, signal(), standalone component, @Component, Nx, Angular Universal, or Angular CLI.4---56# Angular Architect78## Load Order9Read `shared-kernel/SKILL.md` first.1011## Core Competencies1213### Modern Angular (17+)14- **Standalone components** by default — NgModules only when integrating with legacy code15- **Signals** (`signal`, `computed`, `effect`) as primary reactive primitive16- **New control flow**: `@if`, `@for` with `track`, `@switch`, `@defer`17- **`inject()` function** instead of constructor injection in most cases18- **Functional guards and resolvers** (`CanActivateFn`, `ResolveFn`)19- **Deferrable views** (`@defer`) for lazy-loading below-the-fold UI2021### Signals vs RxJS — When to Use What2223| Use Case | Primitive |24|---|---|25| Component-local state | `signal()` |26| Derived state | `computed()` |27| Side effects from state change | `effect()` |28| HTTP request | `HttpClient` returns `Observable` — bridge with `toSignal()` |29| User input streams (debounce, throttle) | RxJS (`fromEvent`, `debounceTime`) |30| WebSocket / SSE | RxJS |31| Complex event coordination | RxJS operators |3233Bridge: `toSignal(observable$)` and `toObservable(signalRef)`.3435### Change Detection Strategy36- `OnPush` everywhere — the cost of not using it compounds37- Signal reads inside a template mark that template for re-check automatically38- With OnPush: component re-renders only when inputs change (reference) or signals it reads update39- Avoid mutation — always produce new references (spread, immer, or signal `.update()`)4041### Reactive Forms (Typed)4243```typescript44import { FormBuilder, Validators } from '@angular/forms';4546const form = this.fb.group({47 email: ['', [Validators.required, Validators.email]],48 password: ['', [Validators.required, Validators.minLength(12)]],49});50// form is FormGroup<{ email: FormControl<string | null>; password: FormControl<string | null> }>51```5253- Never use template-driven forms in production54- Always strongly typed — no `FormGroup<any>`55- Validators composed, never inline logic in templates5657### Lazy Loading5859```typescript60// app.routes.ts61export const routes: Routes = [62 {63 path: 'dashboard',64 loadComponent: () => import('./dashboard/dashboard.component').then(m => m.DashboardComponent),65 canActivate: [authGuard],66 },67];68```6970- `loadComponent` for standalone components71- `loadChildren` only for legacy NgModule features72- Functional guards via `CanActivateFn`, not class-based7374### SSR + Hydration75- `provideClientHydration()` in `app.config.ts` enables full app hydration76- `@defer (on viewport)` for incremental hydration of below-the-fold content77- Avoid `document` / `window` access in universal code — guard with `isPlatformBrowser`7879## Version Verification (Required First Step)8081Before writing Angular code:82- Read `package.json` for `@angular/core` version83- Confirm Angular CLI version matches (`ng version`)84- Check `angular.json` for builder: `@angular-devkit/build-angular:application` (esbuild, modern) or `browser` (webpack, legacy)85- Determine project posture: fully standalone, hybrid, or NgModule-based86- Check for Nx workspace (`nx.json` present) — Nx changes file layout and commands8788## Common Failure Modes8990| Symptom | Root Cause |91|---|---|92| Memory leak over time | `.subscribe()` in component without `takeUntilDestroyed()` or async pipe |93| Signal value not updating | Used `.set()` with same reference (mutated object) — use spread or new object |94| `ExpressionChangedAfterItHasBeenCheckedError` | State changed during the same CD cycle — move to `effect()` or `ngAfterViewInit` with care |95| OnPush component not refreshing | Parent passed mutated object with same reference |96| Infinite loop in effect | `effect()` writes to a signal it also reads — split into two signals |97| `@for` performance degraded | Missing `track` expression — always provide `track item.id` |98| SSR hydration mismatch | Browser-only API used during SSR, or non-deterministic value in template |99| Change detection running constantly | Event listener triggering CD without OnPush + signal boundary |100101## Non-Negotiables102103- `strict: true` in `tsconfig.json`104- `strictTemplates: true` in Angular compiler options105- No `any` in component public API (inputs, outputs, exposed methods)106- Every HTTP call goes through a service — never in a component107- Every `@for` block has a `track` expression108- Every subscription uses `takeUntilDestroyed()` or the `async` pipe109- Every route has a guard appropriate to its access level110- No logic in templates beyond boolean checks and simple property access111112## Deliverables113114### Standalone Component (Modern Shape)115116```typescript117import { Component, inject, signal, computed, input, output } from '@angular/core';118import { UserService } from './user.service';119120@Component({121 selector: 'app-user-card',122 standalone: true,123 changeDetection: ChangeDetectionStrategy.OnPush,124 template: `125 @if (user(); as u) {126 <article>127 <h2>{{ u.displayName }}</h2>128 <p>{{ subtitle() }}</p>129 <button type="button" (click)="edit.emit(u.id)">Edit</button>130 </article>131 } @else {132 <p>Loading…</p>133 }134 `,135})136export class UserCardComponent {137 userId = input.required<string>();138 edit = output<string>();139140 private userService = inject(UserService);141142 user = computed(() => this.userService.userById(this.userId()));143 subtitle = computed(() => {144 const u = this.user();145 return u ? `${u.role} · Joined ${u.joinedAt.toLocaleDateString()}` : '';146 });147}148```149150### Service with Signal Store Pattern151152```typescript153import { Injectable, computed, inject, signal } from '@angular/core';154import { HttpClient } from '@angular/common/http';155import { firstValueFrom } from 'rxjs';156157@Injectable({ providedIn: 'root' })158export class UserService {159 private http = inject(HttpClient);160161 private _users = signal<User[]>([]);162 private _loading = signal(false);163 private _error = signal<string | null>(null);164165 readonly users = this._users.asReadonly();166 readonly loading = this._loading.asReadonly();167 readonly error = this._error.asReadonly();168169 readonly userCount = computed(() => this._users().length);170171 userById(id: string) {172 return computed(() => this._users().find(u => u.id === id));173 }174175 async load(): Promise<void> {176 this._loading.set(true);177 this._error.set(null);178 try {179 const data = await firstValueFrom(this.http.get<User[]>('/api/users'));180 this._users.set(data);181 } catch (err) {182 this._error.set(err instanceof Error ? err.message : 'Unknown error');183 } finally {184 this._loading.set(false);185 }186 }187}188```189190### Functional Guard191192```typescript193import { CanActivateFn, Router } from '@angular/router';194import { inject } from '@angular/core';195import { AuthService } from './auth.service';196197export const authGuard: CanActivateFn = () => {198 const auth = inject(AuthService);199 const router = inject(Router);200 if (auth.isAuthenticated()) return true;201 router.navigate(['/login']);202 return false;203};204```205206## Performance Checklist207- OnPush change detection on every component208- `@defer` for below-the-fold content209- Route-level lazy loading for every feature area210- `@for` uses `track` with a stable identifier211- Images use `NgOptimizedImage` directive212- Bundle analyzed with `source-map-explorer` or Nx `webpack-bundle-analyzer`213- Lighthouse score measured in CI214215## Testing Standard216- Unit tests with Jest or Karma + Jasmine (Jest preferred for speed)217- Component tests with Angular Testing Library — test behavior, not implementation218- E2E with Playwright or Cypress219- Mock `HttpClient` with `provideHttpClientTesting()` — never hit real network220221## Reference Links to Verify222- https://angular.dev (primary — the new docs site)223- https://angular.dev/guide/signals224- https://angular.dev/guide/hydration225- https://nx.dev (for Nx workspaces)