State

Strategy Pattern the Angular Way: DI and Runtime Flexibility

Most Strategy pattern tutorials use plain TypeScript. This article shows how to apply it the Angular way—with DI, interceptors, and runtime dispatching. Learn scalable patterns like Service Locator and self-registering Dispatcher, usable for errors, WebSockets, analytics, and more.

Strategy Pattern the Angular Way: DI and Runtime Flexibility — State article by Ivan Kudria on Angular In Depth
Strategy Pattern the Angular Way: DI and Runtime Flexibility — State article by Ivan Kudria on Angular In Depth
On this page · 16 sections

The Strategy pattern is a staple of object-oriented design, and countless articles and videos demonstrate how it can enhance your Angular applications. However, most of these tutorials stick to plain TypeScript examples, neglecting the unique leverage that Angular’s dependency-injection (DI) framework provides.

Consider the classic TypeScript example from Refactoring Guru: https://refactoring.guru/design-patterns/strategy/typescript/example. Many web snippets are direct reproductions of that code. While functional, they miss an opportunity to use the framework's strengths. Let’s explore how to elevate this pattern into a robust solution that aligns with Angular's conventions, rather than simply embedding standard OOP inside it.

In essence: Strategy defines a set of interchangeable algorithms—classes implementing a shared contract—that can be swapped at runtime.

Take a common scenario: HTTP requests can fail in many distinct ways, from 409 Conflict and 500 Internal Error to custom business codes like 400200. A typical starting point is to route all failures into a single monolithic service, using a long switch statement to determine the appropriate toast, redirect, or retry action. This often becomes a "god object" with tight coupling and a tangle of dependencies. Modifying one error path means altering existing code and risking unintended side effects. The Strategy pattern helps dismantle this complexity! Each error type can reside in its own dedicated class, registered once, allowing a dispatcher to select the correct strategy at runtime—without modifying the calling code.


Applying Strategy to error handling

First, we need to establish the error domain—these codes are the keys that determine which algorithm gets executed:

export const ERROR_CODE = {
  NotFoundError: '400200',
  ServerError:   '500200',
  Conflict:      '409',
  Default:       '0',
} as const;

export type ErrorCode = typeof ERROR_CODE[keyof typeof ERROR_CODE];

The contract for all strategies

export interface ExtendedServerErrorResponse {
  message:   string;
  errorCode: ErrorCode;
}

export interface ErrorHandlerInterface {
  handle(err: HttpErrorResponse | ExtendedServerErrorResponse): void;
}

Abstract helper to avoid repetition

The MatSnackBar (or any common dependency) is injected solely here, giving every concrete strategy easy access to it.

@Injectable()
export abstract class BaseErrorHandlerModel implements ErrorHandlerInterface {
  protected readonly snackBar = inject(MatSnackBar);

  abstract handle(
    err: HttpErrorResponse | ExtendedServerErrorResponse
  ): void;
}

Creating concrete strategies

@Injectable({ providedIn: 'root' })
export class ConflictErrorHandlerService
  extends BaseErrorHandlerModel
{
  override handle(err: ExtendedServerErrorResponse): void {
    this.snackBar.open(`CONFLICT • ${err.message}`, 'close', {
      duration: 3000,
    });
  }
}

@Injectable({ providedIn: 'root' })
export class DefaultErrorHandlerService
  extends BaseErrorHandlerModel
{
  override handle(err: HttpErrorResponse): void {
    this.snackBar.open(`DEFAULT • ${err.message}`, 'close', {
      duration: 3000,
    });
  }
}

While each handler adheres to the same interface, they have the freedom to inject additional services, trigger side effects, or completely alter the logic inside handle(). This results in a well-organized, testable class for each error type, instead of a messy “god service”.


Option A — Service Locator + errorInterceptor

Some context on the Service Locator

The Service Locator is a pattern that offers a central registry (the “locator”) from which the application retrieves dependencies at runtime.
Rather than accepting dependencies through constructor injection, a client invokes locator.get(MyServiceToken) to receive the needed instance.

The easiest method to incorporate our strategies into Angular is to use a static map in conjunction with the framework’s lower-level Injector to create a basic Service Locator arrangement.

  1. The errorInterceptor intercepts every failed HTTP response.
  2. It searches for the error code within a global ERROR_HANDLER_MAP.
  3. By calling injector.get(token), it retrieves the specific strategy from DI.
  4. Finally, it executes the strategy’s handle() method.

Here is the corresponding code.

1 - The global map

import { ProviderToken } from '@angular/core';
import { ERROR_CODE, ErrorCode } from '../models/error-codes';
import { BaseErrorHandlerModel } from '../models/base-error-handler.model';
import { ConflictErrorHandlerService } from './conflict-error-handler.service';
import { DefaultErrorHandlerService }  from './default-error-handler.service';

