Header image of Angular Robot
60 min read

Angular + NgRx Architecture for Agents

This guide (and Claude plugin) covers the complete Angular architecture, including signals, NgRx Signal Store, and enterprise-ready practices.

AngularArchitectureNgRx

Services, signal stores, signal reactivity, dependency injection, security, observability, and everything else that determines whether an Angular app survives contact with a growing team.

Written to be read by humans and pasted into an AI agent.

Targets Angular 20+ and NgRx Signals. Verified against the angular.dev docs and the official Angular best-practices guide; version-sensitive claims are flagged inline.


Get this as a plugin instead

Everything in this document is also packaged as a Claude Code plugin, available at:

https://github.com/KylerJohnsonDev/angular-architecture-skills

/plugin marketplace add KylerJohnsonDev/angular-architecture-skills
/plugin install angular-architecture@angular-architecture

The plugin (and this document) cover:

  • Component → Store → Service layering — the non-negotiable data-flow rule and the one sanctioned exception (rxResource) and when it applies
  • Signal reactivitysignal, computed, linkedSignal, effect, and the substitution table that replaces almost every effect() an agent writes unprompted
  • NgRx Signal Store — anatomy, call-state, rxMethod orchestration, and keeping shared entities consistent across multiple stores at scale
  • Dependency injection — injection contexts, injector hierarchies, scoping, and DI as a testing seam
  • Auth & security architecture — token storage, the refresh-request race every implementation gets wrong, CSRF/CSP, and the sanitization boundary
  • Production observability — global error handling, structured logging, correlation IDs, and real-user monitoring
  • Application architecture at scale — routing, lazy loading, feature flags, i18n/l10n, SSR/hydration strategy, and monorepo/library boundaries
  • Forms, templates, styling, accessibility, performance, and testing conventions
  • An anti-pattern catalogue, structured as a PR-review checklist

Prefer the plugin when you can install one. It loads only the reference file a task actually touches, which keeps an agent's context small and lets version-sensitive guidance stay current independently of this document. This article is the alternative for when you can't install a plugin — a one-off session, a tool without Claude Code plugin support, a teammate who just wants the rules in one place — where you'd rather paste the whole thing into an agent's context (a system prompt, CLAUDE.md, or a single message) and query it directly. Same rules, same code, delivered as one file instead of a loaded skill.


Contents


Why this document exists

Ask a coding agent to "add a widget that shows the top five shipping carriers" and you will almost always get back code that works. You will also usually get back code like this:

// ❌ Plausible. Works. Wrong.
@Component({ selector: 'app-carrier-list', templateUrl: './carrier-list.component.html' })
export class CarrierListComponent implements OnInit {
  carriers: Carrier[] = [];
  loading = false;
  error: string | null = null;

  constructor(private http: HttpClient) {}

  ngOnInit() {
    this.loading = true;
    this.http.get<Carrier[]>('/api/carriers').subscribe({
      next: (carriers) => { this.carriers = carriers; this.loading = false; },
      error: (err) => { this.error = err.message; this.loading = false; },
    });
  }
}

Every line of that is defensible in isolation. Together they produce an application that cannot be reasoned about: state lives in twelve components, loading flags are hand-rolled and inconsistently reset, nothing is observable in devtools, and the only way to test the data flow is to mount the component and mock HTTP. Scale that pattern to an enterprise app — multiple teams, a dozen features, a login wall, a compliance audit — and the same defensible lines compound into an application nobody can safely change.

The fix is not "use a state library." It is a set of explicit rules about which layer is allowed to do what, extended past the component boundary into how the app boots, how auth and errors are handled, and how the codebase is bounded as it grows. Once the rules are written down, both humans and agents produce the same shape of code, and code review stops being an argument about taste.

That's what follows. Ten parts, each ending in rules you can enforce. The appendix compresses all of it into a block you can drop into CLAUDE.md, AGENTS.md, or .cursorrules.

A note on the examples. Everything runs on an invented shipping-carriers domain with an app- selector prefix. Adapt the names; the shapes are the point.


Part 1 — Application architecture

Before any component gets written, several decisions determine how the application ages: how it boots, how code is grouped and bounded, how routing carries the structure, how rendering and translation strategy are chosen, and how change detection is driven.

1.1 Bootstrap and application-wide providers

Modern Angular has no NgModule. An application is a root component plus a flat list of providers.

// main.ts
bootstrapApplication(App, appConfig).catch((error) => console.error(error));
// app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes, withComponentInputBinding()),
    provideHttpClient(withFetch(), withInterceptors([authInterceptor, retryInterceptor])),
    provideAnimationsAsync(),
    // your own feature configuration, exposed the same way
    provideAppConfig({ apiUrl: '/api', pageSize: 50 }),
  ],
};

Three conventions worth adopting:

Expose configuration through provide* functions, not raw provider arrays. This is the pattern every Angular library uses (provideRouter, provideHttpClient) and it is the right pattern for your own feature libraries too. It keeps the token private and gives you one place to validate or default the config:

export function provideAppConfig(config: AppConfig): EnvironmentProviders {
  return makeEnvironmentProviders([
    { provide: APP_CONFIG, useValue: config },
    TelemetryService,
  ]);
}

withComponentInputBinding() is close to mandatory. It binds route params, query params, and resolved data straight to signal input()s on the routed component, which deletes an entire category of ActivatedRoute plumbing. More in §1.4.

withFetch() on provideHttpClient. Uses the fetch API instead of XMLHttpRequest; better SSR behaviour and fewer polyfills.

1.2 How to group code

Angular has no opinion here, which means you need one. The grouping that survives contact with a growing team is by feature, with a strict dependency direction:

src/app/
  app.ts                     ← root shell component
  app.config.ts              ← bootstrap providers
  app.routes.ts              ← top-level route table
  core/                      ← app-wide singletons: auth, interceptors, error handling
  features/
    carriers/
      carriers.routes.ts     ← lazy entry point for the feature
      carrier-list/          ← component + store + template + styles, colocated
      carrier-detail/
    shipments/
  data-access/
    carriers/                ← CarriersApiService, shared CarriersStore, models
    shipments/
  ui/                        ← presentational components: buttons, cards, empty states
  util/                      ← pure functions: formatting, date math, comparators

The layout matters less than the dependency rule, which is what actually prevents rot:

