The most important design patterns you need to know as a developer (part 3)
After part 1 and part 2, where creational and structural design patterns were explained, the third and final part is about behavioral design patterns.
Behavioral design patterns are the patterns that provide efficient communication and interaction between classes and objects and improve responsibilities between entities.
We will go through 10 design patterns:
- Observer
- Chain of responsibility
- Command
- Iterator
- Mediator
- Memento
- State
- Strategy
- Template method
- Visitor
Each pattern will be described by use-case from real-life and TypeScript code examples.
1) Observer
Sometimes this pattern is also called Publish/Subscribe or Producer/Consumer but I will stick to the name Observer.
The observer is a design pattern that is used when you have too many relationships between one central object and many others.
This pattern provides an elegant way to manage situations when one object updates the state and needs to automatically notify and update other objects.
Usually, that central object is called a Subject, and dependent objects are Observers.
One of the best examples that embody this pattern is my neighbors Ane and Ina.
They are old ladies who like to observe everything from their windows. They see everything, and we don’t need security cameras in my building.
Let’s use them as an example for this pattern:
interface NosyNeighbor {
update(subject: Person): void;
}
interface Person {
attach(observer: NosyNeighbor): void;
detach(observer: NosyNeighbor): void;
notify(): void;
}
class Me implements Person {
public hours: number;
private observers: NosyNeighbor[] = [];
public attach(observer: NosyNeighbor): void {
const observerExists = this.observers.includes(observer);
if (observerExists) {
console.log("Me: Observer is already attached.");
return;
}
console.log("Me: Attached an observer.");
this.observers.push(observer);
}
public detach(observer: NosyNeighbor): void {
const observerIndex = this.observers.indexOf(observer);
if (observerIndex === -1) {
console.log("Me: Observer doesn't exist.");
return;
}
this.observers.splice(observerIndex, 1);
console.log("Me: Detached an observer.");
}
public notify(): void {
console.log("Me: Notifying observers...");
for (const observer of this.observers) {
observer.update(this);
}
}
public livingMyLife(): void {
console.log("Me: Just living my life...");
this.hours = Math.floor(Math.random() * (10 + 1));
console.log(`Me: My state has just changed to: ${this.hours}`);
this.notify();
}
}
class NeighborIna implements NosyNeighbor {
public update(subject: Person): void {
if (subject instanceof Me && subject.hours < 3) {
console.log("Ina: Reacted to the event.");
}
}
}
class NeighborAne implements NosyNeighbor {
public update(subject: Person): void {
if (subject instanceof Me && (subject.hours === 0 || subject.hours >= 2)) {
console.log("Ane: Reacted to the event.");
}
}
}
At the start, we have 2 interfaces NosyNeighbor
and Person
.
NosyNeighbor
contains only 1 method, update
. This method executes actions when the subject (me) notifies.
On the other hand, the interface Person
contains 3 methods:
attach
- used to attach the observerdetach
- used to detach the observernotify
- used to notify the observer about the event
Following up, there are 3 classes:
Me
- this class implements thePerson
interface with previously explained methods. There is also one more methodlivingMyLife
which is setting hours to a random numberNeighborIna
- implements theNosyNeighbor
interface with the update method. Ina’s watch is up until 3 pm so she only observes up until that time.NeighborAne
- Same asNeighborIna
but her watch is from 1 pm am to 3 pm
Now let’s test this code:
const me = new Me();
const neighborIna = new NeighborIna();
me.attach(neighborIna);
const neighborAne = new NeighborAne();
me.attach(neighborAne);
me.livingMyLife();
me.livingMyLife();
me.detach(neighborAne);
me.livingMyLife();
/*
"Me: Attached an observer."
"Me: Attached an observer."
"Me: Just living my life..."
"Me: My state has just changed to: 1"
"Me: Notifying observers..."
"Ina: Reacted to the event."
"Me: Just living my life..."
"Me: My state has just changed to: 4"
"Me: Notifying observers..."
"Ane: Reacted to the event."
"Me: Detached an observer."
"Me: Just living my life..."
"Me: My state has just changed to: 9"
"Me: Notifying observers..."
*/
As you can see observers react to the state changes in the subject automatically.
2) Chain of responsibility
This pattern is good when you have specific request types which need to be handled by specific objects or handlers.
Each of these handlers can decide if they want to handle the request or pass it on to the next handler, and so on.
A good example would be handling articles if the store.
Each article must be handled on a specific shelf, you can’t place meat near the fruits, or sweets near the milk.
Here is the presentation of this use case in the code:
interface Handler {
setNext(handler: Handler): Handler;
handle(request: string): string;
}
abstract class ArticleHandler implements Handler {
private nextArticleHandler: Handler;
public setNext(handler: Handler): Handler {
this.nextArticleHandler = handler;
return handler;
}
public handle(request: string): string {
if (this.nextArticleHandler) {
return this.nextArticleHandler.handle(request);
}
return null;
}
}
class FruitHandler extends ArticleHandler {
public handle(request: string): string {
if (request === "Apple" || request === "Banana") {
return `FruitHandler: Store the ${request}.`;
}
return super.handle(request);
}
}
class MeatHandler extends ArticleHandler {
public handle(request: string): string {
if (request === "Bacon") {
return `MeatHandler: Store the ${request}.`;
}
return super.handle(request);
}
}
class SweetsHandler extends ArticleHandler {
public handle(request: string): string {
if (request === "Chocolate") {
return `SweetsHandler: Store the ${request}.`;
}
return super.handle(request);
}
}
First, there is an interface Handler
which consists of 2 functions:
setNext
- used to pass the request onto the next available handlerhandle
- used for implementation of handling the request
Next, there is the abstract class ArticleHandler
which implements the above 2 methods.
After that, there are 3 concrete handlers, FruitHandler
, MeatHandler
, and SweetsHandler
. These handlers are very similar, the only difference is the preference for specific requests.
Now let’s test this code:
function storeArticles(handler: Handler) {
const articles = ["Bacon", "Apple", "Chocolate", "Milk", "Banana"];
for (const article of articles) {
console.log(`Next article is ${article}?`);
const result = handler.handle(article);
if (result) {
console.log(`${result}`);
} else {
console.log(`${article} is not stored.`);
}
}
}
const fruitHandler = new FruitHandler();
const meatHandler = new MeatHandler();
const sweetsHandler = new SweetsHandler();
fruitHandler.setNext(meatHandler).setNext(sweetsHandler);
storeArticles(fruitHandler);
/*
"Next article is Bacon?"
"MeatHandler: Store the Bacon."
"Next article is Apple?"
"FruitHandler: Store the Apple."
"Next article is Chocolate?"
"SweetsHandler: Store the Chocolate."
"Next article is Milk?"
"Milk is not stored."
"Next article is Banana?"
"FruitHandler: Store the Banana."
*/
storeArticles(meatHandler);
/*
"Next article is Bacon?"
"MeatHandler: Store the Bacon."
"Next article is Apple?"
"Apple is not stored."
"Next article is Chocolate?"
"SweetsHandler: Store the Chocolate."
"Next article is Milk?"
"Milk is not stored."
"Next article is Banana?"
"Banana is not stored."
*/
Function storeArticles
is used to iterate over various types of articles and pass them to handlers.
As you can see, handlers either store the article on a shelf or pass it to the next handler.
After that, a chain of handlers is formed using setNext
function.
Notice how if the first example when fruitHandler
is passed as a parameter, all items except milk are stored, as milk doesn’t have a handler.
In the second example, meatHandler
is passed as a parameter and only meat and sweets articles are stored, as their handler are the only ones that are chained previously.
3) Command
This design pattern is often used when you have features with a lot of requests that can be executed in different ways and are all related to functionality.
With this pattern, you can avoid creating a huge number of classes and code repetition.
A good example of this design pattern is video games.
Usually, in video games, you have your character’s inventory which you can open in 2 or more ways:
- Click the button on the in-game menu
- Or press the keyboard shortcut, for example, the character “I”
Both of these commands are doing the same thing, they open the inventory menu in the game. So let’s implement this in the code:
interface Command {
execute(): void;
}
class OpenInventoryKeyboardCommand implements Command {
private videoGame: VideoGame;
constructor(videoGame: VideoGame) {
this.videoGame = videoGame;
}
public execute(): void {
console.log("OpenInventoryKeyboardCommand: execute method called!");
this.videoGame.openInventory();
}
}
class OpenInventoryButtonCommand implements Command {
private videoGame: VideoGame;
constructor(videoGame: VideoGame) {
this.videoGame = videoGame;
}
public execute(): void {
console.log("OpenInventoryButtonCommand: execute method called!");
this.videoGame.openInventory();
}
}
class OperatingSystem {
private commands: Command[];
constructor() {
this.commands = [];
}
public storeAndExecute(cmd: Command) {
this.commands.push(cmd);
cmd.execute();
}
}
class VideoGame {
public openInventory(): void {
console.log("VideoGame: action open inventory executed!");
}
}
At the begging there are 2 classes:
OpenInventoryKeyboardCommand
- a class that accepts the game object in the constructor and implements theexecute
function, calling theopenInventory
functionOpenInventoryButtonCommand
- same asOpenInventoryKeyboardCommand
class
Next, 2 classes are the core of this pattern:
OperatingSystem
- this is the invoker class that is used to store and execute all commands to the class with business logic. This class is not dependent on any specific commands, it just sends commands to the receiver classVideoGame
- this is a class that contains business logic, which means this class knows how to execute any command
Let’s see how it works:
const videoGame: VideoGame = new VideoGame();
const openInventoryKeyboardCommand: Command = new OpenInventoryKeyboardCommand(videoGame);
const openInventoryButtonCommand: Command = new OpenInventoryButtonCommand(videoGame);
const operatingSystem: OperatingSystem = new OperatingSystem();
operatingSystem.storeAndExecute(openInventoryKeyboardCommand);
operatingSystem.storeAndExecute(openInventoryButtonCommand);
/*
"KeyboardShortcutCommand: execute method called!"
"VideoGame: action is being executed!"
"InGameMenuButtonCommand: execute method called!"
"VideoGame: action is being executed!"
*/
As you can see commands are executed and the video game performs the action of opening the inventory.
4) Iterator
When you have an iterable structure like a tree, stack, or list, you must traverse them to execute some operations.
To do this efficiently, but without exposing the internal logic or implementation, you can use the iterator design pattern.
A good example would be an address book or cake recipe book search.
When you search for a specific person in the address book or cake recipe, you need to go through the book to find it.
Everything is better with food, so let’s implement this cake recipe book example in the code:
interface RecipeIterator {
next(): any;
hasNext(): boolean;
}
interface RecipeAggregator {
createIterator(): RecipeIterator;
}
class CakeRecipeIterator implements RecipeIterator {
private cakeRecipes: any[] = [];
private index: number = 0;
constructor(recipes: any[]) {
this.cakeRecipes = recipes;
}
public next(): any {
const result = this.cakeRecipes[this.index];
this.index += 1;
return result;
}
public hasNext(): boolean {
return this.index < this.cakeRecipes.length;
}
}
class CakeRecipes implements RecipeAggregator {
private cakeRecipes: string[] = [];
constructor(recipes: string[]) {
this.cakeRecipes = recipes;
}
public createIterator(): RecipeIterator {
return new CakeRecipeIterator(this.cakeRecipes);
}
}
First, there are 2 simple interfaces RecipeIterator
and RecipeAggregator
.
Next, the core of this pattern is 2 classes CakeRecipeIterator
and CakeRecipes
:
CakeRecipeIterator
- a simple class that takes the recipes in the constructor and implements 2 methods,next
andhasNext
. The first one returns the next recipe andhasNext
checks if there is the next element in the iterable collection.CakeRecipes
- this class is a wrapper around an iterable collection that implements thecreateIterator
method and returns an iterator instance.
Now let’s see this in action:
const cakes = [
"New York Cheesecake",
"Molten Chocolate Cake",
"Tres Leches Cake",
"Schwarzwälder Kirschtorte",
"Cremeschnitte",
"Sachertorte",
"Kasutera",
];
const cakeRecipes: CakeRecipes = new CakeRecipes(cakes);
const it: CakeRecipeIterator = <CakeRecipeIterator>cakeRecipes.createIterator();
while (it.hasNext()) {
console.log(it.next());
}
/*
"New York Cheesecake"
"Molten Chocolate Cake"
"Tres Leches Cake"
"Schwarzwälder Kirschtorte"
"Cremeschnitte"
"Sachertorte"
"Kasutera"
*/
As you can see all cake recipes are printed out with the help of an iterator.
5) Mediator
This pattern is a good choice when you have a big number of objects and their behavior is dependent on each other, so to reduce chaos in communication between them there is a mediator - a special object which is restricting direct communication between objects and forcing communication only through the mediator object.
One of the best examples of mediators in real life is police officers in situations where traffic lights are broken, so they have to guide the drivers and their cars and signal them when they can drive and when they must wait.
So let’s imagine there are 2 cars on opposite lanes and a patrol officer guiding the traffic:
interface Mediator {
notify(sender: object, message: string): void;
}
class Car {
protected mediator: Mediator;
public setMediator(mediator: Mediator): void {
this.mediator = mediator;
}
}
class PatrolOfficer implements Mediator {
private car1: Car1;
private car2: Car2;
constructor(c1: Car1, c2: Car2) {
this.car1 = c1;
this.car1.setMediator(this);
this.car2 = c2;
this.car2.setMediator(this);
}
public notify(sender: object, message: string): void {
if (message === "Car1-left") {
console.log(`PatrolOfficer: reacts on ${message}`);
this.car2.stopAndWait();
}
if (message === "Car2-left") {
console.log(`PatrolOfficer: reacts on ${message}`);
this.car1.stopAndWait();
}
}
}
class Car1 extends Car {
public driveLeft(): void {
console.log("Car1: drive left!");
this.mediator.notify(this, "Car1-left");
}
public driveStraight(): void {
console.log("Car1: drive straight!");
this.mediator.notify(this, "Car1-straight");
}
public stopAndWait(): void {
console.log("Car1: stop and wait!");
this.mediator.notify(this, "Car1-stop");
}
}
class Car2 extends Car {
public driveLeft(): void {
console.log("Car2: drive left!");
this.mediator.notify(this, "Car2-left");
}
public driveStraight(): void {
console.log("Car2: drive straight!");
this.mediator.notify(this, "Car2-straight");
}
public stopAndWait(): void {
console.log("Car2: stop and wait!");
this.mediator.notify(this, "Car2-stop");
}
}
const car1 = new Car1();
const car2 = new Car2();
const policeOfficer = new PatrolOfficer(car1, car2);
car1.driveLeft();
car1.driveStraight();
car2.driveStraight();
car2.stopAndWait();
/*
"Car1: drive left!"
"PatrolOfficer: reacts on Car1-left"
"Car2: stop and wait!"
"Car1: drive straight!"
"Car2: drive straight!"
"Car2: stop and wait!"
*/
The core of this pattern is PatrolOfficer
class which implements the Mediator
interface and notify
method.
In that method, the mediator reacts only when the situation will cause a car crash, eg. car 1 needs to go left so car 2 needs to stop and wait, and vice-versa.
The next 2 classes are car objects with their functions driveLeft
, driveStraight
, and stopAndWait
.
In each function, the mediator object is notified about the behavior and can react.
You can also see that when cars are driving straight mediator doesn’t intervene.
6) Memento
This design pattern is not used very often, but sometimes you need it in situations where you need to restore the previous state of an object without revealing any business logic in it.
A good example of a memento is the Maccy program I use every day.
Copy-paste is a very useful tool but when you copy something previously copied content is gone.
This is where Maccy comes in, it allows you to have a history of copied content and use it again. So it perfectly embodies the Memento design pattern with how it handles the clipboard state.
Let’s show it in the code:
class ClipboardState {
private copiedContent: string;
constructor(command: string) {
this.copiedContent = command;
}
get Command(): string {
return this.copiedContent;
}
set Command(command: string) {
this.copiedContent = command;
}
}
class StateManager {
private state: ClipboardState;
constructor(state: ClipboardState) {
this.state = state;
}
get State(): ClipboardState {
return this.state;
}
set State(state: ClipboardState) {
console.log("State:", state);
this.state = state;
}
public createMemento(): Memento {
return new Memento(this.state);
}
public setMemento(memento: Memento) {
this.State = memento.State;
}
}
class Memento {
private state: ClipboardState;
constructor(state: ClipboardState) {
this.state = state;
}
get State(): ClipboardState {
console.log("get memento state");
return this.state;
}
}
class MementoManager {
private memento: Memento;
get Memento(): Memento {
return this.memento;
}
set Memento(memento: Memento) {
this.memento = memento;
}
}
const state: ClipboardState = new ClipboardState("I copied this line of text");
const stateManager: StateManager = new StateManager(state);
const mementoManager: MementoManager = new MementoManager();
mementoManager.Memento = stateManager.createMemento();
stateManager.State = new ClipboardState("This is another line I copied");
stateManager.setMemento(mementoManager.Memento);
/*
"State:", {copiedContent: "This is another line I copied"}
"get memento state"
"State:", {copiedContent: "I copied this line of text"}
*/
First, there is ClipboardState
class, which is very simple. It can hold one copy, has a getter and setter for that copied content, and that’s it.
Next is StateManager
class, which also has a getter and setter for ClipboardState
instance, but also createMemento
and setMemento
functions. This class is the middleman between copied content and memento.
After that, there is the Memento
class, which can store only one instance of ClipboardState
and contains a getter for it.
Finally, MementoManager
is a class that is a wrapper for the Memento
object, with a getter and setter for it.
Below is the example section, you can see how copied content “This is another line I copied” can be restored to the clipboard state.
7) State
State lets you modify object behavior when its internal state changes, so it can behave like a completely different object.
If you are familiar with finite-state machines, this pattern is a very similar concept.
The coffee machine is a great example of a state design pattern:
- when the coffee machine is turned on and ready to work
- when is making a coffee
- when the water tank is empty, it shows the symbol to fill it.
So basically, it has 3 different types of states. Of course, the coffee machine has more states but for the sake of simplicity, the code example can contain only these 3 above:
interface State {
handle(machine: CoffeeMachine): void;
}
class CoffeeMachineReadyState implements State {
public handle(machine: CoffeeMachine): void {
console.log("CoffeeMachineReadyState: ready to work!");
if (machine.isWaterTankEmpty()) {
machine.State = new WaterTankEmptyState();
return;
}
machine.State = new MakingCoffeeState();
}
}
class WaterTankEmptyState implements State {
public handle(machine: CoffeeMachine): void {
console.log("WaterTankEmptyState: it is empty, please refill water tank!");
machine.State = new CoffeeMachineReadyState();
}
}
class MakingCoffeeState implements State {
public handle(machine: CoffeeMachine): void {
console.log("MakingCoffeeState: making coffee!");
machine.takeWater();
machine.State = new CoffeeMachineReadyState();
}
}
class CoffeeMachine {
private state: State;
private waterLevel: number;
constructor(state: State) {
this.state = state;
this.waterLevel = 20;
}
get State(): State {
return this.state;
}
set State(state: State) {
this.state = state;
}
public takeWater(): void {
this.waterLevel -= 10;
}
public isWaterTankEmpty(): boolean {
return this.waterLevel < 10;
}
public fillWaterTank(water: number): void {
this.waterLevel = water;
}
public request(): void {
this.state.handle(this);
}
}
There is an interface State
, followed by 3 classes that implement that interface:
CoffeeMachineReadyState
- checks if the water tank is empty and if yes, it switches the machines state toWaterTankEmptyState
, otherwise toMakingCoffeeState
WaterTankEmptyState
displays the message that the water tank is emptyMakingCoffeeState
which is displaying a message to make a coffee
Finally, there is CoffeeMachine
class which contains a bunch of stuff, getter, and setter for a state, then functions to manage the water tank, handle state requests, and others.
Now let’s see how it works:
const coffeeMachine: CoffeeMachine = new CoffeeMachine(new CoffeeMachineReadyState());
coffeeMachine.request();
coffeeMachine.request();
coffeeMachine.request();
coffeeMachine.request();
coffeeMachine.request();
coffeeMachine.request();
coffeeMachine.fillWaterTank(50);
coffeeMachine.request();
coffeeMachine.request();
/*
"CoffeeMachineReadyState: ready to work!"
"MakingCoffeeState: making coffee!"
"CoffeeMachineReadyState: ready to work!"
"MakingCoffeeState: making coffee!"
"CoffeeMachineReadyState: ready to work!"
"WaterTankEmptyState: it is empty, please refill water tank!"
"CoffeeMachineReadyState: ready to work!"
"MakingCoffeeState: making coffee!"
*/
Notice how the machine switches states and when the water tank is empty until it’s refilled it won’t make coffee.
8) Strategy
This pattern is very popular and it lets you create a close group of strategies in separate classes and make their objects exchangeable.
A good example of strategy pattern application is open-world RPG games. They let you decide what you want to do next, so you have multiple choices:
- You can roam free through the open world and do your own thing
- You can do some side quests
- You can do the main quest and progress the main story
So let’s implement this in the code:
interface Strategy {
execute(): void;
}
class ExploreTheWorldStrategy implements Strategy {
public execute(): void {
console.log("ExploreTheWorldStrategy executed!");
}
}
class SideQuestStrategy implements Strategy {
public execute(): void {
console.log("SideQuestStrategy executed!");
}
}
class MainQuestStrategy implements Strategy {
public execute(): void {
console.log("MainQuestStrategy executed!");
}
}
class Context {
private strategy: Strategy;
constructor(strategy: Strategy) {
this.strategy = strategy;
}
set Strategy(strategy: Strategy) {
this.strategy = strategy;
}
public executeStrategy(): void {
this.strategy.execute();
}
}
const context: Context = new Context(new SideQuestStrategy());
context.executeStrategy();
context.Strategy = new MainQuestStrategy();
context.executeStrategy();
context.Strategy = new ExploreTheWorldStrategy();
context.executeStrategy();
/*
"SideQuestStrategy executed!"
"MainQuestStrategy executed!"
"ExploreTheWorldStrategy executed!"
*/
First, there are 3 classes representing different strategies you can do in the game.
These classes can have business logic inside them, but in this example, they just print the message.
Next, there is a Context
class, which has the property to store one strategy. Keep in mind, that Context
is not responsible for choosing the strategy, it only delegates the work to the preferred one.
Finally, in the end, you can see how each strategy can be swapped for a new one and executed.
9) Template method
This pattern is very useful when you have set of a specific instructions that can be used as a basis for different actions.
It allows you to define a core in the base class and then override the various steps in the subclasses without a change in the structure.
For example, you are making apple pie and pumpkin pie. The First 4 steps are identical:
- Add flour
- Add eggs
- Add water
- Mix to make a dough
The only difference is the main pie ingredient, which will be apples or pumpkin, so you need to add that and bake the pie to eat it.
Let’s implement this in the code:
abstract class Pie {
protected addFlour(): void {
console.log("Pie: addFlour");
}
protected addEggs(): void {
console.log("Pie: addEggs");
}
protected addWater(): void {
console.log("Pie: addWater");
}
protected mix(): void {
console.log("Pie: mix");
}
public makeDough(): void {
console.log("template method 'makeDough' is called!");
this.addFlour();
this.addEggs();
this.addWater();
this.mix();
}
protected abstract addPieIngredient(): void;
protected abstract bake(): void;
}
class ApplePie extends Pie {
public addPieIngredient(): void {
console.log(`ApplePie: addPieIngredient Apples!`);
}
public bake(): void {
console.log(`ApplePie: bake!`);
}
}
class PumpkinPie extends Pie {
public addPieIngredient(): void {
console.log(`PumpkinPie: addPieIngredient pumpkin!`);
}
public bake(): void {
console.log(`PumpkinPie: bake!`);
}
}
The main class is the Pie
class, which contains a template method called makeDough
.
This method calls for all other repetitive steps that are used to make pie dough. It also contains two methods that must be implemented in the subclasses, these are addPieIngredient
and bake
.
Next, there are 2 classes ApplePie
and PumpkinPie
that implement previously explained methods.
Now to the testing part:
const applePie: ApplePie = new ApplePie();
const pumpkinPie: PumpkinPie = new PumpkinPie();
applePie.makeDough();
applePie.addPieIngredient();
applePie.bake();
pumpkinPie.makeDough();
pumpkinPie.addPieIngredient();
pumpkinPie.bake();
/*
"template method 'makeDough' is called!"
"Pie: addFlour"
"Pie: addEggs"
"Pie: addWater"
"Pie: mix"
"ApplePie: addPieIngredient Apples!"
"ApplePie: bake!"
"template method 'makeDough' is called!"
"Pie: addFlour"
"Pie: addEggs"
"Pie: addWater"
"Pie: mix"
"PumpkinPie: addPieIngredient pumpkin!"
"PumpkinPie: bake!"
*/
As you can see, the template method is executed so there is no need to repeat all these core steps in making pie dough, while specific steps such as adding apples or pumpkins are executed later.
10) Visitor
When you are in a situation where you need to separate business logic depending on the objects on which you operate, a visitor design pattern comes in handy.
Let’s take a singer for example. A singer will sing:
- kid’s songs at kids’ birthdays,
- his songs at his concert,
- other songs or requested ones at weddings
So the singer is a visitor object.
Let’s try to implement this scenario in the code:
interface IOcassion {
accept(singer: ISinger): void;
}
interface ISinger {
singChildrenSongs(ocassion: ChildBirthday): void;
singWeddingSongs(ocassion: Wedding): void;
singConcertSongs(ocassion: Concert): void;
}
class ChildBirthday implements IOcassion {
public accept(singer: ISinger): void {
singer.singChildrenSongs(this);
}
public singBabyShark(): string {
return "Sing 'Baby Shark' from Pinkfong";
}
}
class Wedding implements IOcassion {
public accept(singer: ISinger): void {
singer.singWeddingSongs(this);
}
public singIGiveYouMyWord(): string {
return "Sing 'I give you my word' from Dalmatino";
}
}
class Concert implements IOcassion {
public accept(singer: ISinger): void {
singer.singConcertSongs(this);
}
public singIfYouLeaveMe(): string {
return "Sing 'If You Leave Me' from Mišo Kovač";
}
}
class Singer implements ISinger {
public singChildrenSongs(ocassion: ChildBirthday): void {
console.log(`Singer: ${ocassion.singBabyShark()}`);
}
public singWeddingSongs(ocassion: Wedding): void {
console.log(`Singer: ${ocassion.singIGiveYouMyWord()}`);
}
public singConcertSongs(ocassion: Concert): void {
console.log(`Singer: ${ocassion.singIfYouLeaveMe()}`);
}
}
const ocassions = [new ChildBirthday(), new Wedding(), new Concert()];
const singer = new Singer();
for (const component of ocassions) {
component.accept(singer);
}
/*
"Singer: Sing 'Baby Shark' from Pinkfong"
"Singer: Sing 'I give you my word' from Dalmatino"
"Singer: Sing 'If You Leave Me' from Mišo Kovač"
*/
First, there are 2 interfaces IOcassion
and ISinger
to describe the above types of occasions and types of songs that singers can perform.
Then there are 3 classes and all of them implement accept method which calls the targeted function from the singer instance.
Each class also contains a specific method for that occasion with a song for it.
Finally, there is a Singer
class that implements all methods from the ISinger
interface and accepts the occasion
as a parameter.
In the end, you can see the output with songs that the singer is singing.
That’s all for the final part, hope you enjoyed it and find it useful!
Comments ()