Strategy Pattern
The strategy pattern lets you define a family of interchangeable algorithms behind a common interface and pick the right one at runtime instead of hard-coding branching logic. In Angular this maps cleanly onto dependency injection: each strategy is an injectable that implements a shared contract, and a DI token selects or resolves the implementation. The result is code free of sprawling if/switch ladders, open to new behaviors without touching existing ones.
When the pattern earns its keep
Reach for strategy whenever a single responsibility has several valid implementations chosen by configuration, user input, or context — payment processors, export formats (CSV, JSON, PDF), sorting rules, pricing tiers, or shipping calculators. The classic smell is a growing switch statement that everyone edits whenever a new case appears. Strategy replaces that branching with polymorphism: adding a behavior means adding a class, not modifying a conditional.
Defining the contract
Start with an interface every strategy must satisfy. Keep it narrow — one focused method is ideal.
export interface ShippingStrategy {
readonly id: string;
calculate(weightKg: number, distanceKm: number): number;
}
Each concrete strategy is a standalone injectable implementing that contract.
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class StandardShipping implements ShippingStrategy {
readonly id = 'standard';
calculate(weightKg: number, distanceKm: number): number {
return 5 + weightKg * 0.5 + distanceKm * 0.02;
}
}
@Injectable({ providedIn: 'root' })
export class ExpressShipping implements ShippingStrategy {
readonly id = 'express';
calculate(weightKg: number, distanceKm: number): number {
return 15 + weightKg * 1.2 + distanceKm * 0.05;
}
}
Registering strategies with a multi-provider token
The idiomatic Angular way to collect interchangeable implementations is a multi InjectionToken. Every strategy registers itself under the same token, and the consumer injects the whole array.
import { InjectionToken } from '@angular/core';
export const SHIPPING_STRATEGIES = new InjectionToken<ShippingStrategy[]>(
'SHIPPING_STRATEGIES',
);
Provide them at the application level — typically in app.config.ts.
import { ApplicationConfig } from '@angular/core';
import { SHIPPING_STRATEGIES } from './shipping.token';
import { StandardShipping } from './standard-shipping';
import { ExpressShipping } from './express-shipping';
export const appConfig: ApplicationConfig = {
providers: [
{ provide: SHIPPING_STRATEGIES, useExisting: StandardShipping, multi: true },
{ provide: SHIPPING_STRATEGIES, useExisting: ExpressShipping, multi: true },
],
};
Selecting a strategy at runtime
A small resolver service injects the array and looks up the right implementation by key. This is the heart of the pattern — the caller asks for a behavior by name and gets it without knowing which class fulfills the request.
import { Injectable, inject } from '@angular/core';
import { SHIPPING_STRATEGIES } from './shipping.token';
@Injectable({ providedIn: 'root' })
export class ShippingService {
private readonly strategies = inject(SHIPPING_STRATEGIES);
quote(method: string, weightKg: number, distanceKm: number): number {
const strategy = this.strategies.find((s) => s.id === method);
if (!strategy) {
throw new Error(`Unknown shipping method: ${method}`);
}
return strategy.calculate(weightKg, distanceKm);
}
available(): string[] {
return this.strategies.map((s) => s.id);
}
}
Adding a PriorityShipping strategy later requires only a new class and one provider line — ShippingService never changes.
Driving it from a component
A standalone component wires the selected method to a signal and recomputes the quote reactively.
import { Component, computed, inject, signal } from '@angular/core';
import { ShippingService } from './shipping.service';
@Component({
selector: 'app-checkout',
standalone: true,
template: `
<select [value]="method()" (change)="method.set($any($event.target).value)">
@for (m of methods; track m) {
<option [value]="m">{{ m }}</option>
}
</select>
<p>Cost: {{ cost() | currency }}</p>
`,
})
export class CheckoutComponent {
private shipping = inject(ShippingService);
methods = this.shipping.available();
method = signal('standard');
cost = computed(() =>
this.shipping.quote(this.method(), 2, 100),
);
}
Output:
standard → Cost: $8.00
express → Cost: $22.40
Strategy vs. branching logic
| Concern | switch statement | Strategy with DI |
|---|---|---|
| Adding a behavior | Edit existing code | Add a class + provider |
| Testability | Test all branches together | Test each strategy alone |
| Coupling | Caller knows every case | Caller depends on a token |
| Lazy loading | Hard | Provide per-route or per-feature |
Prefer
useExistingoveruseClassinmultiproviders when the strategies are alreadyprovidedIn: 'root'. This reuses the singleton instance instead of creating a second copy under the token.
Best Practices
- Keep the strategy interface minimal — one or two methods — so new implementations are cheap to write and easy to test.
- Register interchangeable behaviors through a single
multiInjectionTokenrather than a hand-maintained map. - Give each strategy a stable
id(or use aMapkeyed by an enum) so selection is explicit and type-safe. - Fail loudly when no strategy matches the requested key rather than silently falling back.
- Keep strategies pure where possible; if one needs collaborators, inject them rather than reaching for global state.
- Provide route- or feature-scoped strategies when only part of the app needs them, enabling lazy loading.