Patterns: Part four: Advanced patterns for specialized use cases
Introduction
These patterns are designed for unique challenges and optimizations. They’re less common but incredibly useful when you need to handle edge cases, optimize performance, or work with complex workflows.
Target audience: Advanced developers solving specialized problems or looking to deepen their architectural skills.
In order of practical use
-
JavaScript
Builder pattern
// Monster Builder class MonsterBuilder { constructor(name) { this.name = name; this.snacks = []; this.abilities = []; } addSnack(snack) { this.snacks.push(snack); return this; // Enables method chaining } addAbility(ability) { this.abilities.push(ability); return this; } build() { return new Monster(this.name, this.snacks, this.abilities); } } // Monster Class class Monster { constructor(name, snacks, abilities) { this.name = name; this.snacks = snacks; this.abilities = abilities; } display() { console.log(`${this.name} loves ${this.snacks.join(", ")} and can ${this.abilities.join(", ")}.`); } } // Example Usage const chunky = new MonsterBuilder("Chunky") .addSnack("cookies") .addSnack("milk") .addAbility("fly") .addAbility("breathe fire") .build(); chunky.display(); // Output: Chunky loves cookies, milk and can fly, breathe fire. const munchy = new MonsterBuilder("Munchy") .addSnack("chips") .addAbility("run fast") .build(); munchy.display(); // Output: Munchy loves chips and can run fast.
How do you create complex objects step by step, especially when there are many optional or configurable parts? For example, building a monster with specific snacks, abilities, or traits can become cumbersome if you have a lot of parameters to pass.
The Builder Pattern solves this problem by providing a way to construct objects piece by piece. It encapsulates the construction logic, making the code easier to read, maintain, and extend.
In this example, the MonsterBuilder class lets you create a monster step by step by chaining methods like addSnack and addAbility. When you’re done, calling build() returns a fully constructed Monster object.
This pattern is classified as Creational because it focuses on constructing objects in a flexible and controlled way.
-
JavaScript
Prototype pattern
// Base Monster prototype const MonsterPrototype = { init(name, favoriteSnack) { this.name = name; this.favoriteSnack = favoriteSnack; return this; // Allows method chaining }, eat() { console.log(`${this.name} munches on ${this.favoriteSnack}.`); }, clone() { return Object.create(this); // Creates a new object with the same prototype }, }; // Example Usage const chunky = Object.create(MonsterPrototype).init("Chunky", "cookies"); chunky.eat(); // Output: Chunky munches on cookies. const munchy = chunky.clone().init("Munchy", "chips"); munchy.eat(); // Output: Munchy munches on chips.
How do you efficiently create objects that share the same structure or behavior, while keeping them customizable? For example, you might have many monsters that share common traits like eating snacks, but each monster has a unique name or favorite snack.
The Prototype Pattern solves this problem by creating objects based on a prototype. Instead of building every object from scratch, you clone an existing object and customize it as needed. This makes it faster and easier to create similar objects while keeping their structure consistent.
In this example, MonsterPrototype serves as a blueprint for all monsters. Using Object.create, you can clone the prototype and initialize new monsters with their own unique properties.
This pattern is classified as Creational because it deals with object creation.
ES6 classes often replace the need for this pattern, but prototypes are still useful for lightweight, dynamic object creation.
-
JavaScript
Flyweight pattern
// Flyweight Factory class MonsterFactory { constructor() { this.monsters = {}; // Cache to store shared monster objects } createMonster(type) { if (!this.monsters[type]) { this.monsters[type] = new Monster(type); console.log(`Creating new ${type} monster.`); } else { console.log(`Reusing existing ${type} monster.`); } return this.monsters[type]; } } // Shared Monster Class (intrinsic state) class Monster { constructor(type) { this.type = type; } display(extrinsicData) { console.log( `${this.type} monster is ${extrinsicData.action} at location: ${extrinsicData.location}.` ); } } // Example Usage const factory = new MonsterFactory(); const chunky = factory.createMonster("Chunky"); chunky.display({ action: "eating cookies", location: "the kitchen" }); const munchy = factory.createMonster("Munchy"); munchy.display({ action: "playing", location: "the backyard" }); const reusedChunky = factory.createMonster("Chunky"); reusedChunky.display({ action: "napping", location: "the couch" }); /* Output: Creating new Chunky monster. Chunky monster is eating cookies at location: the kitchen. Creating new Munchy monster. Munchy monster is playing at location: the backyard. Reusing existing Chunky monster. Chunky monster is napping at location: the couch. */
How do you efficiently manage memory when creating many similar objects? For example, if you’re creating thousands of monsters with shared characteristics (like type), it can become wasteful to duplicate the same data for each object.
The Flyweight Pattern solves this problem by sharing common, intrinsic data (like the monster type) across multiple objects. Only unique, extrinsic data (like the action or location) is stored separately. This reduces memory usage and improves performance.
In this example, the MonsterFactory ensures that only one instance of each monster type is created. The display method uses extrinsic data (action and location) to customize the shared monster instance.
This pattern is classified as Structural because it optimizes the structure of objects in memory.
-
JavaScript
Template method pattern
// Abstract class class MonsterTask { execute() { this.prepare(); this.doTask(); this.cleanup(); } prepare() { console.log("Preparing for the task..."); } cleanup() { console.log("Cleaning up after the task..."); } doTask() { throw new Error("doTask() must be implemented by subclasses."); } } // Concrete class for feeding monsters class FeedMonsterTask extends MonsterTask { doTask() { console.log("Feeding the monster with snacks!"); } } // Concrete class for putting monsters to bed class PutMonsterToBedTask extends MonsterTask { doTask() { console.log("Tucking the monster into its cozy bed."); } } // Example Usage const feedTask = new FeedMonsterTask(); feedTask.execute(); /* Output: Preparing for the task... Feeding the monster with snacks! Cleaning up after the task... */ const bedtimeTask = new PutMonsterToBedTask(); bedtimeTask.execute(); /* Output: Preparing for the task... Tucking the monster into its cozy bed. Cleaning up after the task... */
How do you define the structure of an algorithm while allowing some parts to be customized? For example, preparing, performing, and cleaning up tasks like feeding or putting monsters to bed might follow the same steps, but the core action in the middle varies.
The Template Method Pattern solves this problem by defining the overall process in a base class and delegating specific steps to subclasses. The template method (execute) ensures the structure is consistent, while allowing customization through methods like doTask.
In this example, MonsterTask defines the template, and subclasses like FeedMonsterTask and PutMonsterToBedTask override doTask to provide the specific action.
This pattern is classified as Behavioral because it organizes dynamic behavior while enforcing a consistent structure.
-
JavaScript
Memento pattern
// Memento: Stores the state of the monster class MonsterMemento { constructor(state) { this.state = state; } } // Originator: The monster whose state can change class Monster { constructor(name) { this.name = name; this.state = "happy"; } setState(state) { console.log(`${this.name} changes state to: ${state}`); this.state = state; } saveState() { console.log(`${this.name}'s state has been saved.`); return new MonsterMemento(this.state); } restoreState(memento) { this.state = memento.state; console.log(`${this.name}'s state has been restored to: ${this.state}`); } } // Caretaker: Manages the mementos class MonsterCaretaker { constructor() { this.history = []; } save(memento) { this.history.push(memento); } undo() { return this.history.pop(); } } // Example Usage const chunky = new Monster("Chunky"); const caretaker = new MonsterCaretaker(); chunky.setState("hungry"); caretaker.save(chunky.saveState()); chunky.setState("angry"); caretaker.save(chunky.saveState()); chunky.setState("sleepy"); chunky.restoreState(caretaker.undo()); // Output: // Chunky changes state to: hungry // Chunky's state has been saved. // Chunky changes state to: angry // Chunky's state has been saved. // Chunky changes state to: sleepy // Chunky's state has been restored to: angry chunky.restoreState(caretaker.undo()); // Output: // Chunky's state has been restored to: hungry
How do you save and restore the state of an object without exposing its internal details? For example, you might want to let users undo actions like feeding or changing a monster’s mood.
The Memento Pattern solves this problem by capturing the object’s state in a separate memento object, which can be stored and restored later. The memento provides a snapshot of the object’s state without revealing its internal workings.
In this example, Monster acts as the originator, creating mementos of its state, while MonsterCaretaker manages these mementos and allows restoring the monster’s state.
This pattern is classified as Behavioral because it focuses on managing state and behavior over time.
-
JavaScript
Visitor pattern
// Monster classes class Chunky { accept(visitor) { visitor.visitChunky(this); } eat() { console.log("Chunky eats cookies."); } } class Munchy { accept(visitor) { visitor.visitMunchy(this); } play() { console.log("Munchy plays with toys."); } } // Visitor class class MonsterVisitor { visitChunky(chunky) { console.log("Visiting Chunky..."); chunky.eat(); } visitMunchy(munchy) { console.log("Visiting Munchy..."); munchy.play(); } } // Example Usage const chunky = new Chunky(); const munchy = new Munchy(); const visitor = new MonsterVisitor(); chunky.accept(visitor); // Output: // Visiting Chunky... // Chunky eats cookies. munchy.accept(visitor); // Output: // Visiting Munchy... // Munchy plays with toys.
How do you perform operations on objects of different types without modifying their classes? For example, you might want to apply special actions like feeding or playing to monsters, but you don’t want to add those behaviors directly to their classes.
The Visitor Pattern solves this problem by separating the operation (visitor) from the object structure. The objects (like Chunky and Munchy) “accept” a visitor, which implements the desired operation.
In this example, MonsterVisitor defines operations for visiting Chunky and Munchy, while the monsters themselves delegate the operation to the visitor via the accept method.
This pattern is classified as Behavioral because it focuses on how objects interact and execute operations.
-
JavaScript
Interpreter pattern
// Abstract Expression class MonsterExpression { interpret(context) { throw new Error("This method must be implemented by subclasses."); } } // Terminal Expressions class FeedExpression extends MonsterExpression { interpret(context) { if (context.includes("feed")) { console.log("Feeding the monster!"); return true; } return false; } } class PlayExpression extends MonsterExpression { interpret(context) { if (context.includes("play")) { console.log("Playing with the monster!"); return true; } return false; } } // Non-Terminal Expression class OrExpression extends MonsterExpression { constructor(expression1, expression2) { super(); this.expression1 = expression1; this.expression2 = expression2; } interpret(context) { return ( this.expression1.interpret(context) || this.expression2.interpret(context) ); } } // Example Usage const feed = new FeedExpression(); const play = new PlayExpression(); const feedOrPlay = new OrExpression(feed, play); feedOrPlay.interpret("feed the monster"); // Output: Feeding the monster! feedOrPlay.interpret("play with the monster"); // Output: Playing with the monster! feedOrPlay.interpret("put the monster to bed"); // Output: (no output, neither action matches)
How do you define and evaluate a grammar or set of rules for interpreting structured input? For example, you might want to process commands like “feed the monster” or “play with the monster” in a flexible, rule-based way.
The Interpreter Pattern solves this problem by defining a grammar (rules) for a language and implementing an interpreter to process it. Each rule is encapsulated in a class, allowing you to combine and evaluate rules dynamically.
In this example, FeedExpression and PlayExpression handle specific commands, while OrExpression combines them to allow more complex queries like “feed or play.”
This pattern is classified as Behavioral because it focuses on interpreting and processing input dynamically.