LayerMay depend onMust never depend on
features/*data-access, ui, util, coreanother feature
data-access/*util, corefeatures, ui
ui/*utilfeatures, data-access
util/*nothingeverything else

Read that table as one sentence: dependencies point downward only, and features never import each other. Two features that need to share something are telling you that the shared thing belongs in data-access, ui, or util.

Two rules make this stick:

  • Colocate by feature, not by type. A component's class, template, styles, store, and spec live in one directory. Directories named components/, services/, and stores/ each holding forty unrelated files are an organisational smell — they guarantee that working on one feature means touching four distant places.
  • Enforce the boundaries mechanically. In an Nx workspace this is project tags plus the enforce-module-boundaries ESLint rule. In a plain CLI workspace, eslint-plugin-boundaries or an import-path lint rule does the job. An architecture rule that only lives in a document is a rule your agent will breach without noticing.

1.3 When folders aren't enough: real library boundaries

The layout above is folders in one app, lint-enforced. That's the right amount of structure for a single team on a single deployable. Past a certain size — multiple teams owning different features, a shared design system consumed by more than one app, or a CI pipeline slow enough that "only test what changed" stops being optional — folders stop being enough, because a lint rule can be disabled with a comment and an import path is still just a string.

The next step is real libraries: each of features/carriers, data-access/carriers, ui, and util becomes its own buildable unit with its own tsconfig path (Nx's nx g @nx/angular:library generators map onto exactly these four types — feature, data-access, ui, util — which is not a coincidence; the directory layout above is the Nx library taxonomy, one step before you need the tooling). A forbidden import (a feature reaching into another feature, ui reaching into data-access) becomes a build-time module-resolution error, not a lint warning someone silences. Tag each library (scope:carriers, type:feature, type:data-access, …) and encode the dependency-rule table above as depConstraints — the table stops being documentation and becomes an enforced constraint.

Two practical notes:

  • Default to non-buildable libraries (Nx computes them as part of the app build) unless a library is independently published (a shared design-system package consumed by other repos) or built by a separate pipeline. Buildable-by-default adds config overhead most libraries never need.
  • Pair each top-level tag with a human owner (a CODEOWNERS entry matching the library's path) so the boundary has someone who gets pinged on a violation, not just a CI failure someone routes around.

Don't reach for this by default on a single-team app — folders plus a lint rule cost nothing and a premature Nx migration is ceremony with no payoff. Graduate when the pain (slow full-repo CI, cross-team merge conflicts on shared files, a design system that needs its own release cadence) actually shows up.

1.4 Routing is the architectural spine

The route table is where lazy loading, access control, and data pre-fetching are declared. Treat it as architecture, not configuration.

// app.routes.ts
export const routes: Routes = [
  { path: '', pathMatch: 'full', redirectTo: 'carriers' },
  {
    path: 'carriers',
    loadChildren: () => import('./features/carriers/carriers.routes'),
    canMatch: [featureEnabledGuard('carriers')],
  },
  {
    path: 'admin',
    loadChildren: () => import('./features/admin/admin.routes'),
    canActivate: [authGuard, roleGuard('admin')],
  },
  { path: '**', loadComponent: () => import('./features/not-found/not-found') },
];
// features/carriers/carriers.routes.ts
export default [
  {
    path: '',
    // Feature-scoped providers: created on entry, destroyed on exit.
    providers: [provideCarrierFilters()],
    children: [
      { path: '', loadComponent: () => import('./carrier-list/carrier-list') },
      { path: ':carrierId', loadComponent: () => import('./carrier-detail/carrier-detail') },
    ],
  },
] satisfies Routes;

Lazy-load every feature. Eager-load only the shell and the landing route. loadChildren for a feature's route table, loadComponent for a single routed component. Both loaders run inside an injection context, so you can inject() a feature-flag service and pick which chunk to load.

canMatch vs canActivate. canMatch decides whether the route is considered at all — if it returns false the router keeps looking at later routes, so it's the correct guard for feature flags and licensing (the route effectively doesn't exist). canActivate decides whether this user may proceed, and typically redirects. Use canMatch for "does this route exist for this tenant," canActivate for "is this user allowed in."

Route params reach the component as signal inputs. With withComponentInputBinding() enabled, a :carrierId path param binds directly:

export class CarrierDetail {
  // Bound from the route. No ActivatedRoute, no snapshot, no subscription.
  readonly carrierId = input.required<string>();
}

This is a genuinely important simplification. The pre-signals pattern — route.paramMap.subscribe(...) — was the most common source of subscriptions in components, and it is now gone. Anything that reads route state through ActivatedRoute in new code should be questioned.

Resolvers: use sparingly. A ResolveFn blocks navigation until data arrives, which means a slow endpoint becomes a frozen UI with no feedback. Prefer navigating immediately and letting the store's call state render a skeleton. Resolvers earn their place when the route genuinely cannot render without the data (a 404 decision, a permission-shaped redirect).

Guards are UX, not security. They hide what a user shouldn't reach; they do not protect anything. Every rule enforced in a guard must also be enforced server-side. An agent will happily write a canActivate and consider the feature secured — it isn't. More in Part 5.

1.5 Feature flags

A flag gates three different layers, and treating it as a single ad hoc check scattered across all three is how flags become untestable and permanent. The three layers:

LayerMechanism
Route existencecanMatch: [featureEnabledGuard('carriers-v2')] — shown above
Rendered UI@if (flags.isEnabled('carriers-v2')) { ... }
Store/async behaviourThe flag value passed to the store, or read via normal DI in a method

One service, signal-backed, loaded before the app becomes interactive so the first canMatch check already has data:

@Injectable({ providedIn: 'root' })
export class FeatureFlagsService {
  private readonly flags = signal<Record<string, boolean>>({});
  isEnabled(flag: string): boolean { return this.flags()[flag] ?? false; }
  setAll(flags: Record<string, boolean>): void { this.flags.set(flags); }
}

export function provideFeatureFlags(): EnvironmentProviders {
  return makeEnvironmentProviders([
    provideAppInitializer(async () => {
      const api = inject(FeatureFlagsApiService);
      inject(FeatureFlagsService).setAll(await firstValueFrom(api.loadFlags()));
    }),
  ]);
}

A store that genuinely branches on a flag injects FeatureFlagsService like any other dependency, as a default parameter in withMethods — it's a service, not a special case. What it should not do is become the way every store discovers its own on/off state for unrelated reasons; if half a codebase's stores inject the flag service, that's a sign flags are being used as informal configuration rather than a rollout mechanism.

Every flag is debt on a timer. Give it an owner and a removal condition ("delete both branches once at 100% for two weeks") when it's created. A flag with no removal plan is a permanent second code path that gets tested half as often as the primary one. In TestBed, override FeatureFlagsService and exercise both branches explicitly — an untested "flag off" path is exactly where the next regression hides.

For gradual rollout, bucket by a stable identifier (user or session ID) hashed against the target percentage once, at flag-load time — not re-rolled on every check, which would flip a user between variants mid-session.

1.6 Lazy-load inside templates with @defer

Route-level lazy loading splits pages. @defer splits within a page — the right tool for heavy below-the-fold content: charts, comment threads, rich editors, maps.

@defer (on viewport) {
  <app-carrier-trend-chart [carrierId]="carrierId()" />
} @placeholder (minimum 300ms) {
  <div class="chart-placeholder"></div>
} @loading (after 100ms; minimum 300ms) {
  <app-spinner />
} @error {
  <app-error-banner message="Chart unavailable" />
}

Everything inside @defer — component, directives, pipes, and their transitive imports — moves to a separate chunk that isn't requested until the trigger fires.

TriggerFires when
on idleBrowser is idle (the default)
on viewportBlock scrolls into view — best default for below-the-fold
on interaction / on hoverUser interacts with or hovers the placeholder
on immediateRight after render — still off the critical path
on timer(5s)After a delay
when <expr>A condition becomes true
prefetch on ...Fetch the chunk early, render later — combine with any trigger

The @placeholder/@loading timings exist to prevent flicker: after delays showing a spinner for fast loads, minimum stops it flashing. Use them, or fast connections get a visible flash on every deferred block.

1.7 Internationalization (i18n)

Decide the translation strategy before the first component ships. Retrofitting locale awareness into a codebase full of hardcoded English strings and hand-formatted dates is one of the most expensive refactors an enterprise app takes on, and it's entirely avoidable by deciding early.

Two real options — pick one, don't run both:

ApproachMechanismFit
Compile-time (@angular/localize, $localize)A separate build artifact per localeNo runtime language switch needed; ships in CI per market. Zero runtime cost.
Runtime library (Transloco, ngx-translate)Loads locale JSON, switches without rebuildA user-facing language switcher; one build serves every locale.

Where translations live. Alongside data-access and ui — translation keys are consumed across features, so they're shared infrastructure, not a feature concern. A dedicated i18n/ area, not inline JSON scattered per feature.

Loading strategy for runtime i18n. Lazy-load only the active locale's bundle — don't ship every language up front — and treat "translations not yet loaded" as a real loading state, the same way any other async data is handled, rather than a flash of raw translation keys.

Formatting goes through the platform, not string templates. Dates, numbers, and currency route through DatePipe / CurrencyPipe / Intl.*, driven by the active LOCALE_ID — never `${amount} ${currency}`, which breaks the moment a locale puts the symbol somewhere else or uses a different decimal separator.

Never concatenate translated fragments. t('you have') + count + t('items') breaks for any language with different word order or pluralization rules. Use the library's interpolation/pluralization support instead of assembling a sentence from parts.

RTL falls out of the logical-properties rule for free. A component styled with margin-inline-start (see Part 8) needs zero changes for an RTL locale; one styled with margin-left needs a parallel stylesheet. Set dir on <html> from the active locale at bootstrap and on every locale change.

Locale switch is an app-wide event. Route it through the same kind of root configuration/service as provideAppConfig — reload the active translation bundle and update LOCALE_ID/dir in one place, not per component.

1.8 Rendering strategy: SSR and hydration

Decide per route, not per app. A marketing or content page wants server-rendered or prerendered HTML for first paint and crawlability. An authenticated internal tool behind a login wall gets little from SSR — there's nothing to crawl, and first paint is already gated on auth — while paying the operational cost of a render server. Angular supports mixing render modes (server / client / prerender) per route rather than an all-or-nothing choice; check the current @angular/ssr route-configuration API against your installed version, since this surface has changed across recent releases.

Hydration mismatches share one root cause: server and client computing different output for the same render. The usual culprits:

  • Reading window / document / localStorage during render (see "don't assume globals" in Part 8).
  • Date.now() / Math.random() in a template or computed.
  • Locale-dependent formatting where the server's locale isn't pinned to match the client's.

A mismatch doesn't just render wrong — Angular detects it and re-renders the affected subtree client-side, silently discarding the SSR benefit for exactly that content.

Incremental hydration (@defer with a hydrate trigger, where available on your version) extends the @defer mental model from lazy loading to lazy hydration: below-the-fold or low-priority content can ship as server-rendered, visible-but-non-interactive HTML that stays dehydrated until its trigger fires, rather than hydrating the entire page eagerly. Same triggers as the table above; the payoff is deferred JS execution rather than a deferred fetch.

PendingTasks (§1.9) doesn't decide whether a route is SSR'd — it makes the routes you do SSR correctness-safe, by making serialization wait for async work that must complete first.

1.9 Change detection: zoneless is the default now

This is the biggest recent shift, and agents trained on older material get it wrong.

Angular 21+ runs zoneless by default. In v20 you opt in with provideZonelessChangeDetection(). Zone.js should be removed from polyfills in angular.json (both build and test) and uninstalled.

Without Zone.js, Angular no longer discovers changes by monkey-patching browser APIs. It relies on explicit notifications, of which the ones that matter are:

  • updating a signal that a template reads ← this is the mechanism you should be using
  • ChangeDetectorRef.markForCheck() (the async pipe calls it for you)
  • ComponentRef.setInput()
  • host and template listener callbacks

The architectural consequence is direct: signals stop being a style preference and become the notification mechanism. A component that mutates a plain field and expects the view to update worked under Zone.js and silently does not now.

Practical fallout:

  • Every component runs under OnPush — the default since v22, set explicitly on v20/v21. It's the recommended path to zoneless-compatibility and it makes accidental reliance on eager change detection impossible.
  • NgZone.onMicrotaskEmpty / onStable / onUnstable never emit; isStable is always true. Replace with afterNextRender (one pass) or afterEveryRender (spanning passes), or a direct DOM API like MutationObserver. (NgZone.run and runOutsideAngular remain fine.)
  • Reactive forms don't self-notify. setValue, patchValue, FormArray.push update form state and emit on form observables but do not schedule change detection. If a template depends on reactive-forms state, bridge it — markForCheck(), or reflect the value through a signal. This is a real, easy-to-miss bug source in migrated apps.
  • SSR needs PendingTasks. Serialization used to wait on the zone. Now async work that must complete before serializing has to say so:
    const tasks = inject(PendingTasks);
    await tasks.run(async () => {
      this.state.set(await loadCriticalData());
    });
    

    The framework already does this for in-flight router navigations and HttpClient requests; it's your own async work that needs wiring. pendingUntilEvent() from @angular/core/rxjs-interop does the same for an Observable.
  • In development, provideCheckNoChangesConfig({ exhaustive: true, interval: 1000 }) will periodically verify that no binding changed without a notification. Turn it on while migrating; it finds exactly the bugs described above.

Part 1 rules

  • Bootstrap with bootstrapApplication; no NgModule in new code.
  • Expose library/feature configuration via provide* functions returning EnvironmentProviders.
  • Enable withComponentInputBinding() and bind route params as signal input()s.
  • Group by feature and colocate; dependencies point downward; features never import features.
  • Enforce boundaries with lint, not documentation; graduate to real Nx libraries (with depConstraints) once folders and lint stop being enough.
  • Lazy-load every feature route. canMatch for existence, canActivate for permission.
  • Gate a feature flag at all three layers it affects (route, template, store) through one FeatureFlagsService; every flag ships with an owner and a removal condition.
  • Use @defer (on viewport) for heavy below-the-fold content, with @placeholder and @loading timings.
  • Pick a translation strategy (compile-time vs runtime) before the first component ships; format dates/numbers/currency through Intl/pipes, never string templates.
  • Decide SSR per route, not per app; avoid reading browser globals during render to prevent hydration mismatches.
  • Assume zoneless: drive the view with signals, OnPush everywhere, PendingTasks for SSR async work.

Part 2 — The three layers

2.0 The model in one diagram

┌─────────────────────────────────────────────────────────────┐
│  Component  — view only                                     │
│  · reads signals from the store                             │
│  · derives with computed / linkedSignal                     │
│  · calls store methods on user interaction                  │
│  · NEVER subscribes, NEVER holds fetched state              │
└──────────────────────────┬──────────────────────────────────┘
                           │ calls methods, reads signals
┌──────────────────────────▼──────────────────────────────────┐
│  Store  — state + orchestration                             │
│  · owns all feature state                                   │
│  · owns loading / error state                               │
│  · owns fetch, poll, retry, cancel, debounce                │
│  · injects services, never HttpClient directly              │
└──────────────────────────┬──────────────────────────────────┘
                           │ calls, receives Observable
┌──────────────────────────▼──────────────────────────────────┐
│  Service  — transport only                                  │
│  · one method per endpoint / query                          │
│  · maps wire shape → domain model                           │
│  · returns a cold Observable                                │
│  · holds NO state, sets NO flags                            │
└─────────────────────────────────────────────────────────────┘

The single most important rule in the whole document:

A component may not call a service that performs I/O. Data flows Component → Store → Service. No shortcuts.

Everything else in this part follows from that.

There is exactly one sanctioned exception, and it is narrow: a component whose entire data story is one read-only fetch may use rxResource and skip the store. That case, and the precise point at which it stops applying, gets its own section (§2.4) — but read the three layers first, because the store is the default and rxResource is the thing you justify.

2.1 Services are boring on purpose

A service that talks to the network is a transport adapter. Its entire job is to turn a typed request into a typed response. It should be so dull that reading it tells you nothing about the feature.

// carriers-api.service.ts
@Injectable({ providedIn: 'root' })
export class CarriersApiService {
  private readonly http = inject(HttpClient);

  /** Ranked server-side; every field the view needs comes back on the row. */
  loadTopCarriers(limit: number, range: DateRange): Observable<Carrier[]> {
    return this.http
      .get<{ carriers: CarrierDto[] }>('/api/carriers/top', {
        params: { limit, from: range.from, to: range.to },
      })
      .pipe(map((response) => response.carriers.map(toCarrier)));
  }
}

/** Wire shape → domain model. Keeps DTO leakage out of the store and view. */
function toCarrier(dto: CarrierDto): Carrier {
  return { id: dto.id, name: dto.name, shipmentCount: dto.shipment_count, risk: dto.risk_level };
}
RuleWhy
Return a cold Observable (or Promise), never a signalThe caller decides subscription semantics — cancellation is the store's business
No BehaviorSubject state, no cached last valueTwo sources of truth is how staleness bugs are born
No loading/error flagsThe store owns call state; a service that sets flags has hidden coupling
One method per operation, named for the operationloadTopCarriers, not getData
Map DTO → domain at the boundaryExternal data is untrusted; narrow the type once, here
providedIn: 'root' unless it genuinely needs scopingTree-shakable, no provider boilerplate
Design around a single responsibilityA service that fetches carriers and formats them and caches them is three services
Name the file for its role — *-api.service.ts for I/O servicesMakes the "components may not inject these" rule mechanically checkable

That last row is worth dwelling on. If your I/O services are identifiable by filename, the central architectural rule stops being a code-review convention and becomes an ESLint rule: no *-api.service may be injected into a *.component.ts. Rules an agent can violate silently are rules you don't have. Rules CI enforces are rules you have.

Not every service is an API service. A pure formatter, a date-math helper, a permissions calculator — these hold no state and perform no I/O, and components may inject them freely. The prohibition is specifically about I/O and state, not about the word "service."

Cross-cutting HTTP concerns belong in interceptors, not services. Auth headers, retry policy, error normalisation, correlation IDs, loading telemetry — write a functional interceptor once and register it at bootstrap. Every API service re-implementing retry is the duplication this exists to prevent.

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(AuthStore).accessToken();
  return next(token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req);
};

2.2 The store is where the feature actually lives

The store owns three things: state, derivations, and orchestration. If you can name a piece of feature behaviour, it belongs here.

Anatomy

Compose store features in a fixed order. Fixed order means every store in the codebase is skimmable in five seconds, and an agent has no decisions to make.

// carriers.store.ts

// ─── Constants ────────────────────────────────────────────────
const TOP_CARRIER_COUNT = 5;

// ─── State ────────────────────────────────────────────────────
interface CarriersState {
  carriers: Carrier[];
  selectedId: string | null;
}

const initialState: CarriersState = {
  carriers: [],
  selectedId: null,
};

// ─── Store ────────────────────────────────────────────────────
export const CarriersStore = signalStore(
  withState<CarriersState>(initialState),   // 1. state first, always
  withCallState(),                          // 2. loading/error, if async
  withEntities({ ... }),                    // 3. only if you need a normalized collection
  withComputed((store) => ({ ... })),       // 4. exactly one block
  withMethods((store) => ({ ... })),        // 5. exactly one block
  withHooks({ ... }),                       // 6. lifecycle, optional
  withLogger('CarriersStore'),              // 7. last, always, named
);

One withComputed, one withMethods. Splitting them into multiple blocks creates ordering dependencies between blocks that are invisible at the call site and break in confusing ways when someone reorders them. If a block gets long, that's a signal to extract pure functions — not to add another block.

State design: flat, and free of derivations

// ✅ flat — one patch updates one thing
interface CarriersState {
  carriers: Carrier[];
  selectedId: string | null;
  isPanelOpen: boolean;
}

// ❌ nested — every update needs a spread of a spread
interface CarriersState {
  ui: { selection: { id: string | null; open: boolean } };
}

And the rule that prevents the most bugs per character:

