How to use design patterns as a web developer

by: | Oct 11, 2021

A few years ago, a coworker and I were tasked with modifying our company’s criteria for evaluating front-end developers during job interviews. Based on my experience writing code for our projects, I created a list of topics we should discuss with candidates.

For the most part, we agreed on which concepts were important and which ones were “nice to have.” However, there was a specific one that proved to be very divisive: design patterns.

While discussing it with my peers, I was astounded by the number of senior web developers who don’t believe design patterns matter for web development. There is a widespread belief that those patterns only apply to the object-oriented paradigm, and don’t always map to Javascript.

This post aims to convince you of the opposite — that, as a web developer, you should invest time in learning and practicing design patterns. To do that, I’ll start by analyzing what design patterns really are, and why people might think they are unsuitable for languages like Javascript. I’ll then propose a framework to apply any pattern to any programming paradigm, using Chain of Responsibility (CoR) as an example. Lastly, I’ll look into real-world uses of patterns in Javascript projects and how they improve the code quality.

What is a design pattern?

A design pattern is a simple, scalable, highly reusable solution to a recurring software architecture problem. Twenty-three of those patterns were compiled in a best-selling book by the Gang of Four in the early 90s, which very quickly gained widespread adoption and became a seminal guide for software development.

It is important to remember that the book was released almost 30 years ago, in a world heavily influenced by object-oriented programming (OOP) languages. As such, the way some of the patterns are presented doesn’t translate well to modern scripting languages like Javascript, either because some of the concepts from OOP aren’t prevalent — or even present — or because many languages now provide native constructs for the pattern.

To compensate for this, many well-intentioned authors simply translate them to corresponding OOP concepts in Javascript. Since many of the readers don’t write classes on a daily basis, this leads them to think studying this subject is a waste of time. The point both author and readers miss is that classes and objects are just tools, whereas design patterns describe problems and solutions — a much more essential part of what we do. An engineer’s job isn’t to use tools, but to provide simple solutions to complex problems — the tools are simply the way we express those.

As web developers, we may not use classes and objects as much as other software engineers, but the problems we face are ultimately programming problems. Luckily, design patterns aren’t about classes, objects, or even composition vs inheritance. They are a compendium of complex problems and their simple, scalable, highly reusable solutions — and that’s where our focus should be.

How to think about design patterns

The best way to study a pattern is to ask yourself what problem it tries to solve. Then, consider a naïve solution to that problem and compare it to the one proposed by the pattern. Trying to apply both solutions to a realistic example is also a very helpful way to internalize those concepts. Only after that should we start discussing the pattern in terms of specific paradigms.

To exemplify that, let’s look at the Chain of Responsibility.

What problem does it solve? Multiple independent operations should be triggered in response to the same action. The operations may be run in different groups, or different orders, based on context, and a single operation may need to halt the entire code execution depending on its result.

What is the naïve solution? Represent the entire queue of operations as one single entity (e.g. a function or a class method) and execute it every time the action happens. New contexts are defined as additional entities of the same type.

What is the solution proposed by the pattern?

  1. Move each response to its own entity — a.k.a handlers
  2. For each context, arrange handlers in a different queue and order
  3. Give each handler the ability to process the operation, forward it to the next handler, or halt execution

A design patterns example: with ninjas and ghosts

Imagine you’re building a browser-based RPG with a simple battle system. As part of the MVP, you create two characters (a Ninja and a Monster), who may attack or defend using a handful of items (a sword, a knife, or a shield). A naïve implementation for your attack system may look something like this:

web design patterns RPG example

After your MVP is approved, the product team decides to add two new characters to the game, the Wizard and the Ghost, each with a different set of abilities and items that can modify or even nullify attacks.

web design patterns scale RPG

You feel like things are getting out of hand. Not only did you have to write several attack functions for each new character, but since item availability never fully overlaps between attacks (for instance, wizards can use swords like ninjas, but ghosts can’t hold shields like monsters), you had to repeat the code between many of those functions.

