Modern Angular
Strategy Pattern in Angular: Swapping Rules Without Spreading Switches
Strategy earns its place when a rule really varies by tenant, plan, route, permission model, or runtime policy, and the variation is starting to leak across the feature. The Gang of Four defined Strategy in 1994 as 'a family of interchangeable algorithms, selected on-the-fly at runtime'. In Angular, the ErrorStateMatcher in Material and the HTTP_INTERCEPTORS multi-provider both ship the pattern via DI tokens, with no switch needed across the codebase.
Start with repeated variation
Do not start with the pattern name. Start with the review smell: the same decision appears in the component, template, validation, analytics, and tests. Every new tenant or plan adds another switch, and each switch is slightly different.
I usually reach for this shape when a product rule starts appearing in the component, the template, and the tests with slightly different branches.
That is when Strategy can help. The rule varies, the caller wants a stable contract, and Angular's DI can select the implementation at a route, component, or application boundary.
If the rule is only used once, a function is probably enough. If the rule changes every time the user types, a computed value may be enough. Strategy is strongest when a stable policy needs interchangeable implementations.
| Variation | Strategy candidate? | Reason |
|---|---|---|
| Pricing by account type | Yes | Same quote contract, different rules and messages |
| Permission policy by tenant | Yes | Same action checks, different policy source |
| Checkout flow by route | Yes | Route can select the policy before the page runs |
| One conditional in one component | No | A local branch is cheaper than DI indirection |
| Pure display formatting | Usually no | A pipe or formatter is often clearer |
The smell: switches spread with the rule
This checkout page has one real source of complexity: pricing policy. The component calculates discounts, review messages, and totals. That looks harmless until the next plan adds a new exception, or the enterprise flow needs a different review rule.
The duplication is not just visual. The switch appears in more than one computed value, so one branch can be updated while another branch keeps the old behavior. Tests also have to know which branch appears in which computed value.
The pattern is justified when the team can name the rule that varies. Here the rule is not checkout state in general. It is pricing policy for a selected account kind.
type AccountKind = 'selfServe' | 'enterprise' | 'education';
type BillingCycle = 'monthly' | 'annual';
@Component({
selector: 'app-checkout-page',
templateUrl: './checkout-page.html',
})
export class CheckoutPage {
readonly accountKind = input.required<AccountKind>();
readonly seats = signal(12);
readonly billingCycle = signal<BillingCycle>('annual');
readonly subtotalCents = computed(() => this.seats() * 2900);
readonly discountCents = computed(() => {
switch (this.accountKind()) {
case 'selfServe':
return this.billingCycle() === 'annual'
? Math.round(this.subtotalCents() * 0.1)
: 0;
case 'enterprise':
return Math.round(this.subtotalCents() * 0.2);
case 'education':
return Math.round(this.subtotalCents() * 0.5);
}
});
readonly reviewMessage = computed(() => {
switch (this.accountKind()) {
case 'selfServe':
return null;
case 'enterprise':
return 'A contract review is required before payment.';
case 'education':
return 'Eligibility must be verified before activation.';
}
});
readonly totalCents = computed(() =>
this.subtotalCents() - this.discountCents()
);
}Extract the policy contract
A strategy contract should be small enough that the caller does not need to know which implementation it received. For checkout, the page passes a CheckoutContext and receives a CheckoutQuote.
In this article, pricing policy is the business rule that varies; strategy is the implementation shape used to swap that policy safely.
Keep the shared contract boring. The variation belongs inside each strategy, not in the caller. Self-serve, enterprise, and education pricing can now change independently without adding another branch to the page component.
The classes are not valuable because they are classes. They are valuable because each one owns a policy that product, QA, and code review can discuss by name.
type BillingCycle = 'monthly' | 'annual';
interface CheckoutContext {
seats: number;
billingCycle: BillingCycle;
}
interface CheckoutQuote {
subtotalCents: number;
discountCents: number;
totalCents: number;
reviewMessage: string | null;
}
interface CheckoutPricingStrategy {
quote(context: CheckoutContext): CheckoutQuote;
}
const seatPriceCents = 2900;
function baseQuote(context: CheckoutContext): CheckoutQuote {
const subtotalCents = context.seats * seatPriceCents;
return {
subtotalCents,
discountCents: 0,
totalCents: subtotalCents,
reviewMessage: null,
};
}
@Injectable()
export class SelfServePricingStrategy implements CheckoutPricingStrategy {
quote(context: CheckoutContext): CheckoutQuote {
const quote = baseQuote(context);
const discountCents = context.billingCycle === 'annual'
? Math.round(quote.subtotalCents * 0.1)
: 0;
return {
...quote,
discountCents,
totalCents: quote.subtotalCents - discountCents,
};
}
}
@Injectable()
export class EnterprisePricingStrategy implements CheckoutPricingStrategy {
quote(context: CheckoutContext): CheckoutQuote {
const quote = baseQuote(context);
const discountCents = Math.round(quote.subtotalCents * 0.2);
return {
...quote,
discountCents,
totalCents: quote.subtotalCents - discountCents,
reviewMessage: 'A contract review is required before payment.',
};
}
}
@Injectable()
export class EducationPricingStrategy implements CheckoutPricingStrategy {
quote(context: CheckoutContext): CheckoutQuote {
const quote = baseQuote(context);
const discountCents = Math.round(quote.subtotalCents * 0.5);
return {
...quote,
discountCents,
totalCents: quote.subtotalCents - discountCents,
reviewMessage: 'Eligibility must be verified before activation.',
};
}
}Select the strategy with Angular DI
TypeScript interfaces disappear at runtime, so Angular cannot inject CheckoutPricingStrategy directly. Use an InjectionToken as the runtime key and provide the selected implementation through Angular's provider system.
This keeps selection outside the component. The page asks for CHECKOUT_PRICING_STRATEGY; the route, component, or app configuration decides which implementation should satisfy that token.
The factory should inject only the selected implementation. Listing the classes in providers registers provider records; the switch below keeps the factory from asking Angular for strategies the route will not use.
Do not hide dynamic business state inside the provider. Route-level selection works well when the route, tenant, plan, or shell already decides the policy. If the user can switch policies inside the screen, make that state explicit instead of pretending DI is reactive state.
type AccountKind = 'selfServe' | 'enterprise' | 'education';
export const CHECKOUT_PRICING_STRATEGY =
new InjectionToken<CheckoutPricingStrategy>(
'checkout.pricing.strategy'
);
export function provideCheckoutPricingStrategy(
kind: AccountKind
): Provider[] {
return [
SelfServePricingStrategy,
EnterprisePricingStrategy,
EducationPricingStrategy,
{
provide: CHECKOUT_PRICING_STRATEGY,
useFactory: () => {
switch (kind) {
case 'selfServe':
return inject(SelfServePricingStrategy);
case 'enterprise':
return inject(EnterprisePricingStrategy);
case 'education':
return inject(EducationPricingStrategy);
}
},
},
];
}const selectedKind = signal<AccountKind>('selfServe');
export const routes: Routes = [
{
path: 'checkout',
// Avoid this when selectedKind can change inside the screen.
providers: provideCheckoutPricingStrategy(selectedKind()),
loadComponent: () =>
import('./checkout/checkout-page.component')
.then((m) => m.CheckoutPage),
},
];Let the route own route-specific policy
Angular routes can own providers, which makes them a useful boundary for policy that changes by route. The checkout component stays the same; the route decides whether this page runs with self-serve, enterprise, or education pricing.
That is the difference between Strategy and a scattered switch. The decision is made once at the boundary where the variation enters the feature.
export const checkoutRoutes: Routes = [
{
path: 'checkout/self-serve',
providers: provideCheckoutPricingStrategy('selfServe'),
loadComponent: () =>
import('./checkout/checkout-page.component')
.then((m) => m.CheckoutPage),
},
{
path: 'checkout/enterprise',
providers: provideCheckoutPricingStrategy('enterprise'),
loadComponent: () =>
import('./checkout/checkout-page.component')
.then((m) => m.CheckoutPage),
},
{
path: 'checkout/education',
providers: provideCheckoutPricingStrategy('education'),
loadComponent: () =>
import('./checkout/checkout-page.component')
.then((m) => m.CheckoutPage),
},
];The component consumes one contract
After the strategy is selected, the component becomes smaller without losing behavior. It still owns current UI state: seats and billing cycle. It does not own pricing policy.
Signals work well here because the quote is synchronous derived state. The component can recompute the quote when local inputs change, while the selected strategy remains a stable dependency for this route instance.
The template reads that quote through @let so the markup has one local name for the derived value and does not need non-null assertions for the warning message.
@Component({
selector: 'app-checkout-page',
templateUrl: './checkout-page.html',
})
export class CheckoutPage {
private readonly pricing = inject(CHECKOUT_PRICING_STRATEGY);
readonly seats = signal(12);
readonly billingCycle = signal<BillingCycle>('annual');
private readonly context = computed(() => ({
seats: this.seats(),
billingCycle: this.billingCycle(),
}));
readonly quote = computed(() =>
this.pricing.quote(this.context())
);
}@let currentQuote = quote();
<section class="checkout-summary">
<p>Subtotal: {{ currentQuote.subtotalCents | cents }}</p>
<p>Discount: {{ currentQuote.discountCents | cents }}</p>
<strong>Total: {{ currentQuote.totalCents | cents }}</strong>
@if (currentQuote.reviewMessage; as message) {
<app-inline-warning [message]="message" />
}
</section>Do not turn every branch into a strategy
The failure mode is predictable: once the team sees Strategy working, every if starts looking like a pattern. That creates more files, more providers, and less readable code.
Use Strategy when it gives a varying rule an explicit home. Avoid it when the variation is local, temporary, or easier to understand as a direct expression.
| Code shape | Better owner | Reason |
|---|---|---|
| Tenant-specific pricing, permissions, or workflow policy | Strategy selected by DI | The caller needs one contract with interchangeable rules |
| One local UI branch | Component or template | The variation is not a reusable policy |
| Derived synchronous UI value | computed | Signals already express local derivation |
| Formatting a value for display | Pipe or formatter | The rule is presentational, not a policy boundary |
| Backend DTO translation | Adapter | The problem is contract translation, not interchangeable behavior |
Test the selected rule
A strategy should make tests cheaper. You can test the selected implementation through the same token the component injects, or substitute a fake strategy when testing the page.
This test does not render checkout. It proves that the route/provider configuration can select the education rule and that the rule produces the expected quote. Component tests can then focus on rendering the contract.
describe('checkout pricing strategy', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideCheckoutPricingStrategy('education')],
});
});
it('applies the selected pricing rule through DI', () => {
const strategy = TestBed.inject(CHECKOUT_PRICING_STRATEGY);
const quote = strategy.quote({
seats: 10,
billingCycle: 'annual',
});
expect(quote.subtotalCents).toBe(29000);
expect(quote.discountCents).toBe(14500);
expect(quote.totalCents).toBe(14500);
expect(quote.reviewMessage).toContain('Eligibility');
});
});Reusable artifact
Strategy review checklist
- Create a strategy only when a real rule varies behind a stable caller contract.
- Name the policy that varies before adding interfaces, tokens, or providers.
- Use an InjectionToken when the caller needs to inject an interface-shaped contract.
- Select strategies at route, component, or app boundaries when the policy is stable for that boundary.
- Do not use DI as reactive state when the user can change the policy inside the screen.
- Keep local UI derivation in computed values instead of pushing every branch into DI.
- Test the selected rule through the same provider token the feature consumes.
Sources checked
- https://angular.dev/guide/di
- https://angular.dev/guide/di/dependency-injection-providers
- https://angular.dev/api/core/InjectionToken
- https://angular.dev/guide/http/interceptors
- https://github.com/angular/components/blob/main/src/material/core/error/error-options.ts
- https://www.angularspace.com/strategy-pattern-the-angular-way-di-and-runtime-flexibility/
- https://en.wikipedia.org/wiki/Design_Patterns
Modern Angular Playbook
This article is one play.
The Modern Angular Playbook collects the diagnostic, the adoption matrix, eleven plays, and the 30-day plan. Free, in English and Portuguese.
You get both PDFs by email, through the Dojo IA list.
André Ramosavailable for remote roles, UTC−3Get in touch →