// ❌ derived values in state — two sources of truth, guaranteed to drift
interface CarriersState {
  carriers: Carrier[];
  hasCarriers: boolean;       // ← who updates this? every writer? forever?
  selectedCarrier: Carrier;   // ← stale the moment carriers changes
}

// ✅ derived values in computed — structurally impossible to be stale
withComputed((store) => ({
  hasCarriers: computed(() => store.carriers().length > 0),
  selectedCarrier: computed(() =>
    store.carriers().find((carrier) => carrier.id === store.selectedId())
  ),
}));

There's a corollary agents get wrong constantly: don't park transient values in state. If a value is only needed by one updater, pass it as a method parameter. A search term that only search() reads is an argument, not state. State is what the view observes.

Keeping shared entities consistent across stores

Component-scoped stores (the recommended default — §2.2's "Scoping") create a problem that only shows up at scale: if CarriersStore and ShipmentsStore both hold their own denormalized copy of the same Carrier, updating one leaves the other stale. This is invisible in a small app — one feature, one store, no duplication — and becomes the most common data-integrity bug in a large one, because nothing in the type system catches it.

Default: one entity store per domain concept owns the canonical copy. Feature stores hold IDs and derive the object from the shared store via computed, rather than duplicating it:

// carrier-entities.store.ts — root-provided, the single source of truth for Carrier records
export const CarrierEntitiesStore = signalStore(
  { providedIn: 'root' },
  withEntities<Carrier>(),   // normalized: entityMap keyed by id, via a selectId you configure
  withMethods((store, api = inject(CarriersApiService)) => ({
    loadMany: rxMethod<CarrierIds>(/* ... setAllEntities / upsertEntities on response */),
    updateOne: rxMethod<CarrierPatch>(/* ... updateEntity on success, so every reader updates at once */),
  })),
);

// carrier-list.store.ts — feature-scoped, holds only what's specific to this feature
export const CarrierListStore = signalStore(
  withState<{ visibleIds: string[]; selectedId: string | null }>({ visibleIds: [], selectedId: null }),
  withComputed((store, entities = inject(CarrierEntitiesStore)) => ({
    // Derived, not duplicated — reading entities.entityMap() here means a write from
    // ANY feature is visible here without this store doing anything.
    visibleCarriers: computed(() =>
      store.visibleIds().map((id) => entities.entityMap()[id]).filter(Boolean)
    ),
  })),
);

A write from ShipmentsStore that also touches a carrier goes through CarrierEntitiesStore.updateOne, not through its own patchState on a locally-held copy — so CarrierListStore's computed picks up the change automatically, with no message passed between the two feature stores and no feature importing another (the dependency rule from §1.2 holds: both depend downward on the shared entity store, not on each other).

When duplication is intentional — a feature genuinely needs a denormalized, shape-specific read model, and round-tripping through a shared entity store would mean reshaping on every read — accept the duplication but make invalidation explicit rather than hoping the copies stay in sync. A minimal shared invalidation signal is enough:

// entity-invalidation.ts — a root-provided counter per domain concept
export const CARRIER_INVALIDATED = new InjectionToken<WritableSignal<number>>(
  'carrier-invalidated',
  { providedIn: 'root', factory: () => signal(0) },
);

// any store that mutates a carrier bumps it on success
patchState(store, { carrier: saved }, setSuccess());
inject(CARRIER_INVALIDATED).update((n) => n + 1);

// any store that caches carriers refetches when it changes
loadCarrier: rxMethod<string>(
  pipe(switchMap((id) => /* combine id with the invalidation signal, refetch on either */)),
),

The cheapest fallback, and often the right call: for read-mostly data where staleness for the length of one navigation is an acceptable cost, don't build cache-invalidation machinery at all — refetch on route entry. A component-scoped store that reloads its data in withHooks.onInit sidesteps the whole consistency problem at the price of one extra request per navigation. Reach for the entity-store or invalidation-signal patterns only once that cost is measured and too high, not by default.

The rule that prevents the bug in the first place: never let two stores independently patchState their own copy of the same conceptual entity. One store (or a shared entity store) owns the write; every reader selects from it.

Loading and error state: build it once, reuse it everywhere

Hand-rolled loading flags are the single most repeated code in Angular applications, and the most consistently buggy — the failure mode is always the same, an error that is set on failure and never cleared on retry.

Build a small reusable store feature and never write the flags again. NgRx's signalStoreFeature exists exactly for this:

// call-state.feature.ts — write this once for your whole codebase
type CallState = 'init' | 'pending' | 'success' | { error: string };

export function withCallState() {
  return signalStoreFeature(
    withState<{ callState: CallState }>({ callState: 'init' }),
    withComputed(({ callState }) => ({
      pending: computed(() => callState() === 'pending'),
      success: computed(() => callState() === 'success'),
      error: computed(() => {
        const state = callState();
        return typeof state === 'object' ? state.error : null;
      }),
    }))
  );
}

export const setPending = () => ({ callState: 'pending' as const });
export const setSuccess = () => ({ callState: 'success' as const });
export const setError = (error: string) => ({ callState: { error } });

Real versions take a collection name so one store can track several independent operations (carriersPending(), regionsPending()), and add a canceled state. The principle is what matters: loading state is infrastructure, not feature code. Write it once, and make it the only accepted way to express "this is loading."

Orchestration: rxMethod owns the async flow

Fetching, polling, retrying, cancelling, debouncing — all of it lives in the store, in an rxMethod. The component's involvement is one call.

withMethods((store, carriersApi = inject(CarriersApiService)) => ({
  loadTopCarriers: rxMethod<DateRange>(
    pipe(
      switchMap((range) => {
        patchState(store, { carriers: [] }, setPending());

        return carriersApi.loadTopCarriers(TOP_CARRIER_COUNT, range).pipe(
          tapResponse({
            next: (carriers) => patchState(store, { carriers }, setSuccess()),
            error: (error: Error) => patchState(store, { carriers: [] }, setError(error.message)),
          })
        );
      })
    )
  ),

  selectCarrier: (id: string) => patchState(store, { selectedId: id }),
})),

Three things are load-bearing here.

tapResponse, never subscribe. From @ngrx/operators. A bare subscribe() inside a store method leaks, and — worse — an unhandled error in subscribe's next kills the outer subscription permanently, so the method silently stops working for the rest of the session. tapResponse handles both.

The flattening operator is a semantic decision, not a default. Getting this wrong produces race conditions that only appear under load:

OperatorSemanticsUse for
switchMapCancel in-flight, keep latestSearches, filter changes, navigation-triggered loads
exhaustMapIgnore new while one is runningForm submits, anything irreversible
concatMapQueue, preserve orderOptimistic updates, ordered mutations
mergeMapAll in parallel, no orderingRare — usually a bug in disguise

The switchMap above isn't decoration. It guarantees that when the user changes the date range twice quickly, a response for the old range can never overwrite state for the new one. That correctness property is why the fetch belongs in the store: a component that owns its own subscribe has no cancellation story at all.

An rxMethod accepts a signal, and that is how you make a fetch reactive. This is the third load-bearing property and the one most often missed. rxMethod<T> can be called with a plain T, an Observable<T>, or a Signal<T>. Hand it a signal and it subscribes to that signal for the lifetime of its injection context: every time the value changes, the pipeline re-runs — and because the pipeline starts with switchMap, the superseded request is cancelled automatically.

// ✅ One line. Reactive re-fetch + cancellation + call state, all wired.
this.store.loadTopCarriers(this.range);   // `range` is a Signal<DateRange>

Read that against what you'd otherwise write:

// ❌ An effect that reinvents rxMethod's signal handling, minus the cancellation
effect(() => {
  this.store.loadTopCarriers(this.range());
});

The effect version is longer, adds a node to the reactive graph, drops the automatic teardown, and — most damagingly — establishes a place where a future contributor will feel invited to write state. Whenever you catch yourself reaching for effect() to trigger a fetch, the answer is almost always to pass the signal to the rxMethod instead. This is the single highest-leverage substitution in the entire guide: it eliminates the large majority of effects that get written in signal-based Angular codebases.

The same trick works for derived arguments. If the fetch depends on several signals, combine them into one computed and pass that:

private readonly query = computed(() => ({
  range: this.range(),
  region: this.store.selectedRegion(),
  page: this.page(),
}));

constructor() {
  this.store.loadTopCarriers(this.query);  // re-fetches when any input changes
}

One caveat: the signal overload registers a reactive subscription, so it must be called inside an injection context — a field initialiser or the constructor. Calling it from a click handler works fine with a plain value, but pass a signal there and you'll need an explicit injector.

Make every mutation self-describing

patchState(store, { selectedId: id }) tells devtools what changed. It tells you nothing about why, which is what you actually need at 2am. Wrap it:

// action-patch-state.ts
export function actionPatchState<State extends object>(
  action: string,
  store: WritableStateSource<State>,
  ...updaters: PartialStateUpdater<State>[]
): void {
  // log `action` to your devtools bridge, then delegate
  patchState(store, ...updaters);
}

Then every mutation reads as a sentence, with a convention of 'methodName(): what happened':

actionPatchState('loadTopCarriers(): fetching', store, { carriers: [] }, setPending());
actionPatchState('loadTopCarriers(): success', store, { carriers }, setSuccess());
actionPatchState('selectCarrier(): selection changed', store, { selectedId: id });

Pair it with a withLogger('CarriersStore') feature — always last, always named to match the export — and your devtools timeline becomes a readable narrative of what the application did, per store. The cost is one string per mutation. It is the cheapest debugging affordance in the stack, and the reason to make it mandatory rather than optional is that a partially instrumented timeline is nearly as useless as none. This is devtools-only — it disappears the moment the tab closes. For production error visibility, see Part 6.

Lifecycle belongs to the store, not the component

If the store owns polling, the store owns starting and stopping it:

withHooks({
  onInit(store) {
    // effect() is acceptable here: store-owned lifecycle, not state derivation
    effect((onCleanup) => {
      const subscription = timer(0, POLL_INTERVAL_MS).subscribe(() => store.loadTopCarriers());
      onCleanup(() => subscription.unsubscribe());
    });
  },
}),

Note the onCleanup callback — without it, a component-scoped store that gets destroyed and recreated (route navigation, tab switching) accumulates timers. This is the most common leak in signal-store code.

Never call effect() inside withMethods. Methods run outside the injection context; effect() requires one. It will throw.

Scoping: singleton or component-scoped?

NeedHow
Shared across the app (session, tenant, feature flags)signalStore({ providedIn: 'root' }, ...), in data-access
Shared across one feature's routesNo providedIn; add to the feature route's providers: []
Owned by one component, dies with itNo providedIn; add to the component's providers: []

Component-scoped is the right default. A store in providers: [] gets a fresh instance per component instance and is destroyed with it — no cross-instance state bleed, no manual reset logic. Reach for providedIn: 'root' only when two unrelated parts of the tree genuinely need to observe the same state.

When a store gets big

At roughly 200 lines, extract pure functions into sibling files and let the store become a composition of thin wrappers:

carriers/
  carriers.store.ts       ← composition; each method 1–5 lines
  carriers.state.ts       ← interface + initialState
  carriers.computed.ts    ← pure derivation functions
  carriers.methods.ts     ← pure state-transition functions
  carriers.store.spec.ts
export const CarriersStore = signalStore(
  withState(initialState),
  withCallState(),
  withComputed((store) => ({
    summary: computed(() => buildSummary(store.carriers(), store.selectedId())),
    sorted: computed(() => sortCarriers(store.carriers())),
  })),
  withMethods((store) => ({
    selectCarrier: (id: string) =>
      actionPatchState('selectCarrier()', store, applySelection(store, id)),
  })),
  withLogger('CarriersStore')
);

Pure functions are trivially unit-testable without a TestBed, and the store file stays a one-screen table of contents for the feature.

2.3 Components are a projection of state

A component's job is to render signals and forward user intent. That's the whole job.

@Component({
  selector: 'app-carrier-list',
  templateUrl: './carrier-list.component.html',
  styleUrl: './carrier-list.component.scss',
  changeDetection: ChangeDetectionStrategy.OnPush, // redundant on v22+, required on v20/v21
  imports: [MatButton, BarMeterComponent],
  providers: [CarriersStore],
})
export class CarrierListComponent {
  protected readonly store = inject(CarriersStore);

  readonly range = input.required<DateRange>();

  /** Bars scale against the busiest carrier in the current result set. */
  protected readonly carriers = computed(() => {
    const carriers = this.store.carriers();
    const max = carriers.reduce((acc, carrier) => Math.max(acc, carrier.shipmentCount), 0);
    return carriers.map((carrier) => ({
      ...carrier,
      percent: max > 0 ? Math.round((carrier.shipmentCount / max) * 100) : 0,
    }));
  });

  constructor() {
    this.store.loadTopCarriers(this.range);
  }

  protected select(id: string): void {
    this.store.selectCarrier(id);
  }
}

That constructor line is the piece agents most reliably reinvent badly. Passing the rangesignal — not range() — is what makes the fetch reactive, and it replaces the effect() an agent writes unprompted.

Component rules

  • Every component runs under OnPush. It's the default in v22+ — don't write it there. On v20/v21 set changeDetection: ChangeDetectionStrategy.OnPush explicitly. Never opt out with ChangeDetectionStrategy.Eager (or the deprecated Default).
  • Do not write standalone: true. It's the default since v19 and an error to add in v20+.
  • No .subscribe(). The one pragmatic exception is dialog results — afterClosed() is a single-emission Observable tied to a user action, and routing it through a store is more ceremony than clarity. Take the exception knowingly; don't let it become a precedent. If you genuinely must subscribe, takeUntilDestroyed() is mandatory.
  • No pass-through computed. readonly items = computed(() => this.store.items()) adds a graph node and zero information. Use the store signal directly in the template.
  • Keep components small and single-responsibility. A component doing layout and filtering and export is three components.
  • Prefer inline templates for small components; external files once the template earns it. When external, use paths relative to the component's .ts.
  • Host bindings in the host metadata object — not @HostBinding / @HostListener.
  • Import standalone components, not their modulesMatButton, not MatButtonModule. Modules pull in the whole barrel. No unused entries in imports.
  • protected for template-only members, private for internals, readonly on injected dependencies and signals. This makes the template's actual API obvious.
  • No any, no as casts to silence a mismatch. A cast that makes an error go away has moved it to runtime. Prefer unknown and narrow.

On naming: the current Angular style guide drops the Component suffix (CarrierList, not CarrierListComponent), while a great many existing codebases keep it. Either is fine — pick one and enforce it in lint. The cost is inconsistency, not the choice.

2.4 The small-component escape hatch: rxResource

Not every component needs a store. If a component's entire data story is "fetch one thing by id, show a spinner while it loads, show an error if it fails," a store is real ceremony for no real benefit. Angular's rxResource covers that case directly:

@Component({
  selector: 'app-carrier-detail',
  templateUrl: './carrier-detail.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush, // redundant on v22+, required on v20/v21
})
export class CarrierDetailComponent {
  private readonly carriersApi = inject(CarriersApiService);

  readonly carrierId = input.required<string>();   // bound from the route

  // Re-fetches when carrierId changes; cancels the superseded request.
  protected readonly carrier = rxResource({
    params: () => this.carrierId(),
    stream: ({ params: id }) => this.carriersApi.loadCarrier(id),
  });
}
@if (carrier.isLoading()) {
  <app-spinner />
} @else if (carrier.error(); as error) {
  <app-error-banner [message]="error.message" />
} @else if (carrier.hasValue()) {
  <app-carrier-card [carrier]="carrier.value()" />
}

rxResource gives you value(), status(), isLoading(), error(), hasValue(), and reload() — the same shape a hand-rolled withCallState would, without the store. It takes a params function, so it is reactive by construction: the same "pass a signal, get automatic re-fetch and cancellation" idea as rxMethod, packaged for the single-fetch case. There is still no effect() anywhere.

Two footnotes. rxResource lives in @angular/core/rxjs-interop and is for services returning Observables; plain resource from @angular/core is the Promise-based equivalent, taking a loader instead of a stream and receiving an abortSignal you should forward to fetch. On versions: the option names are params/stream (the older request/loader pair is gone), and rxResource became stable in v22.0 after being experimental through v21. Stability removes the churn risk but not the architectural argument — keep it confined to small cases and let the store carry anything load-bearing. v22 also adds chain() for wiring one resource's output into another's params.

When to graduate to a store

rxResource is a legitimate tool with a narrow window. Use it only when all of these hold:

  • one read, no writes or mutations;
  • no state beyond the fetched value and its status;
  • the fetch is keyed by one signal (or one trivial computed);
  • nothing else in the app needs to observe this data.

The moment any of those stops being true, move to a signal store:

SignalWhy rxResource stops paying off
A second fetch appearsTwo resources with no coordination; nowhere for cross-cutting derived state
The component writes anything backMutations need an orchestrated pipeline (exhaustMap, optimistic update, refetch)
Selection, filters, sorting, or pagination arriveThat's feature state; it belongs in withState
Another component needs the same dataNothing to inject — you'd fetch twice
Polling, debouncing, or retry is neededrxMethod composes RxJS naturally; rxResource doesn't
You want the devtools timelineResource mutations aren't described actions

The failure mode to watch for is the slow slide: an rxResource plus a signal for selection, plus another signal for a filter, plus a computed stitching them together, plus a second resource — at which point you have a store with none of a store's structure, scattered across a component. Refactor at the second concern, not the fifth. Migrating one rxResource to one rxMethod is a ten-minute change; unpicking six component signals later is not.


Part 3 — Reactivity with signals

Part 2 covered where reactive code lives. This part covers the primitives themselves, because most bad signal code is a wrong pick from a small menu.

3.1 The decision table

Hand your agent this table before anything else in this section.

You needUseNotes
Local writable statesignal()set / update. Never mutate
A value derived from other signalscomputed()Pure, lazy, memoized
Writable state that resets when a source changeslinkedSignal()The most underused API in Angular
Async data fetched in reaction to signalsstore rxMethod, passed the signalCancellation + call state for free
A single read-only fetch keyed by one signalrxResourceTrivial cases only (§2.4). Stable as of v22.0
To trigger async work when a signal changespass the signal to rxMethodNever an effect()
Component inputsinput() / input.required()Not @Input()
Two-way bound inputmodel()
Component outputsoutput()Not @Output() + EventEmitter
A reference to a child element/componentviewChild() / viewChildren()Not @ViewChild
A reference to projected contentcontentChild() / contentChildren()Not @ContentChild
To read a signal without depending on ituntracked()
To consume an Observable as a signaltoSignal()Needs initialValue
To expose a signal as an ObservabletoObservable()
To touch the DOM after renderafterNextRender() / afterRenderEffect()Not effect()
To sync to a non-signal API (analytics, localStorage, canvas)effect()Last resort. Justify in a comment

3.2 signal — writable state

const count = signal(0);

count();                       // read — always call the getter
count.set(3);                  // replace
count.update((value) => value + 1);   // derive from previous

Three things to internalise:

Never mutate. It doesn't exist any more, and mutating an object inside update without returning a new reference defeats change detection, because signal equality is reference-based by default:

// ❌ same reference — dependents may not be notified
items.update((list) => { list.push(newItem); return list; });

// ✅ new reference
items.update((list) => [...list, newItem]);

Expose readonly state from services and stores. A public writable signal is a public setter for your internal state:

private readonly _count = signal(0);
readonly count = this._count.asReadonly();

Use a custom equal for expensive derivations. If a signal holds a structure that is often recomputed to an equivalent value, a comparator prevents downstream churn:

readonly filters = signal(defaultFilters, { equal: (a, b) => a.key === b.key });

3.3 computed — derived state

computed is lazy (the body doesn't run until something reads it), memoized (it only recomputes when a tracked dependency actually changes), and has dynamic dependencies — only the signals genuinely read on the last run are tracked, so a short-circuiting branch doesn't subscribe to signals it skipped.

readonly visible = computed(() =>
  this.carriers().filter((carrier) => carrier.risk >= this.threshold())
);

Rules: keep it pure — no writes, no I/O, no Date.now(), no side effects. Keep it cheap — see Part 9. And don't write pass-through computeds.

3.4 Reactive contexts, untracked, and the await trap

A reactive context is a runtime state where Angular records which signals you read, to build the dependency graph. Angular enters one when evaluating computed bodies, effect callbacks, linkedSignal computations, and component templates.

Two consequences agents routinely miss.

untracked() reads without depending. Use it when a reactive context needs a value but shouldn't re-run when it changes:

effect(() => {
  // Re-runs when `user` changes — but not when `sessionId` does.
  analytics.track('user_changed', user(), untracked(this.sessionId));
});

Tracking stops at the first await. The reactive context is only active for synchronous code, so a signal read after an await is invisible to the graph:

// ❌ theme() is never tracked — read after the await
effect(async () => {
  const data = await fetchData();
  applyTheme(theme(), data);
});

// ✅ read the signal first, then await
effect(async () => {
  const currentTheme = theme();
  const data = await fetchData();
  applyTheme(currentTheme, data);
});

The same rule applies inside computed — which is one of several reasons computed bodies must be synchronous and pure.

3.5 linkedSignal — writable state with a source of truth

This is the API agents know least and need most often. Whenever the requirement is "the user can change X, but X should reset when Y changes", that's linkedSignal — not an effect, and not a computed you then fight against.

// Selection is user-writable, but a new filter clears it.
readonly selectedId = linkedSignal<string, string | null>({
  source: this.filter,
  computation: () => null,
});

// Page returns to 1 whenever the search term changes.
readonly page = linkedSignal({ source: this.searchTerm, computation: () => 1 });

// Keep the selection if it still exists in the new list; otherwise pick the first.
readonly selected = linkedSignal<Carrier[], Carrier | undefined>({
  source: this.carriers,
  computation: (carriers, previous) =>
    carriers.find((carrier) => carrier.id === previous?.value?.id) ?? carriers[0],
});

That third form — where computation receives the previous value — is the shape that otherwise gets written as a buggy effect. It's a shortlist of recurring cases: a selected row that survives a refresh if still present, a draft value that reloads when the entity changes, a tab that falls back when the active tab disappears.

3.6 effect — the last resort

effect() is the single biggest source of unmaintainable signal code, so it gets two rules, stated as strongly as they deserve:

1. Avoid effect() wherever an alternative exists. In practice an alternative almost always exists.

2. Never write state from inside an effect(). No set, no update, no patchState. No exceptions.

An effect that writes signals creates a feedback edge in the reactive graph. The symptoms are glitchy intermediate renders, ExpressionChangedAfterItHasBeenChecked errors, infinite loops that only trigger for certain data, and — most insidiously — code that works until someone adds a second effect and the two start racing. Signals are a pull system; effect() is the one place you can smuggle push semantics in, and every time you do you give up the guarantee that makes the rest of the system predictable.

Nearly every effect an agent writes is one of three things in disguise. In order of how often they apply:

Triggering async work when a signal changes → rxMethod, given the signal.

// ❌ effect as a fetch trigger
constructor() {
  effect(() => { this.store.loadCarrier(this.carrierId()); });
}

// ✅ pass the signal; rxMethod re-runs on change and switchMap cancels the stale request
constructor() {
  this.store.loadCarrier(this.carrierId);
}

Deriving a value → computed.

// ❌ effect writing state
readonly filtered = signal<Carrier[]>([]);
constructor() {
  effect(() => {
    this.filtered.set(this.all().filter((c) => c.name.includes(this.filter())));
  });
}

// ✅ computed
readonly filtered = computed(() =>
  this.all().filter((carrier) => carrier.name.includes(this.filter()))
);

Resetting writable state when a source changes → linkedSignal.

// ❌ effect resetting state
constructor() {
  effect(() => { this.filter(); this.selectedId.set(null); });
}

// ✅ linkedSignal
readonly selectedId = linkedSignal({ source: this.filter, computation: () => null });

What's actually left for effect()? A short list: logging and analytics, syncing to localStorage, driving a non-signal third-party library (a charting or grid instance), and store-owned lifecycle like polling in withHooks.onInit. All of them share a shape — pushing signal state out to something that isn't reactive. None of them read state back in. Any surviving effect() should carry a comment naming which alternative was considered and why it didn't fit.

Also: effect() runs before the DOM is updated, and it must be created in an injection context. Use onCleanup for anything that needs tearing down.

3.7 DOM work: afterNextRender and afterRenderEffect

effect() is the wrong tool for reading or writing the DOM, because it runs before Angular has rendered. Use the render hooks — afterNextRender for one-shot setup, afterRenderEffect for reactive DOM work.

afterRenderEffect makes you split reads from writes into phases, which is what prevents layout thrashing:

export class TrendChart {
  private readonly canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');

  constructor() {
    afterRenderEffect({
      earlyRead: () => this.canvas().nativeElement.getBoundingClientRect().width,
      write: (width) => drawChart(this.canvas().nativeElement, width()),
    });
  }
}

Phases run in order: earlyReadwritemixedReadWrite (avoid) → read. Never read the DOM in write; never write in read. Neither hook runs during SSR, which makes them the correct home for browser-only work.

3.8 Inputs, outputs, and queries

Inputs are signals:

readonly name = input('Guest');                             // optional, with default
readonly carrierId = input.required<string>();              // required — compile-time checked
readonly label = input('', { alias: 'btnLabel' });          // aliased
readonly disabled = input(false, { transform: booleanAttribute });   // coerced

Transforms must be pure and statically analysable. Don't name an input after a DOM property (id, title). Derive from inputs with computed, never by copying into a local signal in an effect.

model() for two-way binding — [(value)]="mySignal" in the consumer.

Outputs are output():

readonly carrierSelected = output<string>();
readonly panelClosed = output<void>();

Name them for the event that happened (carrierSelected), not the handler (onSelect). They don't bubble, and they must not collide with native DOM event names.

Queries are signals too, and production-ready since v19:

readonly canvas = viewChild.required<ElementRef>('canvas');
readonly rows = viewChildren(RowComponent);
readonly projected = contentChildren(ItemComponent);

Signal queries compose with computed and need no lifecycle hook to be safe to read — a strict improvement over the decorator versions. ng generate @angular/core:signal-queries-migration converts existing code.

3.9 RxJS interop — and when to stay in RxJS

Signals are for state; RxJS is for events over time. Neither replaces the other.

Reach for RxJS when you need time-based operators — debounceTime, throttleTime, retry/retryWhen, combineLatest over async streams, switchMap cancellation. In this architecture that lands almost entirely inside store rxMethods, which is exactly where you want it.

At the boundary:

// Observable → signal. `initialValue` is required unless the source emits synchronously.
readonly connection = toSignal(this.socket.status$, { initialValue: 'connecting' as const });

// Signal → Observable, for feeding an RxJS pipeline.
readonly term$ = toObservable(this.searchTerm);

Prefer a store-derived signal over toSignal when the data already flows through a store — toSignal on top of a store is usually a sign the pipeline is in the wrong place. And note toSignal subscribes immediately and unsubscribes on destroy, so it needs an injection context.

If you truly must subscribe manually, takeUntilDestroyed() is not optional:

someEvents$.pipe(takeUntilDestroyed()).subscribe(handler);

Part 3 rules

  • Pick from the decision table; don't improvise.
  • set/update only, never mutate; always produce new references for objects and arrays.
  • Expose asReadonly() from services and stores.
  • computed is pure, cheap, and never a pass-through.
  • Read signals before any await.
  • linkedSignal for writable-state-with-a-source; it replaces most effects that reset state.
  • effect() is a last resort, never writes state, and carries a justifying comment.
  • DOM work goes in afterNextRender / afterRenderEffect, split into phases.
  • Signal input()/output()/viewChild()/contentChild() — never the decorator equivalents.
  • Keep RxJS inside store rxMethods; takeUntilDestroyed() on any manual subscription.

Part 4 — Dependency injection

DI is how this architecture is assembled and how it stays testable. It's also the part agents understand most shallowly — they know providedIn: 'root' and stop there.

4.1 Always inject(), always in an injection context

export class CarrierList {
  private readonly store = inject(CarriersStore);      // ✅ field initializer — preferred
  private readonly router = inject(Router);
}

Prefer inject() over constructor parameters: it works in functions (guards, interceptors, resolvers, rxMethod defaults), composes into reusable helper functions, and doesn't force inheritance boilerplate through constructor signatures in subclasses.

inject() only works inside an injection context, which exists in exactly four places:

  1. field initialisers of DI-instantiated classes (@Component, @Directive, @Injectable, @Pipe)
  2. constructor bodies of those classes
  3. factory functionsuseFactory, and an InjectionToken's factory
  4. functional APIs Angular invokes — route guards, resolvers, HTTP interceptors
export class Example {
  private readonly a = inject(ServiceA);      // ✅
  constructor() { const b = inject(ServiceB); }  // ✅
  onClick() { const c = inject(ServiceC); }    // ❌ throws — not an injection context
}

When you need a context outside those places — dynamic component creation, deferred work — capture an injector and use runInInjectionContext:

private readonly injector = inject(Injector);

createLater() {
  runInInjectionContext(this.injector, () => {
    const analytics = inject(AnalyticsService);   // ✅ now valid
  });
}

And if you write a helper that itself calls inject(), guard it so callers get a clear error rather than a confusing one:

export function injectHostElement<T extends Element>(): T {
  assertInInjectionContext(injectHostElement);
  return inject(ElementRef).nativeElement;
}

This also explains a rule from Part 2: NgRx signal store services are injected as default parameters of withMethods / withComputed / withHooks, because those factory functions run in an injection context while individual store methods do not.

withMethods((store, api = inject(CarriersApiService), router = inject(Router)) => ({ ... }))

4.2 The two injector hierarchies

Angular has two trees, searched in a specific order. Knowing this is the difference between predicting a NullInjectorError and guessing at one.

HierarchyConfigured byLifetime
EnvironmentInjectorprovidedIn: 'root', ApplicationConfig.providers, a Route's providersApp, or the route subtree
ElementInjectorproviders / viewProviders on @Component / @DirectiveThe component instance

Resolution order when something requests a dependency:

  1. Search up the ElementInjector tree, from the requesting component to the root element.
  2. If not found, search up the EnvironmentInjector tree, from the nearest environment injector to the root.
  3. If still not found, throw — unless marked optional.

The practical implication: a component-level provider shadows a root one for that component and its whole subtree. That's the mechanism behind component-scoped stores, and it's also how a well-meaning providers: [SomeService] accidentally gives a subtree its own copy of what should be a singleton.

4.3 Choosing a scope

ScopeHowUse for
Application singleton@Injectable({ providedIn: 'root' })API services, auth, config, logging, shared stores
Route subtreeproviders: [] on a RouteFeature state that should die when the user leaves the feature
Component instanceproviders: [] on @ComponentComponent-scoped stores, per-instance form state
Component view onlyviewProviders: [] on @ComponentSame, but hidden from projected content

providedIn: 'root' is the right default for stateless services: tree-shakable, zero boilerplate, one instance. But be deliberate about stateful singletons — a root-provided store that holds a selected row is shared by every consumer, whether you meant that or not.

providers vs viewProviders is a real distinction, not trivia: providers is visible to the component, its template, and anything projected in via <ng-content>; viewProviders hides the service from projected content. Use viewProviders when a service is an internal implementation detail that consumer-supplied content must not reach.

4.4 Providers beyond the shorthand

providers: [
  LocalService,                                            // shorthand for useClass: itself
  { provide: Logger, useClass: RemoteLogger },              // swap implementation
  { provide: API_URL, useValue: '/api/v2' },                // static value
  { provide: ApiClient, useFactory: () => new ApiClient(inject(HttpClient)) },  // computed
  { provide: LegacyLogger, useExisting: Logger },           // alias — same instance
  { provide: VALIDATORS, useClass: LengthValidator, multi: true },  // contribute to an array
]

useExisting aliases to the same instance; useClass creates a second one. Getting these confused produces two stores where you meant one.

4.5 InjectionToken — and when not to make one

Use an InjectionToken for anything that isn't a class: config objects, primitives, functions. Give it a default factory so it works without explicit registration:

export interface AppConfig {
  apiUrl: string;
  pageSize: number;
}

export const APP_CONFIG = new InjectionToken<AppConfig>('app.config', {
  providedIn: 'root',
  factory: () => ({ apiUrl: '/api', pageSize: 50 }),
});

Now the counter-rule, because this is where agents over-engineer. Do not invent a token when a plain service or a function parameter would do. A token is justified when:

  • the dependency isn't a class (config, primitive, function);
  • you need multiple competing implementations chosen at runtime;
  • you're contributing to a multi collection;
  • you need a test seam — a token with a production default factory is far better than vi.mock/jest.mock on a shared module, because the override is scoped to TestBed instead of mutating the test runner's module cache and leaking into unrelated suites.

A token is not justified because indirection feels architectural. The tell-tale smell is a token with exactly one provider, one consumer, and no test that overrides it — delete it and inject the class.

4.6 Resolution modifiers

// null instead of throwing when absent
readonly telemetry = inject(TelemetryService, { optional: true });

// skip this element's providers; start at the parent
readonly parentGrid = inject(GridComponent, { skipSelf: true });

// only this element's injector; don't walk up
readonly ownConfig = inject(PANEL_CONFIG, { self: true });

// stop at the host component's view boundary
readonly hostForm = inject(FormContainer, { host: true });

optional is the one you'll use most — genuinely useful for optional integrations. The others are for directive authors coordinating with ancestors. If application code is reaching for skipSelf or host, that's usually a sign the relationship should be an explicit input.

4.7 DI is your testing seam

The reason to care about all of this: DI is what makes the architecture testable without mocking frameworks.

TestBed.configureTestingModule({
  providers: [
    CarriersStore,
    { provide: CarriersApiService, useValue: { loadTopCarriers: () => of(FIXTURE) } },
  ],
});
const store = TestBed.inject(CarriersStore);

No HTTP mocking, no module mocking, no component mounting. This works because the store depends on an injected abstraction rather than reaching for HttpClient itself — which is the layering rule from Part 2, paying for itself.

Part 4 rules

  • inject() in field initialisers; never constructor params in new code.
  • Know the four injection contexts; use runInInjectionContext when outside them and assertInInjectionContext in helpers that call inject().
  • Inject store dependencies as default params of withMethods/withComputed/withHooks.
  • providedIn: 'root' for stateless services; component or route providers for stateful scoped things.
  • Understand that a component provider shadows a root one for its subtree.
  • viewProviders when projected content must not see the service.
  • InjectionToken for non-class dependencies and test seams — not for decoration. Delete single-provider, single-consumer, never-overridden tokens.
  • Prefer DI overrides in TestBed over module mocks.

Part 5 — Auth & security architecture

Component/store/service layering makes this tractable: auth is state (who is signed in, what they can do), so it lives in a store like everything else. The parts that are genuinely different from a feature store are token handling, request-level enforcement, and the fact that a mistake here is a security incident, not a bug ticket.

5.1 Where auth state lives

A root-provided AuthStore — the one legitimate case for a singleton stateful store, because the whole app needs to observe it:

// auth.store.ts
interface AuthState {
  user: User | null;
  accessToken: string | null;   // in memory only — see below
}

export const AuthStore = signalStore(
  { providedIn: 'root' },
  withState<AuthState>({ user: null, accessToken: null }),
  withComputed((store) => ({
    isAuthenticated: computed(() => store.user() !== null),
    permissions: computed(() => store.user()?.permissions ?? []),
  })),
  withMethods((store, auth = inject(AuthApiService)) => ({
    login: rxMethod<Credentials>(
      pipe(
        exhaustMap((credentials) =>
          auth.login(credentials).pipe(
            tapResponse({
              next: ({ user, accessToken }) => patchState(store, { user, accessToken }),
              error: () => patchState(store, { user: null, accessToken: null }),
            })
          )
        )
      )
    ),
    logout: () => patchState(store, { user: null, accessToken: null }),
  })),
  withLogger('AuthStore'),
);

Role/permission checks are a computed off this store, never a scattered string comparison in a template or component:

readonly canEditCarriers = computed(() => store.permissions().includes('carriers:write'));

5.2 Token storage: the single highest-stakes decision here

Never put an access or refresh token in localStorage or sessionStorage. Both are plain synchronous JS APIs — any XSS on the page, including a compromised third-party script, can read them and exfiltrate the session. This is the most common real-world Angular security defect, and it ships because localStorage.setItem is the path of least resistance, not because anyone decided it was safe.

Two acceptable patterns, in order of preference:

  1. httpOnly cookie, set by the server on login. JavaScript never touches the token at all; the browser attaches it automatically. This is the correct default for a first-party app talking to its own backend. Pair it with SameSite=Strict or Lax and a CSRF defense (§5.4), since cookies are sent automatically on cross-site requests too.
  2. In-memory only, as a signal in AuthStore. If the backend can't set cookies (a separate API domain, a mobile-shared auth server), hold the access token in a signal and nowhere else. It's lost on full page reload, which is the point — pair it with a httpOnly refresh cookie so a reload can silently re-establish the session without ever persisting the access token to disk.

Never persist a token by hand-rolling JSON.stringify into localStorage "just for the refresh token, that one's fine" — a leaked refresh token is a full session compromise with a longer blast radius than a leaked access token.

5.3 The auth interceptor, and the refresh race every implementation gets wrong

Attaching the token is the easy part:

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(AuthStore).accessToken();
  return next(token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req);
};

The part that's easy to get wrong is refreshing on a 401. A naive implementation lets every in-flight request independently detect the expiry and independently call the refresh endpoint — a "refresh storm" that can invalidate the very token the other requests are about to retry with. Share one in-flight refresh:

export const refreshInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(AuthStore);
  return next(req).pipe(
    catchError((error: HttpErrorResponse) => {
      if (error.status !== 401 || req.url.includes('/auth/refresh')) throw error;
      return auth.refresh$().pipe(
        switchMap(() => next(req)), // retry the original request once, with the new token
      );
    })
  );
};