After countless rounds of manual and unit testing, you feel confident enough to ship this. A few weeks later, your worst nightmare comes true: The game was so successful that the product team decides to add 10 new characters, each with 20 possible items that modify the attack in a specific way. Maintaining and scaling the naïve solution has proved to be an impossible task.

Describing a design pattern

The first thing you notice is that attacking is an action, and the items are operations responding to that action. Therefore, you can very easily move each item to its own handler, and assemble them into a queue based on your game state.

web design patterns RPG defense attack

You then refactor your battle system to check for the attacker’s weapons and apply each of their effects in order. Next, you check each of the foe’s defense items and apply their effects too. Each item is only applied if they are both allowed for the character and equipped during the battle (i.e. based on context).

web design patterns RPG triple damage

Implementing a design pattern

After we describe a pattern in terms of abstract, paradigm-independent concepts, it finally makes sense to start implementing it using paradigm-dependent constructs. By doing things in this order, we realize a multitude of implementations that wouldn’t be possible if we had started our exploration with a class diagram.

For Chain of Responsibility, let’s start by considering a classical OOP implementation. But we shouldn’t stop there! It is completely possible to implement the pattern using function composition and an array of functions — both without classes and objects.

OOP implementation

The traditional CoR implementation uses object composition in order to assemble an operation queue. Each operation is implemented as a class, which holds a reference called next pointing to the next handle on the queue. Usually, the setting of the reference is encapsulated in an abstract class meant to be extended by all handlers.

A simple implementation in Typescript would look like this:

interface Item {
  new (next: Item): Item;
  next: Item;
  process: (attack: Attack) => Attack
};
abstract class BaseItem implements Item {
  constructor(public next: Item) {}
  abstract process(attack: Attack): Attack;
}
class Sword extends BaseItem {
  process(attack: Attack) {
    const copy = { ...attack, attack.strength *= 3 };
  return this.next ? this.next.process(copy) : copy;
  }
}
class GhostlyShape extends BaseItem {
  process(attack: Attack) {
    if (attack.isPhysical()) return null;
    else next(attack);
  }
}

This is the implementation you’ll find on the GoF’s book, and also the one you’re most likely to find on blog posts. The Refactoring Guru has a great guide on how to use it on your project.

Closure-based implementation

In order to understand CoR using function composition, you must also understand the concept of Closures. A closure is a function that holds references to variables from the original scope where it was created, even after that scope becomes inaccessible to the outside world. Check this MDN guide for a more in-depth explanation.

A closure-based CoR uses the handler’s original scope to hold the reference to the next handler in the queue. Here’s what a simple Typescript implementation would look like:

