Types of Behavioral Design Patterns
- The Strategy pattern allows us to extract the common parts of a family of closely related components into a component called the context and allows us to define strategy objects that the context can use to implement specific behaviors.
- The State pattern is a variation of the Strategy pattern where the strategies are used to model the behavior of a component when under different states.
- The Template pattern, instead, can be considered the “static” version of the Strategy pattern, where the different specific behaviors are implemented as subclasses of the template class, which models the common parts of the algorithm.
- The Iterator pattern provides us with a common interface to iterate over a collection. It has now become a core pattern in Node.js. JavaScript offers native support for the pattern (with the iterator and iterable protocols). Iterators can be used as an alternative to complex async iteration patterns and even to Node.js streams.
- The Middleware pattern allows us to define a modular chain of processing steps. This is a very distinctive pattern born from within the Node.js ecosystem. It can be used to preprocess and postprocess data and requests.
- The Command pattern materializes the information required to execute a routine, allowing such information to be easily transferred, stored, and processed.
The Strategy Pattern
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 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:
- Use an
..elsestatement
in thepay()
method to complete the operation based on the chosen payment option - Delegate the logic of the payment to a strategy object that implements the logic for the specific payment gateway selected by the user
In 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.
Multi-format configuration objects
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.
Creating a new module
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:
- In the constructor, we create an instance variable called
data
to hold the configuration data. Then we also storeformatStrategy
, which represents the component that we will use to parse and serialize the data. - We provide two methods,
set()
andget()
, 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). - The
load()
andsave()
methods are where we delegate, respectively, the deserialization and serialization of the data to our strategy. This is where the logic of theConfig
class is altered based on theformatStrategy
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.
Creating format Strategies
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:
- Creating two different strategy families: One for the deserialization and the other for the serialization. This would have allowed reading from a format and saving to another.
- Dynamically selecting the strategy: Depending on the extension of the file provided; the
Config
object could have maintained a mapextension → 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.
Comments are closed.