refresh$() on the store should be the same shared Observable handed to every caller during the refresh window (an RxJS share({ connector: () => new ReplaySubject(1), resetOnError: true }) or equivalent) — not a new HTTP call per failed request. If refresh itself fails, clear the session and redirect to login; don't retry a refresh failure.

5.4 Guards enforce UX, the server enforces security

This is worth repeating from Part 1 because it matters most here: canActivate and canMatch decide what the UI shows, not what the user is allowed to do. Every permission check in a guard, a *ngIf/@if, or a disabled button must be re-checked server-side. An agent asked to "add an admin-only route" will happily write a guard and consider the feature secure — it isn't, until the API also rejects the request.

{
  path: 'admin',
  loadChildren: () => import('./features/admin/admin.routes'),
  canActivate: [authGuard, permissionGuard('admin:access')],  // UX gate
  // The /api/admin/* endpoints must independently reject non-admin tokens.
}

A conditional-rendering directive follows the same rule — it hides a button, it doesn't secure an action:

@if (store.canEditCarriers()) {
  <button (click)="edit()">Edit</button>
}

CSRF is only relevant to cookie-based auth — a pure bearer-token-in-header scheme has no CSRF surface, because a cross-site form or image tag can't set a custom header. If you're using cookies, Angular's HttpClient has built-in double-submit-cookie support:

