Design patterns are proven, practical, and reusable solutions fit for tackling specific problems in software development. They not only help us avoid pitfalls in organizing our applications, but also provide a shared glossary to describe our implementation and understand each other as fellow developers. There are dozens of patterns already discovered and I am pretty sure you are using at least some of them, even if you do not identify them as a design pattern (Hello constructor pattern 👋).

Design patterns are grouped into three categories: Creational, structural, and behavioral. In this article, I would like to focus on one of the behavioral patterns, the strategy (a.k.a. policy) pattern, and how we benefit from it in ABP Framework frontend. I am hoping this article will help you understand and use both the pattern and ABP features more effectively than ever.

What is Strategy Pattern?

I like explaining concepts with code examples and, since we shall see the use of strategy pattern in Angular later, the code examples here are in TypeScript. That being said, JavaScript implementation is quite similar.

So, let's check out what the Avengers would look like, if they were represented by a class:

class Hero {
  constructor(public name: string, public weapon?: string) {}
}

class Avengers {
  private ensemble: Hero[] = [];
  
  private blast(hero: Hero) {
    console.log(`${hero.name} blasted ${hero.weapon}`);
  }
  
  private kickAndPunch(hero: Hero) {
    console.log(`${hero.name} kicked and punched`);
  }
  
  private shoot(hero: Hero) {
    console.log(`${hero.name} shot ${hero.weapon}`);
  }
  
  private throw(hero: Hero) {
    console.log(`${hero.name} threw ${hero.weapon}`);
  }
  
  recruit(hero: Hero) {
    this.ensemble = this.ensemble
      .filter(({name}) => name === hero.name)
    	.concat(hero);
  }
  
  fight() {
    this.ensemble.forEach(hero => this.attack(hero));
  }
  
  attack(hero: Hero) {
    switch(hero.name) {
      case 'Iron Man':
        this.blast(hero);
        break;
      case 'Captain America':
      case 'Thor':
        this.throw(hero);
        break;
      case 'The Hulk':
        this.kickAndPunch(hero);
        break;
      case 'Black Widow':
        hero.weapon ? this.shoot(hero) : this.kickAndPunch(hero);
        break;
      case 'Hawkeye':
        this.shoot(hero);
        break;
      default:
        console.warn('Unknown Avenger: ' + hero.name);
    }
  }
}

Although it looks OK at first, this class has the following drawbacks:

  • It is difficult to recruit a new Hero to fight with other Avengers, because you will need to add another case (and probably a new attack type) for the new hero.
  • It is also difficult to change or remove an existing Hero. Consider changing Thor's attack from throwing his hammer, Mjolnir, to summoning a thunder strike.
  • Each different attack is just a one-liner here, but consider how difficult to read and maintain Avengers would become, if they were longer and more complex.
  • Avengers has to provide an attack for all heroes, although some of them might not be currently in the ensemble. This is not tree-shakable and could lead to a waste of resources.

Strategy design pattern decouples context from an interchangeable algorithm's implementation by delegating it to another class which is bound by a contract defined via the strategy interface.

strategy-design-pattern.png

So, let's refactor Avengers and see that it looks like when strategy pattern is applied:

abstract class Hero {
  constructor(public name: string, public weapon?: string) {}
  
  abstract attack(): void;
}

class BlastingHero extends Hero {
  attack() {
    console.log(`${this.name} blasted ${this.weapon}.`);
  }
}

class ShootingHero extends Hero {
  attack() {
    console.log(`${this.name} shot ${this.weapon}.`);
  }
}

class ThrowingHero extends Hero {
  attack() {
    console.log(`${this.name} threw ${this.weapon}.`);
  }
}

class UnarmedHero extends Hero {
  attack() {
    console.log(`${this.name} kicked and punched.`);
  }
}

class Avengers {
  private ensemble: Hero[] = [];
  
  recruit(hero: Hero) {
    this.ensemble = this.ensemble
      .filter(({name}) => name === hero.name)
    	.concat(hero);
  }
  
  fight() {
    this.ensemble.forEach(hero => hero.attack());
  }
}

Instead of creating an interface and implementing it, we have benefited from inheritance here to avoid repetition, but they have the same effect with regard to the strategy pattern.

Let's check the organization of the new code:

  • The Hero abstract class provides us the strategy, the contract that guarantees the algorithm is implemented by each hero. We could have used an interface, but an abstract class is beneficial here.
  • We need concrete strategies which implement the algorithm. In this case, they are the subclasses. Heroes will be instances of these subclasses and they all will have a separate attack.
  • The context will refer to the method guaranteed by the contract when fight is called.

Advantages of Strategy Pattern

There are some advantages we gained by implementing the strategy pattern above:

  1. The switch statement is gone. We no longer need to check a condition to determine what to do next.
  2. It is much easier to understand and maintain Avengers now.
  3. It is also easier to test Avengers than it was before.
  4. Avengers can recruit any new Hero (Spider-Man, Ant-Man, Scarlet Witch, Falcon, etc.) and their attack will just work. Therefore, another concrete strategy can always be introduced and the functionality is much more extensible.
  5. Avengers is now able to switch between available strategies at runtime. In other words, it is now capable of replacing an UnarmedHero with a ShootingHero. Think about Black Widow who can be both.
  6. If a concrete strategy, a subclass of Hero, is not used, it could possibly be tree-shaked.

