
Angular + NgRx Architecture for Agents
This guide (and Claude plugin) covers the complete Angular architecture, including signals, NgRx Signal Store, and enterprise-ready practices.
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 reactivity —
signal,computed,linkedSignal,effect, and the substitution table that replaces almost everyeffect()an agent writes unprompted - NgRx Signal Store — anatomy, call-state,
rxMethodorchestration, 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
- Part 1 — Application architecture
- Part 2 — The three layers
- Part 3 — Reactivity with signals
- Part 4 — Dependency injection
- Part 5 — Auth & security architecture
- Part 6 — Production observability
- Part 7 — Forms
- Part 8 — Templates, styling, accessibility
- Part 9 — Performance
- Part 10 — Testing
- Anti-pattern catalogue
- Appendix: rules block for your agent
- Closing thought
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:
| Layer | May depend on | Must never depend on |
|---|---|---|
features/* | data-access, ui, util, core | another feature |
data-access/* | util, core | features, ui |
ui/* | util | features, data-access |
util/* | nothing | everything 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/, andstores/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-boundariesESLint rule. In a plain CLI workspace,eslint-plugin-boundariesor 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
CODEOWNERSentry 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:
| Layer | Mechanism |
|---|---|
| Route existence | canMatch: [featureEnabledGuard('carriers-v2')] — shown above |
| Rendered UI | @if (flags.isEnabled('carriers-v2')) { ... } |
| Store/async behaviour | The 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.
| Trigger | Fires when |
|---|---|
on idle | Browser is idle (the default) |
on viewport | Block scrolls into view — best default for below-the-fold |
on interaction / on hover | User interacts with or hovers the placeholder |
on immediate | Right 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:
| Approach | Mechanism | Fit |
|---|---|---|
Compile-time (@angular/localize, $localize) | A separate build artifact per locale | No runtime language switch needed; ships in CI per market. Zero runtime cost. |
| Runtime library (Transloco, ngx-translate) | Loads locale JSON, switches without rebuild | A 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/localStorageduring render (see "don't assume globals" in Part 8). Date.now()/Math.random()in a template orcomputed.- 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 frompolyfillsinangular.json(bothbuildandtest) 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()(theasyncpipe 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/onUnstablenever emit;isStableis alwaystrue. Replace withafterNextRender(one pass) orafterEveryRender(spanning passes), or a direct DOM API likeMutationObserver. (NgZone.runandrunOutsideAngularremain fine.)- Reactive forms don't self-notify.
setValue,patchValue,FormArray.pushupdate 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 andHttpClientrequests; it's your own async work that needs wiring.pendingUntilEvent()from@angular/core/rxjs-interopdoes 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; noNgModulein new code. - Expose library/feature configuration via
provide*functions returningEnvironmentProviders. - Enable
withComponentInputBinding()and bind route params as signalinput()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.
canMatchfor existence,canActivatefor 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@placeholderand@loadingtimings. - 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,
OnPusheverywhere,PendingTasksfor 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 };
}
| Rule | Why |
|---|---|
Return a cold Observable (or Promise), never a signal | The caller decides subscription semantics — cancellation is the store's business |
No BehaviorSubject state, no cached last value | Two sources of truth is how staleness bugs are born |
| No loading/error flags | The store owns call state; a service that sets flags has hidden coupling |
| One method per operation, named for the operation | loadTopCarriers, not getData |
| Map DTO → domain at the boundary | External data is untrusted; narrow the type once, here |
providedIn: 'root' unless it genuinely needs scoping | Tree-shakable, no provider boilerplate |
| Design around a single responsibility | A 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 services | Makes 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:
| Operator | Semantics | Use for |
|---|---|---|
switchMap | Cancel in-flight, keep latest | Searches, filter changes, navigation-triggered loads |
exhaustMap | Ignore new while one is running | Form submits, anything irreversible |
concatMap | Queue, preserve order | Optimistic updates, ordered mutations |
mergeMap | All in parallel, no ordering | Rare — 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?
| Need | How |
|---|---|
| Shared across the app (session, tenant, feature flags) | signalStore({ providedIn: 'root' }, ...), in data-access |
| Shared across one feature's routes | No providedIn; add to the feature route's providers: [] |
| Owned by one component, dies with it | No 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 setchangeDetection: ChangeDetectionStrategy.OnPushexplicitly. Never opt out withChangeDetectionStrategy.Eager(or the deprecatedDefault). - 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
hostmetadata object — not@HostBinding/@HostListener. - Import standalone components, not their modules —
MatButton, notMatButtonModule. Modules pull in the whole barrel. No unused entries inimports. protectedfor template-only members,privatefor internals,readonlyon injected dependencies and signals. This makes the template's actual API obvious.- No
any, noascasts to silence a mismatch. A cast that makes an error go away has moved it to runtime. Preferunknownand 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:
| Signal | Why rxResource stops paying off |
|---|---|
| A second fetch appears | Two resources with no coordination; nowhere for cross-cutting derived state |
| The component writes anything back | Mutations need an orchestrated pipeline (exhaustMap, optimistic update, refetch) |
| Selection, filters, sorting, or pagination arrive | That's feature state; it belongs in withState |
| Another component needs the same data | Nothing to inject — you'd fetch twice |
| Polling, debouncing, or retry is needed | rxMethod composes RxJS naturally; rxResource doesn't |
| You want the devtools timeline | Resource 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 need | Use | Notes |
|---|---|---|
| Local writable state | signal() | set / update. Never mutate |
| A value derived from other signals | computed() | Pure, lazy, memoized |
| Writable state that resets when a source changes | linkedSignal() | The most underused API in Angular |
| Async data fetched in reaction to signals | store rxMethod, passed the signal | Cancellation + call state for free |
| A single read-only fetch keyed by one signal | rxResource | Trivial cases only (§2.4). Stable as of v22.0 |
| To trigger async work when a signal changes | pass the signal to rxMethod | Never an effect() |
| Component inputs | input() / input.required() | Not @Input() |
| Two-way bound input | model() | |
| Component outputs | output() | Not @Output() + EventEmitter |
| A reference to a child element/component | viewChild() / viewChildren() | Not @ViewChild |
| A reference to projected content | contentChild() / contentChildren() | Not @ContentChild |
| To read a signal without depending on it | untracked() | |
| To consume an Observable as a signal | toSignal() | Needs initialValue |
| To expose a signal as an Observable | toObservable() | |
| To touch the DOM after render | afterNextRender() / 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(). Noset, noupdate, nopatchState. 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: earlyRead → write → mixedReadWrite (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/updateonly, nevermutate; always produce new references for objects and arrays.- Expose
asReadonly()from services and stores. computedis pure, cheap, and never a pass-through.- Read signals before any
await. linkedSignalfor 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:
- field initialisers of DI-instantiated classes (
@Component,@Directive,@Injectable,@Pipe) - constructor bodies of those classes
- factory functions —
useFactory, and anInjectionToken'sfactory - 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.
| Hierarchy | Configured by | Lifetime |
|---|---|---|
EnvironmentInjector | providedIn: 'root', ApplicationConfig.providers, a Route's providers | App, or the route subtree |
ElementInjector | providers / viewProviders on @Component / @Directive | The component instance |
Resolution order when something requests a dependency:
- Search up the
ElementInjectortree, from the requesting component to the root element. - If not found, search up the
EnvironmentInjectortree, from the nearest environment injector to the root. - 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
| Scope | How | Use for |
|---|---|---|
| Application singleton | @Injectable({ providedIn: 'root' }) | API services, auth, config, logging, shared stores |
| Route subtree | providers: [] on a Route | Feature state that should die when the user leaves the feature |
| Component instance | providers: [] on @Component | Component-scoped stores, per-instance form state |
| Component view only | viewProviders: [] on @Component | Same, 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
multicollection; - you need a test seam — a token with a production default factory is far better than
vi.mock/jest.mockon a shared module, because the override is scoped toTestBedinstead 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
runInInjectionContextwhen outside them andassertInInjectionContextin helpers that callinject(). - Inject store dependencies as default params of
withMethods/withComputed/withHooks. providedIn: 'root'for stateless services; component or routeprovidersfor stateful scoped things.- Understand that a component provider shadows a root one for its subtree.
viewProviderswhen projected content must not see the service.InjectionTokenfor non-class dependencies and test seams — not for decoration. Delete single-provider, single-consumer, never-overridden tokens.- Prefer DI overrides in
TestBedover 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
localStorageorsessionStorage. 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 becauselocalStorage.setItemis the path of least resistance, not because anyone decided it was safe.
Two acceptable patterns, in order of preference:
httpOnlycookie, 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 withSameSite=StrictorLaxand a CSRF defense (§5.4), since cookies are sent automatically on cross-site requests too.- 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 ahttpOnlyrefresh 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-Policyheader (or<meta>tag) that disallowsunsafe-inlineandunsafe-evalfor 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'sngCspNonceor anngCspNonceattribute on the root element — not hand-added per script tag. - Consider a
Trusted Typespolicy (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, scriptsrc) 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/sessionStorage—httpOnlycookie 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 throughngCspNonce. - 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.
| Situation | Use |
|---|---|
| New form, Angular 22+ | Signal Forms (@angular/forms/signals) — stable |
| New form, Angular 21 | Signal Forms, but they were still experimental; Reactive Forms if you need stability guarantees |
| New form, Angular 20 | Reactive Forms (FormGroup / typed) — Signal Forms don't exist yet |
| Existing form | Match whatever that feature already uses |
| Trivial single input, existing template-driven codebase | Template-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:
touchedbecame an input paired with atouch()output (it was a model).markAsTouched()now marks descendants by default — pass{ skipDescendants: true }to opt out.- Conditional validators take a
whenoption 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/patchValuedo not schedule change detection. If a template depends on form state, bridge it withmarkForCheck()or reflect it through a signal (§1.9). - Don't subscribe to
valueChangesin a component to keep a field in sync — that's a subscription plus derived state. Convert to a signal withtoSignaland derive withcomputed, 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>
}
trackis required on@forand 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.@emptyon@forreplaces the@if (list.length === 0)companion block.@switchuses 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 acomputed. - Don't assume globals.
new Date()andwindoware 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
NgOptimizedImagefor static images — it enforces width/height, sets priority hints andsrcset. 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-deepis 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:hostand 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, notmargin-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
idselectors.
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, oralt=""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.
| Layer | Test style | Coverage weight |
|---|---|---|
util/ pure functions | Plain unit tests, no TestBed | Cheapest — cover thoroughly |
| Services | Assert request shape and response mapping | Light |
| Stores | Stub the service via DI, call methods, assert signals | Heaviest — behaviour lives here |
| Presentational components | Rendered output + interaction (Storybook / harnesses) | Moderate |
| Container components | E2E or interaction tests, not unit tests | Light |
| Critical flows | E2E | A 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.jsisn't inpolyfills,TestBedruns zoneless automatically. If it is still loaded but the app is zoneless, addprovideZonelessChangeDetection()to the test providers so tests match production. - Prefer
await fixture.whenStable()overfixture.detectChanges().detectChanges()forces a pass Angular might never have scheduled, which hides real missing-notification bugs. TestBednow enforces OnPush-compatibility and throwsExpressionChangedAfterItHasBeenCheckedErrorif a template value changed without a notification — including from a test doingfixture.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.mockon 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 toTestBed. - Restore anything global you touch —
Date,Math.random,crypto,localStorage.vi.spyOn(...)plusrestoreAllMocks()inafterEach, never a rawObject.defineProperty(globalThis, ...). - No
done()callbacks. Useasync/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-pattern | Why it's wrong | Do instead |
|---|---|---|
| API service injected into a component | Bypasses state, invisible in devtools, untestable | Inject the store; call a store method |
| Fetch orchestration in the component | No cancellation, duplicated retry logic | rxMethod in the store |
| One feature importing another feature | Cyclic coupling; nothing can be extracted later | Move the shared part to data-access / ui / util |
| Eager-loading every route | Initial bundle grows without bound | loadChildren / loadComponent per feature |
| Guard treated as security | Trivially bypassed client-side | Enforce server-side; the guard is UX |
| Resolver on a slow endpoint | Navigation blocks with no feedback | Navigate, then render store call state |
NgModule in new code | Obsolete | Standalone + provide* functions |
Directories by type (services/, stores/) | Every change touches four places | Colocate by feature |
Reactivity
| Anti-pattern | Why it's wrong | Do instead |
|---|---|---|
effect() that writes state | Feedback loop in the reactive graph | computed / linkedSignal |
effect() to trigger a fetch | Reinvents rxMethod, minus cancellation | Pass the signal to the rxMethod |
effect() for DOM work | Runs before render | afterNextRender / afterRenderEffect |
effect() inside withMethods | No injection context — throws | withHooks.onInit |
Calling rxMethod with signal() not signal | Fetches once; never re-fetches | Pass the signal itself, unread |
Reading a signal after await in a reactive context | Silently untracked | Read before the await |
Pass-through computed | Pure noise | Use the signal directly |
Mutating an object inside update | Same reference; view may not update | Return a new reference |
| Copying an input into a signal in an effect | Two sources of truth | computed, or linkedSignal |
.subscribe() in a component | Leak + shadow copy of state | Signals, or async pipe |
.subscribe() in a store method | Leak; a thrown error kills the pipeline permanently | tapResponse |
Manual subscription without takeUntilDestroyed() | Leak | Add it, or don't subscribe |
@ViewChild / @Input / @Output decorators in new code | Legacy; worse ergonomics and typing | viewChild() / input() / output() |
State
| Anti-pattern | Why it's wrong | Do instead |
|---|---|---|
Hand-rolled loading / error booleans | Reset paths get missed; error never clears on retry | Shared withCallState feature |
| Derived value stored in state | Drifts from its source | withComputed |
| Transient value stored in state | State grows without a consumer | Method parameter |
Multiple withComputed / withMethods blocks | Invisible ordering coupling | One of each |
Bare patchState with no description | Unreadable devtools timeline | actionPatchState('method(): why', ...) |
Missing withLogger | Anonymous store in devtools | Always last, always named |
mergeMap for a search | Out-of-order responses overwrite newer state | switchMap |
| Root-provided store holding per-view state | Leaks between consumers | Component or route providers |
rxResource in a component that also owns selection/filters/writes | A store with no structure | Migrate to a signal store at the second concern |
DI, templates, and the rest
| Anti-pattern | Why it's wrong | Do instead |
|---|---|---|
| Constructor injection in new code | Doesn't compose into functions | inject() in a field initialiser |
inject() in a method | Throws — not an injection context | Field initialiser, or runInInjectionContext |
InjectionToken with one provider and one consumer | Indirection with no payoff | Inject the class |
vi.mock on a shared module | Mutates the runner's module cache; leaks across suites | DI override in TestBed |
standalone: true in a decorator | Default since v19; an error in v20+ | Delete it |
ChangeDetectionStrategy.Default | Deprecated in v22, due to be removed | OnPush (the v22+ default) |
ChangeDetectionStrategy.Eager | Opts out of OnPush; usually an ng update leftover, not a decision | Delete it and fix whatever relied on eager checking |
ngClass / ngStyle | Slower, needs an import | [class.x] / [style.x] |
*ngIf / *ngFor | Legacy | @if / @for |
@for without stable track | Full DOM rebuild; state rebinds to wrong rows | track item.id |
$index as track | Same as no track when the list reorders | Stable identity |
@HostBinding / @HostListener | Legacy | host metadata object |
Importing MatXModule instead of MatX | Pulls in the whole barrel | Import the standalone symbol |
::ng-deep | Deprecated; leaks; signals a design-system bypass | Design-system component or token |
| Hardcoded colour / spacing | Breaks theming and dark mode | Design tokens, both themes |
id selectors in templates | Collides; not reusable | Classes |
<div (click)> for an action | No keyboard, no role, fails AXE | <button> |
any, or as to silence an error | Moves a compile error to runtime | unknown + narrowing; fix the type |
| Unbounded list fetch | Grows with customer data until it doesn't | Paginate |
| Object literal in a template binding | New reference every cycle | computed |
fixture.detectChanges() under zoneless | Hides missing-notification bugs | await fixture.whenStable() |
Trusting external data (params, localStorage, postMessage) | Injection and crash surface | Narrow at the boundary |
bypassSecurityTrust* / innerHTML without cause | XSS | Don't; if unavoidable, prove safety at the call site |
Security, observability, and scale
| Anti-pattern | Why it's wrong | Do instead |
|---|---|---|
Access/refresh token in localStorage/sessionStorage | Any XSS reads it synchronously; full session compromise | httpOnly cookie, or in-memory signal only |
| Each failed request triggers its own token refresh | Refresh storm; can invalidate the token other requests are retrying with | One shared, in-flight refresh Observable |
| Guard treated as the security boundary | Trivially bypassed client-side | Guards 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 screen | Also report to the observability service |
Only a global ErrorHandler, no per-store reporting | Errors caught downstream lose the context of what the user was doing | Report at the point of failure, with action context |
| PII in breadcrumbs or error context | Turns an incident tool into a privacy incident | Opaque user/session ID only |
| Secret/API key embedded in the client bundle | Ships to every visitor as plain, readable JS | Secrets stay server-side; call your own API |
Two stores independently patchState the same entity | Whichever wrote last wins; the other silently goes stale | One entity store owns the write; readers derive via computed |
| Translated strings built by concatenation | Breaks word order and pluralization in most other languages | The i18n library's interpolation/pluralization support |
| Dates/currency formatted with a template literal | Wrong symbol placement, decimal separator, digit grouping per locale | DatePipe / CurrencyPipe / Intl.* |
| Feature flag with no owner or removal condition | Permanent second code path, tested half as often as the primary one | Owner + removal condition at creation; test both branches |
Reading window/document/Date.now() during SSR render | Server and client compute different output → hydration mismatch, silent client re-render | Guard 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.