provideHttpClient(
  withXsrfConfiguration({ cookieName: 'XSRF-TOKEN', headerName: 'X-XSRF-TOKEN' }),
),

The server must set the readable XSRF-TOKEN cookie (not httpOnly, deliberately — the client needs to read it and echo it as a header) and reject requests where the header is missing or doesn't match the cookie.

5.5 CSP and Trusted Types

A strict Content-Security-Policy is the single highest-leverage defense against XSS consequences, because it works even if a sanitization bug slips through review. In practice:

  • Serve a Content-Security-Policy header (or <meta> tag) that disallows unsafe-inline and unsafe-eval for scripts.
  • Angular's production build emits no inline scripts by default; if you add one (a bootstrap perf snippet, a third-party embed), it needs a nonce, and the nonce must be threaded through angular.json's ngCspNonce or an ngCspNonce attribute on the root element — not hand-added per script tag.
  • Consider a Trusted Types policy (require-trusted-types-for 'script') in security-critical apps. Angular's built-in sanitizer is Trusted-Types-aware; this closes off the DOM-XSS sinks (innerHTML, eval, script src) at the browser level, not just in your own code.

5.6 Sanitization and untrusted data — the boundary rule, applied

The anti-pattern catalogue already flags bypassSecurityTrust* and raw innerHTML. The rule underneath it: treat every external input as untrusted at the point it enters the app — API responses, route/query params, localStorage, postMessage, pasted rich text — and narrow or sanitize it once, at that boundary (typically the service's DTO-mapping function). A component or store that re-validates the same field five times downstream is a sign the boundary was drawn in the wrong place.

// service — the boundary
function toCarrier(dto: unknown): Carrier {
  const parsed = carrierSchema.parse(dto); // zod, or an equivalent runtime validator
  return { id: parsed.id, name: parsed.name, riskLevel: parsed.risk_level };
}

If the app must render user-supplied HTML (rich text, markdown-to-HTML), sanitize with a library built for it (DOMPurify) before the [innerHTML] binding, and keep that one call site auditable rather than sprinkling bypassSecurityTrustHtml around the codebase.

5.7 Secrets never ship in the client bundle

A .env value bundled into an Angular build is public — it ships in a JS file anyone can read. There is no such thing as a "secret" Angular environment variable. Anything the frontend needs (a public API key for a client-safe service, a base URL) is fine; anything that would grant access if leaked (a server API key, a signing secret, a database credential) must live behind the backend, called via your own API, never embedded directly.

Part 5 rules

  • No access or refresh token in localStorage/sessionStoragehttpOnly cookie or in-memory signal only.
  • Share one in-flight refresh Observable across concurrent 401s; never let each request trigger its own refresh call.
  • Guards and conditional rendering are UX. Every rule they enforce must also be enforced server-side.
  • CSRF defense (withXsrfConfiguration) only when using cookie-based auth.
  • Strict CSP, no unsafe-inline/unsafe-eval; nonce any inline script through ngCspNonce.
  • Sanitize/validate untrusted data once, at the service boundary — not repeatedly downstream.
  • Never bundle a real secret into the client — only the backend holds credentials that grant access.

Part 6 — Production observability

The withLogger feature from Part 2 gives you a devtools timeline — invaluable locally, gone the moment the tab closes. Production observability is a different concern: an error that happens on a customer's machine has to reach you, with enough context to reproduce it, without waiting for a bug report. Most Angular apps have devtools logging and nothing else; that gap is what this part covers.

6.1 Global error handling: the safety net under everything

Angular's default ErrorHandler logs to console.error and stops. Replace it, once, at the root:

// global-error-handler.ts
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
  private readonly reporter = inject(ErrorReportingService);

  handleError(error: unknown): void {
    // Never let reporting itself throw — a reporting bug must not become a second error.
    try {
      this.reporter.report(error, {
        url: location.href,
        buildVersion: environment.buildVersion,
      });
    } catch {
      console.error('Error reporting failed', error);
    }
    console.error(error); // keep the console signal for local dev
  }
}
// app.config.ts
providers: [
  { provide: ErrorHandler, useClass: GlobalErrorHandler },
],