Drawbacks of Strategy Pattern

There are but a few drawbacks which could be associated with strategy pattern:

  1. The client (consumer of Avengers, Nick Furry?) should be aware of the available strategies in order to recruit the correct one.
  2. There may be some peaceful Hero who will not attack at all, but still has to implement a noop method, just to align with the contract. Think about Bruce Banner (not the Hulk), who can contribute to the Avengers with his science and not participate in the fight.
  3. There is an increased number of objects generated. In some edge cases, this can cause an overhead. There is another design pattern, the flyweight pattern, that can be used to lower this overhead.
  4. There are cases when parameters are passed to methods defined by the strategy. Not all strategies use all parameters, but they still have to be generated and passed.

How ABP Benefits From the Strategy Pattern

Several services in ABP Angular packages resort to the strategy pattern and we are planning to refactor more of the existing ones into this pattern. Let's see how DomInsertionService is used:

import { DomInsertionService, CONTENT_STRATEGY } from '@abp/ng.core';

@Component({
  /* class metadata here */
})
class DemoComponent {
  constructor(private domInsertionService: DomInsertionService) {}

  ngOnInit() {
    const scriptElement = this.domInsertionService.insertContent(
      CONTENT_STRATEGY.AppendScriptToBody('alert()')
    );
  }
}

Remarkably, we have an insertContent method and are passing the content to be inserted to it via a predefined content strategy, CONTENT_STRATEGY.AppendScriptToBody. Let's check the content strategy:

import {
  ContentSecurityStrategy,
  CONTENT_SECURITY_STRATEGY,
} from './content-security.strategy';
import { DomStrategy, DOM_STRATEGY } from './dom.strategy';

export abstract class ContentStrategy<T extends HTMLScriptElement | HTMLStyleElement = any> {
  constructor(
    public content: string,
    protected domStrategy: DomStrategy = DOM_STRATEGY.AppendToHead(),
    protected contentSecurityStrategy: ContentSecurityStrategy = CONTENT_SECURITY_STRATEGY.None(),
  ) {}

  abstract createElement(): T;

  insertElement(): T {
    const element = this.createElement();

    this.contentSecurityStrategy.applyCSP(element);
    this.domStrategy.insertElement(element);

    return element;
  }
}

export class StyleContentStrategy extends ContentStrategy<HTMLStyleElement> {
  createElement(): HTMLStyleElement {
    const element = document.createElement('style');
    element.textContent = this.content;

    return element;
  }
}

export class ScriptContentStrategy extends ContentStrategy<HTMLScriptElement> {
  createElement(): HTMLScriptElement {
    const element = document.createElement('script');
    element.textContent = this.content;

    return element;
  }
}

export const CONTENT_STRATEGY = {
  AppendScriptToBody(content: string) {
    return new ScriptContentStrategy(content, DOM_STRATEGY.AppendToBody());
  },
  AppendScriptToHead(content: string) {
    return new ScriptContentStrategy(content, DOM_STRATEGY.AppendToHead());
  },
  AppendStyleToHead(content: string) {
    return new StyleContentStrategy(content, DOM_STRATEGY.AppendToHead());
  },
  PrependStyleToHead(content: string) {
    return new StyleContentStrategy(content, DOM_STRATEGY.PrependToHead());
  },
};

Well, apparently, ContentStrategy defines a contract that consumes two other strategies: DomStrategy and ContentSecurityStrategy. We are not going to dig deeper and examine these strategies, but the main takeaway here is that ABP employs a composition of strategies to build complex ones.

Another important information here is that, although there are some predefined strategies exported as a constant (CONTENT_STRATEGY), using the superclasses and/or constructing different compositions, you can always develop new strategies.

Let's take a closer look at the DomInsertionService class:

import { Injectable } from '@angular/core';
import { ContentStrategy } from '../strategies/content.strategy';
import { generateHash } from '../utils';

@Injectable({ providedIn: 'root' })
export class DomInsertionService {
  private readonly inserted = new Set<number>();

  insertContent<T extends HTMLScriptElement | HTMLStyleElement>(
    contentStrategy: ContentStrategy<T>,
  ): T {
    const hash = generateHash(contentStrategy.content);

    if (this.inserted.has(hash)) return;

    const element = contentStrategy.insertElement();
    this.inserted.add(hash);

    return element;
  }

  removeContent(element: HTMLScriptElement | HTMLStyleElement) {
    const hash = generateHash(element.textContent);
    this.inserted.delete(hash);

    element.parentNode.removeChild(element);
  }

  has(content: string): boolean {
    const hash = generateHash(content);

    return this.inserted.has(hash);
  }
}

The insertElement method of the ContentStrategy is called and that's pretty much it. The insertion algorithm is delegated to the strategy. As a result, there is not much to read and maintain here, yet the service is capable of doing almost any DOM insertion we may ever need.

Conclusion

The strategy pattern is a proven and reusable way of making your code more flexible and extensible. It also usually leads to a more readable, testable, and maintainable codebase. ABP Angular services like LazyLoadService, ContentProjectionService, and DomInsertionService already make use of the pattern and we are hoping to deliver more of these services in the near future.

Thank you for reading.