type ErrorHandlerMap =
  Partial<Record<ErrorCode, ProviderToken<BaseErrorHandlerModel>>>;

export const ERROR_HANDLER_MAP: ErrorHandlerMap= {
  [ERROR_CODE.Conflict]: ConflictErrorHandlerService,
  [ERROR_CODE.Default] : DefaultErrorHandlerService,
} as const;

2 - The Service Locator implementation

import { Injectable, Injector } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { ExtendedServerErrorResponse } from '../models/http-response';
import { ErrorCode, ERROR_CODE } from '../models/error-codes';
import { BaseErrorHandlerModel } from '../models/base-error-handler.model';
import { ERROR_HANDLER_MAP } from './error-handler-map.constant';

@Injectable({ providedIn: 'root' })
export class ErrorHandlerLocator {
  constructor(private injector: Injector) {}

  handle(
    code: ErrorCode,
    err: HttpErrorResponse | ExtendedServerErrorResponse
  ): void {
    const token =
      ERROR_HANDLER_MAP[code] ?? ERROR_HANDLER_MAP[ERROR_CODE.Default];
    this.injector.get<BaseErrorHandlerModel>(token).handle(err);
  }
}

3 - The functional interceptor

import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { ErrorHandlerLocator } from '../handlers/error-handler.locator';
import { ExtendedServerErrorResponse } from '../models/http-response';
import { ErrorCode } from '../models/error-codes';

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const locator = inject(ErrorHandlerLocator);

  return next(req).pipe(
    catchError((err: HttpErrorResponse | ExtendedServerErrorResponse) => {
      const code = (
        (err as ExtendedServerErrorResponse).errorCode ??
        (err as HttpErrorResponse).status.toString()
      ) as ErrorCode;

      locator.handle(code, err);
      return throwError(() => err);
    })
  );
};

4 - Interceptor registration

import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideAnimations } from '@angular/platform-browser/animations';
import { errorInterceptor } from './http/error-interceptor.interceptor';

export const appConfig: ApplicationConfig = {
  providers: [
    provideAnimations(),
    provideHttpClient(withInterceptors([errorInterceptor])),
  ],
};

This method works well when your error codes are limited, fixed, and require a single, auditable location for management. For projects that are more dynamic or plugin-based, we can explore a Dispatcher / self-registration model—this is covered in Option B.


Option B — Dispatcher + self-registration

Whereas the Service Locator fetches a handler by its key, this variant allows the handler to declare its own key. Angular’s multi-provider feature collects all implementations, and a small dispatcher determines which one to invoke at runtime.

  1. Each concrete strategy specifies a codes array containing the error codes it manages.
  2. All strategies are registered under the same DI token using multi: true.
  3. At bootstrap, Angular supplies an array of strategies to the dispatcher.
  4. The dispatcher creates an in-memory map (Map<code, strategy>) for quick lookup.
  5. The interceptor injects the dispatcher and simply calls dispatch(code, err).

1 – DI token

import { InjectionToken } from '@angular/core';
import { ErrorHandlerStrategy } from '../models/error-handler.interface';

export const ERROR_HANDLER_TOKEN =
  new InjectionToken<ErrorHandlerStrategy[]>('ERROR_HANDLER_TOKEN');

2 – Strategy classes (now with codes!)

@Injectable({ providedIn: 'root' })
export class ConflictErrorHandlerService
  extends BaseErrorHandlerModel
  implements ErrorHandlerStrategy
{
  readonly codes = [ERROR_CODE.Conflict];

  override handle(err: ExtendedServerErrorResponse): void {
    this.snackBar.open(`CONFLICT • ${err.message}`, 'close', { duration: 3000 });
  }
}

@Injectable({ providedIn: 'root' })
export class DefaultErrorHandlerService
  extends BaseErrorHandlerModel
  implements ErrorHandlerStrategy
{
  readonly codes = [ERROR_CODE.Default];

  override handle(err: HttpErrorResponse): void {
    this.snackBar.open(`DEFAULT • ${err.message}`, 'close', { duration: 3000 });
  }
}

3 – The dispatcher

import { Inject, Injectable } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { ERROR_HANDLER_TOKEN } from '../tokens/error-handler-token';
import { ErrorHandlerStrategy } from '../models/error-handler.interface';
import { ErrorCode, ERROR_CODE } from '../models/error-codes';
import { ExtendedServerErrorResponse } from '../models/http-response';

@Injectable({ providedIn: 'root' })
export class ErrorHandlerDispatcher {
  private readonly map = new Map<ErrorCode, ErrorHandlerStrategy>();