type Modifier = (attack: Attack) => Attack;
type Item = (next?: Modifier) => Modifier;
const sword: Item = next => attack => {
  const copy = { ...attack, strength: attack.strength * 3 };
  return next ? next(copy) : copy;
}
const ghostlyShape: Item = next => attack => {
  if (isPhysical(attack) return null;
  else next(attack);
}

Assembling the handler is achieved with a simplecompose call.

function getAttack() {
  const { attacker, foe } = game.state;
  const modifiers = compose([
    ...attacker.attackItems,
    ...foe.defenseItems
  ]);
  return modifiers(attacker.getAttack());
}

compose will call each Item with the next Item in the array, which will be kept by the closure in the next variable. Items can forward execution to the next handler by calling next, or halt the execution by failing to do so.

CoR as an array of functions

This is the simplest way to implement CoR — so much so that people don’t even think about it as a pattern. A queue may be implemented as a simple array of functions that you call in sequence, like this:

function sword (attack) {
  return { ...attack, strength: attack.strength * 3 };
};
function getAttack() {
  const { attacker, foe } = game.state;
  const modifiers = [
    ...attacker.attackItems,
    ...foe.defenseItems
  ];
  return modifiers.reduce((acc, currentItem) => {
    return currentItem(acc);
  }, attacker.getAttack());
}

The upside to this approach is how simple it is. The downside is that it becomes harder to halt the execution of the operation without adding too much complexity.

Real-world applications

A claim that front-end developers should study design patterns would be moot if it didn’t show examples of real projects using them. The Chain of Responsibility has been proven to be a powerful tool that allows projects to implement scalable customization of behavior — and some of the most successful JS projects use it for just that.

Let’s take a look at how Redux and Webpack use CoR with function composition and an array of functions, respectively, to allow developers to extend their functionality.

Redux middleware

The Redux documentation describes an applyMiddleware API that allows clients to install custom behavior that runs in response to the dispatch function. Each middleware is created using a factory function, which has access to the Redux API and returns a handler with the exact same signature we described on the closure-based CoR implementation:

function actionLogger({ getState }) {
  return next => action => {
    console.group(`Action of type ${action.type}`);
    console.log('action:', JSON.stringify(action, null, 1));
    console.log('prev state:', getState());
    console.groupEnd();
    return next(action);
  }
}

Under the hood, Redux calls each factory function with the necessary API, then assembles the resulting handlers using compose (source code here.)

This approach has allowed Redux to be extended with a myriad of custom behavior, ranging from action-based fetching solutions to custom function action types (things the original codebase never even had to think about).

See the Redux Ecosystem page for a list of custom features that CoR has allowed Redux to support.

Webpack loader

Webpack implements its core functionality as rules — objects that specify how to handle source files using tests and loaders. Every time a file is added to the dependency graph, the runtime will use the defined tests to match it to one rule. After that, the loader chain specified by the matching rule will be applied to that source, allowing Webpack to transform code from any arbitrary language into a JS output.

Here’s a (slightly simplified) example of configuration from the Webpack docs:

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          { loader: 'style-loader' },
          {
            loader: 'css-loader',
            options: { modules: true }
          },
          { loader: 'sass-loader' }
        ]
      }
    ]
  }
};

Loader chains are implemented using the array-of-functions implementation of CoR, called in reverse order of declaration. Each loader is executed with the output from the following loader in the chain, with the last one being expected to produce valid Javascript code.

To halt execution or forward it to the next nodes in the chain, each loader implementation may use functions bound to the this object, like this.emitError or this.callback. It is also possible to achieve the same thing by just returning or throwing values.

Other patterns

Many other libraries make extensive use of patterns to achieve maximum scalability while maintaining simplicity of design, including:

  • React uses Composite to render complex UI from small, composable render functions
  • React-Redux uses Decorators to connect React components to data on a Redux store
  • Vue uses Observer to re-render UI based on state change
  • Babel uses Visitors to allow plugins to inspect and modify source code

The examples aren’t limited to library code, though, and you can use the concepts discussed here to apply design patterns and achieve the same goals in your application.

Design patterns are a powerful tool for web developers

A deep understanding of how design patterns work is one of the most powerful tools a software developer can wield (kind of like the Wizard’s sword). For too long those valuable concepts have been described exclusively in terms of OOP constructs, which has discouraged front-end developers from using them to improve code quality in their web development projects.

Luckily, since patterns describe solutions to such common problems, front-end developers are bound to rediscover some of them naturally throughout their careers. That shouldn’t stop you from studying them in full, though. When you attach a common name to a solution, you can discuss it, refine it, and more easily communicate it to your team.

So, next time you find a pattern being taught as a class diagram, do not despair! By applying the process discussed in this article, you can master any pattern and make it a powerful weapon in your arsenal.

[Editor’s Note: This post originally appeared on ArcTouch’s Medium Channel.]


Want a career in web and app development?

At ArcTouch, we build lovable apps and digital experiences to help our clients become superheroes. Come join us! We’ve got some great career opportunities for you.