This is the backstop, not the primary error-handling path — it catches what falls through template bindings, unhandled promise rejections Angular routes to it, and bugs nobody anticipated. It should never be the only place an error is reported; see below.

6.2 Normalize HTTP errors once, at the interceptor

Every store's tapResponse error branch currently gets a raw HttpErrorResponse or whatever the service threw. Without normalization, each store re-derives "is this a network failure, a 4xx, a 5xx, a validation error" independently — and inevitably inconsistently. Do it once:

export const errorNormalizationInterceptor: HttpInterceptorFn = (req, next) =>
  next(req).pipe(
    catchError((error: unknown) => {
      throw toDomainError(error); // { kind: 'network' | 'validation' | 'server' | 'auth', message, status }
    })
  );

Stores then branch on kind, not on parsing error.status themselves, and the shape you report to the error-reporting service is the same shape you show the user.

6.3 Report from the store's error branch too, not only the global handler

A tapResponse error branch that only calls setError() updates the UI but reports nothing — the failure is invisible outside that one user's screen. The two concerns are separate and both need to happen:

tapResponse({
  next: (carriers) => patchState(store, { carriers }, setSuccess()),
  error: (error: DomainError) => {
    patchState(store, { carriers: [] }, setError(error.message));       // UI-facing
    reporter.report(error, { action: 'loadTopCarriers', range });       // observability-facing
  },
}),

Skipping the second line is the most common gap in otherwise-correct store code: the withCallState pattern makes failures render correctly, which quietly removes the pressure to also make them observable.

6.4 Correlation IDs: connect a report back to what the user was doing

A stack trace with no context is close to useless. Generate a correlation ID per navigation (or reuse a request ID per HTTP call) and thread it through:

export const correlationIdInterceptor: HttpInterceptorFn = (req, next) =>
  next(req.clone({ setHeaders: { 'X-Correlation-Id': crypto.randomUUID() } }));

Attach the current correlation/session ID to every error report and every backend log line that handles the request, so a frontend error and the backend log entry that caused it can be joined by ID during an incident.

6.5 Breadcrumbs, without PII

Leave a short trail of recent actions (route changes, key store mutations, the actionPatchState strings already flowing through withLogger) so an error report reads as a story, not an isolated stack trace. Pipe the same descriptive strings you're already writing for withLogger into the production reporter's breadcrumb API — you're not inventing a second logging vocabulary, just giving the existing one a second destination.

Do not put user-identifying data (email, name, full request payloads) in breadcrumbs or error context beyond an opaque user/session ID. A breadcrumb trail is exactly the kind of place PII accumulates by accident.

6.6 Real User Monitoring (Core Web Vitals)

Bundle-size and computed-cost discipline (Part 9) is necessary but not sufficient — it tells you what should be fast, not what's actually fast for real users on real networks. Report field metrics:

import { onLCP, onINP, onCLS } from 'web-vitals';

onLCP((metric) => reporter.metric('LCP', metric.value, { route: currentRoute() }));
onINP((metric) => reporter.metric('INP', metric.value, { route: currentRoute() }));
onCLS((metric) => reporter.metric('CLS', metric.value, { route: currentRoute() }));

Tag every metric with the route (and ideally build version), so a regression shows up as "LCP got worse on /carriers after build 482," not as an unattributed aggregate trend.

6.7 Source maps: ship them to the reporter, not to the public

Production builds should emit source maps ("sourceMap": true in the production build config, or a hidden-source-map variant) and upload them directly to the error-reporting backend during CI — not serve them alongside the public bundle. That gets you readable stack traces in reports without handing every visitor an unminified copy of your source.

Part 6 rules

  • Install one global ErrorHandler; make its own reporting call fail closed (never throw).
  • Normalize HTTP errors once, at an interceptor — stores branch on the normalized shape.
  • Every store's error branch reports to observability and sets UI-facing call state; one without the other is a gap, not a simplification.
  • Thread a correlation ID through requests and attach it to every error report.
  • Breadcrumb recent actions; never put PII in them.
  • Report Core Web Vitals per route, not just lab metrics from a local Lighthouse run.
  • Upload source maps to the reporting backend in CI; don't ship them publicly.

Part 7 — Forms

Angular has three form systems now. Pick by version and by what already exists in the codebase — don't mix approaches within one feature.

SituationUse
New form, Angular 22+Signal Forms (@angular/forms/signals) — stable
New form, Angular 21Signal Forms, but they were still experimental; Reactive Forms if you need stability guarantees
New form, Angular 20Reactive Forms (FormGroup / typed) — Signal Forms don't exist yet
Existing formMatch whatever that feature already uses
Trivial single input, existing template-driven codebaseTemplate-driven is acceptable

Never mix reactive and template-driven bindings on the same control. It's a classic agent error and produces state that updates through one path and not the other.

7.1 Signal Forms (v21+, stable since v22)

Signal Forms derive the entire form structure from a signal model, which makes them a natural fit for everything else in this guide:

export class CarrierForm {
  // CRITICAL: never null or undefined as initial values.
  // '' for strings, 0 for numbers, [] for arrays.
  protected readonly model = signal({
    name: '',
    contactEmail: '',
    maxWeightKg: 0,
    regions: [] as string[],
  });

  protected readonly form = form(this.model, (path) => {
    required(path.name, { message: 'Name is required' });
    maxLength(path.name, 80);
    email(path.contactEmail);
    min(path.maxWeightKg, 1);
  });
}

Validators (required, email, min, max, minLength, maxLength, pattern), field-state rules (disabled, hidden, readonly, debounce), schema helpers (applyWhen, applyEach, schema), and custom validation (validate, validateHttp) all come from @angular/forms/signals. Submission goes through submit().

The one hard rule worth repeating because it bites immediately: no null or undefined in the model. Use the type's empty value.

New in v22: minDate() / maxDate() validators, blur-based debouncing, and getError() for reading a specific validation error.

If you're moving off v21's experimental API, three things changed at stabilisation:

  • touched became an input paired with a touch() output (it was a model).
  • markAsTouched() now marks descendants by default — pass { skipDescendants: true } to opt out.
  • Conditional validators take a when option instead of a reactive function passed directly.

7.2 Reactive Forms (v20, or existing code)

Use the typed APIs and validate declaratively. Two zoneless-era cautions:

  • Under zoneless, setValue/patchValue do not schedule change detection. If a template depends on form state, bridge it with markForCheck() or reflect it through a signal (§1.9).
  • Don't subscribe to valueChanges in a component to keep a field in sync — that's a subscription plus derived state. Convert to a signal with toSignal and derive with computed, or let the form model be the source of truth.

7.3 Where form submission lives

Validation is the form's job. Submission is the store's job — it's a network call with loading state, error handling, and a success path, which is exactly what an rxMethod is for:

// In the store — exhaustMap so a double-click can't submit twice.
saveCarrier: rxMethod<CarrierDraft>(
  pipe(
    exhaustMap((draft) => {
      actionPatchState('saveCarrier(): saving', store, setPending());
      return api.saveCarrier(draft).pipe(
        tapResponse({
          next: (saved) => actionPatchState('saveCarrier(): saved', store, { carrier: saved }, setSuccess()),
          error: (error: Error) => actionPatchState('saveCarrier(): failed', store, setError(error.message)),
        })
      );
    })
  )
),

The component's submit handler is one line: validate, then store.saveCarrier(value).


Part 8 — Templates, styling, accessibility

8.1 Templates

Native control flow only — @if, @for, @switch. The legacy structural directives are slower, need imports, and are on their way out.

@if (store.pending()) {
  <app-spinner />
} @else if (store.error(); as error) {
  <app-error-banner [message]="error" />
} @else {
  <ul>
    @for (carrier of carriers(); track carrier.id) {
      <li>{{ carrier.name }}</li>
    } @empty {
      <li>No carriers in this period.</li>
    }
  </ul>
}
  • track is required on @for and must be a stable identity, not $index. Getting this wrong destroys DOM reuse and, in lists with inputs or form controls, silently rebinds state to the wrong rows.
  • @if (expr; as alias) and @else if (expr; as alias) save recomputing a value you're already testing — and give you correct type narrowing.
  • @empty on @for replaces the @if (list.length === 0) companion block.
  • @switch uses strict equality with no fallthrough. @default never; makes the switch exhaustive at compile time over a union — genuinely valuable, and almost never used.
  • Keep templates dumb. No arithmetic chains, no .filter().map().sort(), no function calls that allocate. Move it to a computed.
  • Don't assume globals. new Date() and window are not safe in a template (or under SSR — see §1.8).
  • Import every pipe you use; the compiler won't find it for you.
  • Use NgOptimizedImage for static images — it enforces width/height, sets priority hints and srcset. It does not work for inline base64.
  • Prefer semantic HTML. A <button> gives you focus, keyboard activation, and the right role for free; a <div (click)> gives you a bug report from a screen-reader user.

8.2 Styling

  • Every component's styles are its own. Angular's default emulated encapsulation is the right setting — don't reach for ViewEncapsulation.None.
  • A component should render correctly anywhere. If it depends on an ancestor's styles to look right, it isn't finished. Own your spacing and layout locally rather than assuming a parent provides it.
  • ::ng-deep is a smell. It's deprecated, it leaks, and its presence almost always means a design-system component or token should have been used instead. If you must, scope it under :host and comment why.
  • Wrap component styles in :host. It bounds the blast radius and makes it obvious the file styles one component.
  • Tokens, not literals. Colours, spacing, and typography come from design-system variables — and every new token needs both light and dark values. Hardcoded hex is how a theme rots.
  • Use logical properties (margin-inline-start, not margin-left) so RTL works for free — this is what makes i18n's RTL support (§1.7) free rather than a parallel stylesheet.
  • Pick a class-naming convention (BEM is a fine default) and apply it consistently. Style with classes, not id selectors.

