A design pattern is a reusable solution to a recurring problem. The term is really broad in its definition and can span multiple domains of an application. However, the term is often associated with a well-known set of object-oriented patterns that were popularized in the 90s by the book, Design Patterns: Elements of Reusable Object- Oriented Software, Pearson Education, by the almost legendary Gang of Four (GoF): Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides.
This article is an excerpt from the book Node.js Design Patterns, Third Edition by Mario Casciaro and Luciano Mammino – a comprehensive guide for learning proven patterns, techniques, and tricks to take full advantage of the Node.js platform.
In this article, we’ll look at the behavior of components in software design. We’ll learn how to combine objects and how to define the way they communicate so that the behavior of the resulting structure becomes extensible, modular, reusable, and adaptable. After introducing all the behavioral design patterns, we will dive deep into the details of the strategy pattern.
Now, it's time to roll up your sleeves and get your hands dirty with some behavioral design patterns.
The Strategy pattern enables an object, called the context, to support variations in its logic by extracting the variable parts into separate, interchangeable objects called strategies. The context implements the common logic of a family of algorithms, while a strategy implements the mutable parts, allowing the context to adapt its behavior depending on different factors, such as an input value, a system configuration, or user preferences.
Strategies are usually part of a family of solutions and all of them implement the same interface expected by the context. The following figure shows the situation we just described:
Figure 1: General structure of the Strategy pattern
Figure 1 shows you how the context object can plug different strategies into its structure as if they were replaceable parts of a piece of machinery. Imagine a car; its tires can be considered its strategy for adapting to different road conditions. We can fit winter tires to go on snowy roads thanks to their studs, while we can decide to fit high-performance tires for traveling mainly on motorways for a long trip. On the one hand, we don't want to change the entire car for this to be possible, and on the other, we don't want a car with eight wheels so that it can go on every possible road.
The Strategy pattern is particularly useful in all those situations where supporting variations in the behavior of a component requires complex conditional logic (lots of if...else
or switch
statements) or mixing different components of the same family. Imagine an object called Order that represents an online order on an e-commerce website. The object has a method called pay()
that, as it says, finalizes the order and transfers the funds from the user to the online store.
To support different payment systems, we have a couple of options:
..elsestatement
in the pay()
method to complete the operation based on the chosen payment optionIn the first solution, our Order
object cannot support other payment methods unless its code is modified. Also, this can become quite complex when the number of payment options grows. Instead, using the Strategy pattern enables the Order
object to support a virtually unlimited number of payment methods and keeps its scope limited to only managing the details of the user, the purchased items, and the relative price while delegating the job of completing the payment to another object.
Let's now demonstrate this pattern with a simple, realistic example.
Let's consider an object called Config
that holds a set of configuration parameters used by an application, such as the database URL, the listening port of the server, and so on. The Config
object should be able to provide a simple interface to access these parameters, but also a way to import and export the configuration using persistent storage, such as a file. We want to be able to support different formats to store the configuration, for example, JSON, INI, or YAML.
By applying what we learned about the Strategy pattern, we can immediately identify the variable part of the Config
object, which is the functionality that allows us to serialize and deserialize the configuration. This is going to be our strategy.
Let's create a new module called config.js
, and let's define the generic part of our configuration manager:
import { promises as fs } from 'fs' import objectPath from 'object-path' export class Config { constructor (formatStrategy) { // (1) this.data = {} this.formatStrategy = formatStrategy } get (configPath) { // (2) return objectPath.get(this.data, configPath) } set (configPath, value) { // (2) return objectPath.set(this.data, configPath, value) } async load (filePath) { // (3) console.log(`Deserializing from ${filePath}`) this.data = this.formatStrategy.deserialize( await fs.readFile(filePath, 'utf-8') ) } async save (filePath) { // (3) console.log(`Serializing to ${filePath}`) await fs.writeFile(filePath, this.formatStrategy.serialize(this.data)) } }
This is what's happening in the preceding code:
data
to hold the configuration data. Then we also store formatStrategy
, which represents the component that we will use to parse and serialize the data.set()
and get()
, to access the configuration properties using a dotted path notation (for example, property.subProperty
) by leveraging a library called object-path (nodejsdp.link/object-path).load()
and save()
methods are where we delegate, respectively, the deserialization and serialization of the data to our strategy. This is where the logic of the Config
class is altered based on the formatStrategy
passed as an input in the constructor.As we can see, this very simple and neat design allows the Config
object to seamlessly support different file formats when loading and saving its data. The best part is that the logic to support those various formats is not hardcoded anywhere, so the Config
class can adapt without any modification to virtually any file format, given the right strategy.
To demonstrate this characteristic, let's now create a couple of format strategies in a file called strategies.js
. Let's start with a strategy for parsing and serializing data using the INI file format, which is a widely used configuration format (more info about it here: nodejsdp.link/ini-format). For the task, we will use an npm package called ini
(nodejsdp.link/ini):
import ini from 'ini' export const iniStrategy = { deserialize: data => ini.parse(data), serialize: data => ini.stringify(data) }
Nothing really complicated! Our strategy simply implements the agreed interface, so that it can be used by the Config
object.
Similarly, the next strategy that we are going to create allows us to support the JSON file format, widely used in JavaScript and in the web development ecosystem in general:
export const jsonStrategy = { deserialize: data => JSON.parse(data), serialize: data => JSON.stringify(data, null, ' ') }
Now, to show you how everything comes together, let's create a file named index.js
, and let's try to load and save a sample configuration using different formats:
import { Config } from './config.js' import { jsonStrategy, iniStrategy } from './strategies.js' async function main () { const iniConfig = new Config(iniStrategy) await iniConfig.load('samples/conf.ini') iniConfig.set('book.nodejs', 'design patterns') await iniConfig.save('samples/conf_mod.ini') const jsonConfig = new Config(jsonStrategy) await jsonConfig.load('samples/conf.json') jsonConfig.set('book.nodejs', 'design patterns') await jsonConfig.save('samples/conf_mod.json') } main()
Our test module reveals the core properties of the Strategy pattern. We defined only one Config
class, which implements the common parts of our configuration manager, then, by using different strategies for serializing and deserializing data, we created different Config
class instances supporting different file formats.
The example we've just seen shows us only one of the possible alternatives that we had for selecting a strategy. Other valid approaches might have been the following:
Config
object could have maintained a map extension → strategy
and used it to select the right algorithm for the given extension.As we can see, we have several options for selecting the strategy to use, and the right one only depends on your requirements and the tradeoff in terms of features and the simplicity you want to obtain.
Furthermore, the implementation of the pattern itself can vary a lot as well. For example, in its simplest form, the context and the strategy can both be simple functions:
function context(strategy) {...}
Even though this may seem insignificant, it should not be underestimated in a programming language such as JavaScript, where functions are first-class citizens and used as much as fully-fledged objects.
Between all these variations, though, what does not change is the idea behind the pattern; as always, the implementation can slightly change but the core concepts that drive the pattern are always the same.
In this article, we dive deep into the details of the strategy pattern, one of the Behavioral Design Patterns in Node.js. Learn more in the book, Node.js Design Patterns, Third Edition by Mario Casciaro and Luciano Mammino.
Mario Casciaro is a software engineer and entrepreneur. Mario worked at IBM for a number of years, first in Rome, then in Dublin Software Lab. He currently splits his time between Var7 Technologies-his own software company-and his role as lead engineer at D4H Technologies where he creates software for emergency response teams.
Luciano Mammino wrote his first line of code at the age of 12 on his father's old i386. Since then he has never stopped coding. He is currently working at FabFitFun as principal software engineer where he builds microservices to serve millions of users every day.