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.

VariationStrategy candidate?Reason
Pricing by account typeYesSame quote contract, different rules and messages
Permission policy by tenantYesSame action checks, different policy source
Checkout flow by routeYesRoute can select the policy before the page runs
One conditional in one componentNoA local branch is cheaper than DI indirection
Pure display formattingUsually noA 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.

Policy spread across the pagets
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.

Pricing strategy contractts
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.

Strategy providerts
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);
        }
      },
    },
  ];
}
DI is not screen statets
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.

Route-selected strategyts
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.

Checkout page contractts
@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())
  );
}
Template contracthtml
@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 shapeBetter ownerReason
Tenant-specific pricing, permissions, or workflow policyStrategy selected by DIThe caller needs one contract with interchangeable rules
One local UI branchComponent or templateThe variation is not a reusable policy
Derived synchronous UI valuecomputedSignals already express local derivation
Formatting a value for displayPipe or formatterThe rule is presentational, not a policy boundary
Backend DTO translationAdapterThe 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.

Strategy selection testts
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

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.

Open playbook →

André Ramosavailable for remote roles, UTC−3Get in touch →

Related

Guide · 9 min

Adapter Pattern in Angular: Isolating APIs and Browser-Only Libraries

A production Angular guide to the Adapter Pattern: where to translate external contracts, how to keep components clean, and when a wrapper is not worth creating.

Read article →

Guide · 9 min

Facade Pattern in Angular: When Components Know Too Much

A production Angular guide to the Facade Pattern: when to create one, what it should own, what it should not hide, and how to test the boundary.

Read article →

Guide · 7 min

Standalone Without Turning NgModules Into a Villain

A practical rule for using standalone components, route providers, and remaining NgModules in mature Angular applications.

Read article →