8.3 Accessibility

Not optional, and cheap when done as you go. The official bar:

Must pass all AXE checks. Must meet WCAG AA minimums — focus management, colour contrast, and ARIA attributes.

Practical checklist:

  • Semantic elements first; ARIA only to fill genuine gaps. A correct native element beats role="button" every time.
  • Every interactive control is keyboard-reachable and has a visible focus indicator.
  • Manage focus on navigation, dialog open/close, and dynamic content insertion.
  • Every input has a programmatically associated label; icon-only buttons get an aria-label.
  • Announce async state changes — a live region for "loading", "saved", "3 results" — so the experience isn't silent for screen-reader users.
  • Never encode meaning in colour alone; check contrast in both themes.
  • Images: meaningful alt, or alt="" when decorative.
  • Building a complex widget (combobox, tree, tabs, grid)? Use a headless accessible primitive or a design-system component. Hand-rolled ARIA for these patterns is nearly always wrong.

Part 9 — Performance

Ordered by how often it actually matters.

Bundle size first. Lazy-load every feature route; @defer heavy in-page content; check what a new dependency costs before adding it. This dominates real-world load performance and is the cheapest thing to get right early.

Paginate every list. No unbounded fetches that grow with customer data. A query that's fast on your seed data and fatal on a large account is the most common production surprise in this category. Blocking, not a suggestion.

Keep computed and template expressions cheap. They re-evaluate on change detection. Pure derivations are fine; anything iterating a large collection, formatting hundreds of rows, or allocating per call needs precomputing. This matters most in hot paths — grid cell renderers, valueFormatter callbacks, table bindings.

Don't allocate in bindings. A fresh object or array literal passed to an input() is a new reference every pass, which defeats every memoization downstream:

<!-- ❌ new object every change detection cycle -->
<app-chart [config]="{ height: 400, animated: true }" />

<!-- ✅ stable reference from a computed or a readonly field -->
<app-chart [config]="chartConfig()" />

track correctly on @for. A wrong track turns a cheap list update into a full DOM teardown and rebuild.

No N+1 fetches. Fetch a list with the fields you need, not a list followed by a per-row detail request. Requesting extra fields on one call beats fifty calls.

Keep the main thread free on interaction. Heavy synchronous work in a click handler, constructor, or ngOnInit is visible jank. Defer it, chunk it, or move it to a worker.

Then measure. OnPush plus signals plus zoneless already eliminates most historic Angular change-detection cost. Beyond the items above, profile before optimising — a Lighthouse run and a DevTools performance trace will tell you more than intuition.


Part 10 — Testing

The layering changes what tests cost, and where they should live.

LayerTest styleCoverage weight
util/ pure functionsPlain unit tests, no TestBedCheapest — cover thoroughly
ServicesAssert request shape and response mappingLight
StoresStub the service via DI, call methods, assert signalsHeaviest — behaviour lives here
Presentational componentsRendered output + interaction (Storybook / harnesses)Moderate
Container componentsE2E or interaction tests, not unit testsLight
Critical flowsE2EA few, high-value

A useful heuristic: if a component is hard to test, the test is telling you the component is doing a job that belongs to a store.

10.1 Store tests are the main event

describe('CarriersStore', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        CarriersStore,
        { provide: CarriersApiService, useValue: { loadTopCarriers: () => of(FIXTURE) } },
      ],
    });
  });

  it('populates carriers and marks the call successful', async () => {
    const store = TestBed.inject(CarriersStore);
    store.loadTopCarriers(RANGE);
    await Promise.resolve();

    expect(store.carriers()).toHaveLength(5);
    expect(store.pending()).toBe(false);
    expect(store.error()).toBeNull();
  });
});

No DOM, no component, no change-detection choreography. Assert on outcomes (signal values) rather than implementation details (which service method got called how many times).

10.2 Zoneless testing changes the rules

  • If zone.js isn't in polyfills, TestBed runs zoneless automatically. If it is still loaded but the app is zoneless, add provideZonelessChangeDetection() to the test providers so tests match production.
  • Prefer await fixture.whenStable() over fixture.detectChanges(). detectChanges() forces a pass Angular might never have scheduled, which hides real missing-notification bugs.
  • TestBed now enforces OnPush-compatibility and throws ExpressionChangedAfterItHasBeenCheckedError if a template value changed without a notification — including from a test doing fixture.componentInstance.value = 'x'. That's a real finding: fix the component to use signals rather than papering over it.

10.3 Test hygiene that prevents cross-suite flakiness

  • Prefer DI overrides to module mocks. vi.mock / jest.mock on a shared module mutates the runner's module cache and can break unrelated suites — especially with test isolation disabled for speed. A token or provider override is scoped to TestBed.
  • Restore anything global you touchDate, Math.random, crypto, localStorage. vi.spyOn(...) plus restoreAllMocks() in afterEach, never a raw Object.defineProperty(globalThis, ...).
  • No done() callbacks. Use async/await, firstValueFrom, fake timers, or return a promise, so a failure is owned by the test that caused it.
  • Don't reach for isolate: true, forked pools, or big timeout bumps to fix flakiness. Those hide shared-state bugs; find the leak.
  • Test through public behaviour, and use component harnesses rather than CSS selectors when driving design-system components.

Anti-pattern catalogue

The compressed version — useful as an agent's review checklist.

Architecture

Anti-patternWhy it's wrongDo instead
API service injected into a componentBypasses state, invisible in devtools, untestableInject the store; call a store method
Fetch orchestration in the componentNo cancellation, duplicated retry logicrxMethod in the store
One feature importing another featureCyclic coupling; nothing can be extracted laterMove the shared part to data-access / ui / util
Eager-loading every routeInitial bundle grows without boundloadChildren / loadComponent per feature
Guard treated as securityTrivially bypassed client-sideEnforce server-side; the guard is UX
Resolver on a slow endpointNavigation blocks with no feedbackNavigate, then render store call state
NgModule in new codeObsoleteStandalone + provide* functions
Directories by type (services/, stores/)Every change touches four placesColocate by feature

Reactivity

Anti-patternWhy it's wrongDo instead
effect() that writes stateFeedback loop in the reactive graphcomputed / linkedSignal
effect() to trigger a fetchReinvents rxMethod, minus cancellationPass the signal to the rxMethod
effect() for DOM workRuns before renderafterNextRender / afterRenderEffect
effect() inside withMethodsNo injection context — throwswithHooks.onInit
Calling rxMethod with signal() not signalFetches once; never re-fetchesPass the signal itself, unread
Reading a signal after await in a reactive contextSilently untrackedRead before the await
Pass-through computedPure noiseUse the signal directly
Mutating an object inside updateSame reference; view may not updateReturn a new reference
Copying an input into a signal in an effectTwo sources of truthcomputed, or linkedSignal
.subscribe() in a componentLeak + shadow copy of stateSignals, or async pipe
.subscribe() in a store methodLeak; a thrown error kills the pipeline permanentlytapResponse
Manual subscription without takeUntilDestroyed()LeakAdd it, or don't subscribe
@ViewChild / @Input / @Output decorators in new codeLegacy; worse ergonomics and typingviewChild() / input() / output()

State

Anti-patternWhy it's wrongDo instead
Hand-rolled loading / error booleansReset paths get missed; error never clears on retryShared withCallState feature
Derived value stored in stateDrifts from its sourcewithComputed
Transient value stored in stateState grows without a consumerMethod parameter
Multiple withComputed / withMethods blocksInvisible ordering couplingOne of each
Bare patchState with no descriptionUnreadable devtools timelineactionPatchState('method(): why', ...)
Missing withLoggerAnonymous store in devtoolsAlways last, always named
mergeMap for a searchOut-of-order responses overwrite newer stateswitchMap
Root-provided store holding per-view stateLeaks between consumersComponent or route providers
rxResource in a component that also owns selection/filters/writesA store with no structureMigrate to a signal store at the second concern

DI, templates, and the rest

Anti-patternWhy it's wrongDo instead
Constructor injection in new codeDoesn't compose into functionsinject() in a field initialiser
inject() in a methodThrows — not an injection contextField initialiser, or runInInjectionContext
InjectionToken with one provider and one consumerIndirection with no payoffInject the class
vi.mock on a shared moduleMutates the runner's module cache; leaks across suitesDI override in TestBed
standalone: true in a decoratorDefault since v19; an error in v20+Delete it
ChangeDetectionStrategy.DefaultDeprecated in v22, due to be removedOnPush (the v22+ default)
ChangeDetectionStrategy.EagerOpts out of OnPush; usually an ng update leftover, not a decisionDelete it and fix whatever relied on eager checking
ngClass / ngStyleSlower, needs an import[class.x] / [style.x]
*ngIf / *ngForLegacy@if / @for
@for without stable trackFull DOM rebuild; state rebinds to wrong rowstrack item.id
$index as trackSame as no track when the list reordersStable identity
@HostBinding / @HostListenerLegacyhost metadata object
Importing MatXModule instead of MatXPulls in the whole barrelImport the standalone symbol
::ng-deepDeprecated; leaks; signals a design-system bypassDesign-system component or token
Hardcoded colour / spacingBreaks theming and dark modeDesign tokens, both themes
id selectors in templatesCollides; not reusableClasses
<div (click)> for an actionNo keyboard, no role, fails AXE<button>
any, or as to silence an errorMoves a compile error to runtimeunknown + narrowing; fix the type
Unbounded list fetchGrows with customer data until it doesn'tPaginate
Object literal in a template bindingNew reference every cyclecomputed
fixture.detectChanges() under zonelessHides missing-notification bugsawait fixture.whenStable()
Trusting external data (params, localStorage, postMessage)Injection and crash surfaceNarrow at the boundary
bypassSecurityTrust* / innerHTML without causeXSSDon't; if unavoidable, prove safety at the call site

Security, observability, and scale

Anti-patternWhy it's wrongDo instead
Access/refresh token in localStorage/sessionStorageAny XSS reads it synchronously; full session compromisehttpOnly cookie, or in-memory signal only
Each failed request triggers its own token refreshRefresh storm; can invalidate the token other requests are retrying withOne shared, in-flight refresh Observable
Guard treated as the security boundaryTrivially bypassed client-sideGuards are UX; enforce every rule server-side too
Store's tapResponse error branch only calls setError()Failure renders correctly but is invisible outside that user's screenAlso report to the observability service
Only a global ErrorHandler, no per-store reportingErrors caught downstream lose the context of what the user was doingReport at the point of failure, with action context
PII in breadcrumbs or error contextTurns an incident tool into a privacy incidentOpaque user/session ID only
Secret/API key embedded in the client bundleShips to every visitor as plain, readable JSSecrets stay server-side; call your own API
Two stores independently patchState the same entityWhichever wrote last wins; the other silently goes staleOne entity store owns the write; readers derive via computed
Translated strings built by concatenationBreaks word order and pluralization in most other languagesThe i18n library's interpolation/pluralization support
Dates/currency formatted with a template literalWrong symbol placement, decimal separator, digit grouping per localeDatePipe / CurrencyPipe / Intl.*
Feature flag with no owner or removal conditionPermanent second code path, tested half as often as the primary oneOwner + removal condition at creation; test both branches
Reading window/document/Date.now() during SSR renderServer and client compute different output → hydration mismatch, silent client re-renderGuard browser-only reads; keep render output deterministic

Appendix: rules block for your agent

Paste into CLAUDE.md, AGENTS.md, .cursorrules, or a system prompt. Deliberately terse and imperative — agents follow directives better than prose.

## Modern Angular rules (v20+, NgRx Signals)

### Version baseline
- Current release is v22. v20 and v21 are in LTS. Check `@angular/core` before applying
  anything version-gated below.
- v22: `OnPush` is the default strategy, Signal Forms and the Resource API are stable,
  TypeScript 6 and Node 22+ required, `HttpClient` defaults to Fetch.
- v21: zoneless became the default; Signal Forms shipped experimental.
- v20: zoneless needs `provideZonelessChangeDetection()`; no Signal Forms.

### Architecture
- Standalone only. `bootstrapApplication` + `ApplicationConfig`. No NgModule.
- Never write `standalone: true` — it is the default and an error in v20+.
- Expose feature/library config via `provide*` functions returning `EnvironmentProviders`.
- Enable `provideRouter(routes, withComponentInputBinding())` and bind route params as
  signal `input()`s. Do not use ActivatedRoute subscriptions in new code.
- Group by feature and colocate (component + template + styles + store + spec together).
- Dependency direction: features → data-access/ui/util → nothing. A feature MUST NOT
  import another feature. Enforce with lint, not documentation; graduate to real Nx
  libraries with `depConstraints` once folders and lint stop being enough.
- Lazy-load every feature route (`loadChildren` / `loadComponent`). Eager-load only shell
  and landing.
- `canMatch` for existence (feature flags, licensing); `canActivate` for permission.
  Guards are UX — every rule must also be enforced server-side.