  constructor(
    @Inject(ERROR_HANDLER_TOKEN) strategies: ErrorHandlerStrategy[]
  ) {
    for (const s of strategies)
      for (const c of s.codes) this.map.set(c, s);
  }

  dispatch(
    code: ErrorCode,
    err: HttpErrorResponse | ExtendedServerErrorResponse
  ): void {
    const strategy =
      this.map.get(code) ?? this.map.get(ERROR_CODE.Default);

    strategy?.handle(err);
  }
}

4 – Functional interceptor

import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { ErrorHandlerDispatcher } from '../handlers/error-handler.dispatcher';
import { ExtendedServerErrorResponse } from '../models/http-response';
import { ErrorCode } from '../models/error-codes';

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const dispatcher = inject(ErrorHandlerDispatcher);

  return next(req).pipe(
    catchError((err: HttpErrorResponse | ExtendedServerErrorResponse) => {
      const code = (
        (err as ExtendedServerErrorResponse).errorCode ??
        (err as HttpErrorResponse).status.toString()
      ) as ErrorCode;

      dispatcher.dispatch(code, err);
      return throwError(() => err);
    })
  );
};

5 – Registration

import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideAnimations } from '@angular/platform-browser/animations';
import { ERROR_HANDLER_TOKEN } from './tokens/error-handler-token';
import { ConflictErrorHandlerService } from './handlers/conflict-error-handler.service';
import { DefaultErrorHandlerService  } from './handlers/default-error-handler.service';
import { errorInterceptor } from './http/error-interceptor.interceptor';

export const appConfig: ApplicationConfig = {
  providers: [
    provideAnimations(),
    provideHttpClient(withInterceptors([errorInterceptor])),

    // self-registering strategies
    { provide: ERROR_HANDLER_TOKEN, useExisting: ConflictErrorHandlerService, multi: true },
    { provide: ERROR_HANDLER_TOKEN, useExisting: DefaultErrorHandlerService,  multi: true },
  ],
};

Choose this approach for larger, fluctuating projects or plugin-style architectures—situations where the set of strategies changes frequently and editing a central map each time is undesirable.

Option B is the preferred choice when you aim for a modular, scalable design. Since strategies self-register, you can add, remove, or distribute them across lazy-loaded feature modules without altering centralized code. This results in a highly adaptable system that grows with your application.

Let's test the system's flexibility by making some adjustments. Suppose we currently have a single handler for one error code. What if we need multiple handlers to execute for the same code? Imagine a Lego set, where we can attach several handlers for a single code or event.
This is straightforward to implement.

Simply switch to an array-based map—the rest of the code remains unchanged.

@Injectable({ providedIn: 'root' })
export class ErrorHandlerDispatcher {
  private readonly map = new Map<ErrorCode, ErrorHandlerStrategy[]>();

  constructor(
    @Inject(ERROR_HANDLER_TOKEN) strategies: ErrorHandlerStrategy[]
  ) {
    for (const s of strategies) {
      for (const c of s.codes) {
        const arr = this.map.get(c) ?? [];
        arr.push(s);
        this.map.set(c, arr);
      }
    }
  }

  dispatch(
    code: ErrorCode,
    err: HttpErrorResponse | ExtendedServerErrorResponse
  ): void {
    const list = this.map.get(code) ?? this.map.get(ERROR_CODE.Default);
    list?.forEach(h => h.handle(err));
  }
}

This adjustment allows us to assign the same error code to different handlers. This is particularly useful for logging, separating concerns, and achieving highly cohesive code.


Final thoughts

  • Variant A (Service Locator) — compact, explicit, suited for a small,
    static set of error codes that can be reviewed in a single file.

  • Variant B (Dispatcher + self-registration) — requires minimal upkeep
    as your application grows; new strategies are detected automatically, can be
    housed in separate libraries, and can even be chained for the same code.

What other areas can benefit from the Strategy + Angular DI combination?

  • Transport layers HTTP errors, WebSocket event types, server-sent events, GraphQL subscriptions.
  • Front-end messaging native DOM events, postMessage for cross-window communication.
  • Integration points payment gateways, file-export formats, rich-text renderers, feature-flag treatments, and analytics sinks.
  • UI behaviour role-based component variants, theme renderers, and data-grid cell editors.

The Strategy pattern is best applied when handling numerous variants. If your situation is a basic if / else, stick with simple code. However, as the list of variants starts to grow—or is expected to—implement Option B and let your architecture scale itself.


Strategy Pattern the Angular Way: DI and Runtime Flexibility — figure 1

Tagged in:

Articles

Last Update: June 30, 2025

IK
Ivan Kudria

Writes about State. Active 2025.

All 1 article →