- Gate feature flags at all three layers (route `canMatch`, template `@if`, store method)
  through one `FeatureFlagsService`. Every flag ships with an owner and a removal condition.
- Avoid resolvers on slow endpoints; navigate and render store call state instead.
- `@defer (on viewport)` for heavy below-the-fold content, with `@placeholder` and
  `@loading (after Xms; minimum Yms)` to prevent flicker.
- Pick an i18n strategy (compile-time `$localize` vs a runtime library) before the first
  component ships. Format dates/numbers/currency via `Intl`/pipes, never string templates.
  Never concatenate translated fragments.
- Decide SSR/prerender per route, not per app. Avoid reading `window`/`document`/`Date.now()`
  during render — it causes hydration mismatches.
- Assume ZONELESS (default in v21+): signals drive the view. `OnPush` everywhere.
  NgZone.onStable/onMicrotaskEmpty/isStable are dead — use afterNextRender/afterEveryRender.
  Reactive-forms setValue/patchValue do NOT trigger CD; bridge via signal or markForCheck.
  Wrap SSR-critical async work in `PendingTasks.run()`.

### Layering (non-negotiable)
- Data flow is Component → Store → Service. A component MUST NOT inject or call a service
  that performs I/O (HttpClient, GraphQL client, fetch, resource).
- Stateless, I/O-free services (formatters, calculators) MAY be injected into components.
- Fetch/poll/retry/cancel orchestration belongs in a store `rxMethod`, never in a component.
- Cross-cutting HTTP concerns (auth headers, retry, error normalisation, correlation IDs)
  go in functional interceptors registered at bootstrap — not repeated per service.

### Store vs. rxResource
- Default to a signal store.
- `rxResource` is permitted ONLY when all hold: one read; no writes; no state beyond the
  fetched value and its status; keyed by one signal or one trivial computed; no other
  consumer. (Stable as of v22.0; experimental through v21.)
- Graduate to a store at the SECOND concern — a second fetch, any write-back, selection,
  filtering, sorting, pagination, polling, debouncing, retry, or a second consumer. Never
  accumulate an rxResource plus loose component signals.
- `rxResource` takes `params` (a function) and `stream`. Never wrap it in an `effect()`.

### Services
- One method per operation, named for the operation. Return a cold Observable.
- Map DTO → domain model inside the service. No state, no caching, no loading flags.
- `providedIn: 'root'` unless scoping is required. Single responsibility.
- Name I/O services `*-api.service.ts` so the no-component-injection rule is lintable.

### Stores (NgRx Signal Store)
- Feature order: withState → withCallState → withEntities? → withComputed → withMethods
  → withHooks? → withLogger. Exactly ONE withComputed and ONE withMethods block.
- `withLogger('XxxStore')` mandatory, always last, named to match the export. Devtools-only
  — pair it with production error reporting (see Observability), which is a separate concern.
- State is flat. Derived values go in withComputed, never in state. Transient/single-use
  values are method parameters, not state.
- Use the shared `withCallState` feature for loading/error. Never hand-roll boolean flags.
- Never let two stores independently `patchState` the same conceptual entity. One entity
  store (or a single owning store) writes; other stores derive via `computed`.
- Every mutation: `actionPatchState('method(): why', store, ...)`.
- Async flows use `rxMethod` + `tapResponse`. NEVER `subscribe()` inside a store.
- To make a fetch reactive, call the rxMethod with the SIGNAL, not the read value:
  `store.load(this.id)` — never `store.load(this.id())` inside an `effect()`. Combine
  multiple dependencies into one `computed` and pass that. Call it from a field initialiser
  or the constructor (needs an injection context).
- Flattening operator is a decision: switchMap (latest wins, default for reads), exhaustMap
  (one at a time, submits), concatMap (ordered mutations). mergeMap almost never.
- Inject services as default params of withMethods/withComputed/withHooks — never at store
  scope. `inject()` outside those blocks throws.
- Effects belong in `withHooks.onInit` with `effect((onCleanup) => ...)`. Never in withMethods.
- Default to component-scoped (`providers: [XxxStore]`); route `providers` for feature-scoped;
  `providedIn: 'root'` only for genuinely shared state.
- Over ~200 lines, extract pure functions to `*.computed.ts` / `*.methods.ts`; store methods
  become 1–5 line wrappers.

### Reactivity
- Pick from this menu, do not improvise:
  local writable state → `signal()`; derived → `computed()`; writable-but-resets-on-source
  → `linkedSignal()`; async keyed on signals → store `rxMethod` given the signal;
  DOM after render → `afterNextRender`/`afterRenderEffect`; sync to a non-reactive API →
  `effect()` (last resort).
- `set`/`update` only — never `mutate`. Always return NEW references from `update`.
- Expose `asReadonly()` from services/stores; never a public writable signal.
- `computed` must be pure and cheap. NO pass-through computed.
- Read signals BEFORE any `await` — tracking stops at the first await.
- `untracked()` to read without depending.
- NEVER write state from inside an `effect()` — no set/update/patchState. Hard blocker.
- AVOID `effect()` wherever an alternative exists. Any surviving effect carries a comment
  naming the alternative considered and why it didn't fit.
- Signal APIs only: `input()`, `input.required()`, `model()`, `output()`, `viewChild()`,
  `viewChildren()`, `contentChild()`, `contentChildren()`. Never the decorator versions.
- Name outputs for the event (`carrierSelected`), not the handler (`onSelect`).
- Keep RxJS inside store rxMethods. `toSignal` needs `initialValue`. Any manual subscription
  requires `takeUntilDestroyed()`.

### Components
- Every component runs under `OnPush`. It is the DEFAULT in v22+ — do not write it there. On
  v20/v21 set `changeDetection: ChangeDetectionStrategy.OnPush` explicitly. Never use
  `ChangeDetectionStrategy.Eager` or the deprecated `Default`.
- NO `.subscribe()`. Only exception: dialog `afterClosed()`.
- Small and single-responsibility. Inline templates for small components; external files use
  paths relative to the component .ts.
- `protected` for template-only members, `private` for internals, `readonly` on injected deps.
- Host bindings in the `host` metadata object — not @HostBinding/@HostListener.
- Import standalone components (`MatButton`), not modules (`MatButtonModule`). No unused
  entries in `imports`; no unused TS imports.

### Dependency injection
- `inject()` in field initialisers. No constructor injection in new code.
- Injection contexts are: field initialisers, constructors, factory functions, and
  Angular-invoked functional APIs (guards, resolvers, interceptors). Outside those use
  `runInInjectionContext`; in helpers that call inject() use `assertInInjectionContext`.
- `providedIn: 'root'` for stateless services; component/route `providers` for stateful
  scoped things. Remember a component provider SHADOWS a root one for its subtree.
- `viewProviders` when projected content must not see the service.
- `InjectionToken` only for non-class deps, competing implementations, `multi` collections,
  or a genuine test seam. Delete single-provider/single-consumer/never-overridden tokens.
- `useExisting` aliases the same instance; `useClass` creates a new one. Don't confuse them.
- `inject(X, { optional: true })` for optional integrations.

### Auth & security
- NEVER store an access or refresh token in `localStorage`/`sessionStorage`. `httpOnly`
  cookie or an in-memory signal only.
- Share ONE in-flight token-refresh Observable across concurrent 401s. Never let every
  failed request trigger its own refresh call.
- Guards (`canActivate`/`canMatch`) and conditional rendering are UX only — every rule they
  enforce MUST also be enforced server-side.
- CSRF defense (`withXsrfConfiguration`) only applies to cookie-based auth.
- Strict CSP: no `unsafe-inline`/`unsafe-eval`. Nonce any inline script via `ngCspNonce`.
- Sanitize/validate untrusted external data once, at the service boundary.
- No secrets (API keys, signing keys, credentials) in the client bundle — ever.

### Observability
- Install one global `ErrorHandler` whose own reporting call cannot throw.
- Normalize HTTP errors once, at an interceptor, into a shared domain-error shape.
- Every store's `tapResponse` error branch must both set UI-facing call state AND report to
  the observability service — one without the other is an incomplete implementation.
- Thread a correlation ID through requests; attach it to every error report.
- Breadcrumb recent actions for context; never put PII in breadcrumbs or error metadata.
- Report Core Web Vitals (LCP/INP/CLS) per route in production, not just local Lighthouse.

### Forms
- v22+ new forms → Signal Forms (`@angular/forms/signals`), now stable. v21 → Signal Forms were
  experimental; prefer Reactive Forms if you need stability guarantees. v20 → Reactive Forms
  (Signal Forms don't exist). Existing features → match what's there (typed Reactive Forms
  preferred).
- Signal Forms models: NEVER null/undefined initial values — '' for strings, 0 for numbers,
  [] for arrays.
- Never mix reactive and template-driven bindings on one control.
- Validation is the form's job; SUBMISSION is a store rxMethod (exhaustMap).

### Templates
- `@if` / `@for` / `@switch` only. `track` on every `@for` and it must be a stable identity,
  never `$index`. Use `@empty`. Use `@default never;` for exhaustive switches.
- Keep templates dumb — no filter/map/sort chains, no allocating calls. Move to `computed`.
- No object/array literals in bindings (new reference every CD cycle).
- Don't assume globals (`new Date()`, `window`) — this also causes SSR hydration mismatches.
  Import every pipe used.
- `NgOptimizedImage` for static images (not base64).
- Semantic HTML: `<button>` for actions, never `<div (click)>`.

### Styling
- Wrap component styles in `:host`. Keep default encapsulation.
- Components own their spacing/layout and must render correctly anywhere — never depend on an
  ancestor's styles.
- No `::ng-deep` (deprecated, leaks, signals a design-system bypass).
- Design tokens for colour/spacing/typography — never hardcoded values. New tokens need
  light AND dark values.
- Logical properties (`margin-inline-start`). Consistent class naming (BEM). Classes, not `id`.

### Accessibility
- MUST pass AXE. MUST meet WCAG AA: focus management, colour contrast, ARIA.
- Semantic elements first; ARIA only for real gaps. Keyboard reachable + visible focus.
- Labels on all inputs; `aria-label` on icon-only buttons. Announce async state via live
  regions. Never colour alone. Meaningful `alt`, or `alt=""` if decorative.
- Complex widgets (combobox, tree, tabs, grid): use accessible primitives, don't hand-roll ARIA.

### Typing
- Strict mode. No `any` — prefer `unknown` and narrow. No `as` casts to silence a mismatch.
- Prefer inference; annotate only when not obvious.
- Treat all external data (API responses, route/query params, localStorage, postMessage) as
  untrusted and narrow at the boundary.
- No `innerHTML` / `bypassSecurityTrust*` without a cited, provable reason.

### Performance
- Lazy-load features; `@defer` heavy in-page content; check the cost of new dependencies.
- Paginate every list endpoint. No unbounded fetches that grow with customer data.
- Keep `computed` and template expressions cheap — they re-run per CD cycle. Precompute
  anything iterating large collections or formatting many rows.
- No N+1 fetch patterns. No heavy synchronous work in click handlers, constructors, ngOnInit.
- Measure before optimising further.

### Testing
- Weight coverage at the STORE level: stub the service via DI, call the method, assert
  signals. Assert outcomes, not implementation details.
- Prefer DI/TestBed overrides over module mocks (`vi.mock`/`jest.mock` mutate the runner's
  module cache and leak across suites).
- Zoneless: prefer `await fixture.whenStable()` over `fixture.detectChanges()`.
- Never mutate globals (Date, crypto, localStorage, Math.random) without restoring them.
- No `done()` callbacks — use async/await, `firstValueFrom`, or fake timers.
- Don't paper over flakiness with isolation/timeout overrides; find the shared-state leak.
- Cover presentational components via rendered output / harnesses; container components via E2E.
- Override `FeatureFlagsService`/`AuthStore` via DI to test both branches of a flag or an
  authenticated vs. unauthenticated flow — never conditionally skip a test on real state.

Closing thought

None of these rules are clever. That's the point. A layering discipline earns its keep by being boring and total: every feature has the same shape, every mutation is named, every async flow cancels the same way, every dependency arrives through the same door, auth and errors are handled in exactly one place each, and there is exactly one place to look for any given behaviour.

That property is worth a great deal to a human maintainer. It is worth even more to an agent, which has no institutional memory, no sense of "how we do things here," and infinite willingness to write a plausible effect(), a localStorage.setItem('token', ...), or a hardcoded English string at 2am. Make the rules explicit, make as many of them as you can machine-checkable, and the code an agent writes starts looking like the code you'd have written yourself — at whatever scale the application actually needs to run at.

If you'd rather have an agent load these rules on demand instead of holding the whole document in context, the plugin this article is built from is at https://github.com/KylerJohnsonDev/angular-architecture-skills.


Sources

Angular guidance in this document was verified against angular.dev (v22 docs) and the official Angular best-practices guide bundled with the Angular CLI MCP server, including the guides on zoneless change detection, signals, dependency injection, deferrable views, queries, and performance. NgRx Signals patterns follow ngrx.io.

Kyler Johnson

Senior Principal Software Engineer building next-gen cybersecurity tools, web geek, blogger, and Linux enthusiast.

© 2026 Kyler Johnson. All rights reserved.