Structural design patterns in ES6 + on the example of the Game of Thrones



Good day, friends!

Structural design patterns are used to build large systems of relationships between objects in order to maintain flexibility and efficiency. Let's look at some of them with references to the Game of Thrones.

In software development, design patterns are used to solve the most common problems. They represent the best practices developed over a long time by developers in the process of testing applications and fixing bugs.

In this article we will talk about structural patterns. They are designed to design applications by defining a simple way to interact with instances.

The most common are the following patterns:

  • Adapter
  • Decorator
  • Facade
  • Adaptive (Lightweight (Element), Flyweight)
  • Proxy

Adapter


An adapter is a template that allows you to translate (transfer) the interface of one class to another. It allows classes to work together, which is often impossible due to incompatible structures.

Imagine that the Targaryen decided to fight with all the forces at their disposal (Flawless and Dragons), and Daenerys is looking for a way to interact with each of them.

We will need the following:

  • Unsullied class
  • Dragon class
  • Adapter class to pass the Dragon class burn method to the kill common method

class Unsullied {
    constructor(name) {
        this.name = name
        this.kill = this.kill.bind(this)
    }

    kill() {
        console.log(`Unsullied ${this.name} kill`)
    }
}

class Dragon {
    constructor(name) {
        this.name = name
        this.burn = this.burn.bind(this)
    }

    burn() {
        console.log(`Dragon ${this.name} burn`)
    }
}

class DragonAdapter extends Dragon {
    kill() {
        this.burn()
    }
}

(() => {
    const Army = [
        new DragonAdapter('Rhaegal'),
        new Unsullied('Grey worm'),
        new DragonAdapter('Drogon')
    ]
    Army.forEach(soldier => soldier.kill())
})()

Usage Cases


  • When new components or modules should work with existing in the application or when part of the code has been improved as a result of refactoring, but should interact with the old parts.
  • When you found a library for solving secondary problems and want to integrate it into your application.

Decorator


A decorator is a template designed to dynamically add behavior or functionality to existing classes. This is an alternative to subclassification.

Suppose we want to check if the drink served to King Joffrey is poisoned.

We will need the following:

  • King class
  • King Joffrey instance
  • Drink class
  • class Poisoned Drink
  • isNotPoisoned function to save the king’s life

function isNotPoisoned(t, n, descriptor) {
    const original = descriptor.value
    if(typeof original === 'function') {
        descriptor.value = function(...args) {
            const [drink] = args
            if(drink.constructor.name === 'poisonedDrink') throw new Error('Someone wants to kill the king')
            return original.apply(this, args)
        }
    }
    return descriptor
}

class PoisonedDrink {
    constructor(name) {
        this.name = name
    }
}

class Drink {
    constructor(name) {
        this.name = name
    }
}

class King {
    constructor(name) {
        this.name = name
    }

    //     
    //      
    @isNotPoisoned
    drink(drink) {
        console.log(`The king drank ${drink}`)
    }
}

(() => {
    const joffrey = new King('Joffrey Baratheon')
    const normalDrink = new Drink('water')
    const poisonedDrink = new Drink('poisoned water')
    joffrey.drink(normalDrink)
    joffrey.drink(poisonedDrink)
})()

Decorator Use Cases


  • When we want to add a function to a large number of classes or methods with different contexts
  • When we want to improve a previously created class, but don’t have time for a full refactoring

Facade


Facade - a template widely used in libraries. It is used to provide a unified and simple interface and hide the complexity of its subsystems or subclasses.

Imagine that we want to control an army in a battle with barbarians. Instances of our army have methods for moving cavalry, soldiers, and giants. But we are forced to call these methods separately, which takes a lot of time. How can we make army management easier?

We will need the following:

  • army specimens
  • ArmyFacade class

class Horse {
    constructor(name) {
        this.name = name
    }
    attack() {
        console.log(`Infantry ${this.name} attack`)
    }
}

class Soldier {
    constructor(name) {
        this.name = name
    }
    attack() {
        console.log(`Soldier ${this.name} attack`)
    }
}

class Giant {
    constructor(name) {
        this.name = name
    }
    attack() {
        console.log(`Giant ${this.name} attack`)
    }
}

class ArmyFacade {
    constructor() {
        //  ,           
        this.army = [];
        (new Array(10)).fill().forEach((_, i) => this.army.push(new Horse(i + 1)));
        (new Array(10)).fill().forEach((_, i) => this.army.push(new Soldier(i + 1)));
        (new Array(1)).fill().forEach((_, i) => this.army.push(new Giant(i + 1)));
        this.getByType = this.getByType.bind(this)
    }
    getByType(type, occurency) {
        return this.army.filter(el => {
            return el.constructor.name === type && occurency-- > 0
        })
    }
    attack(armyInfo = {}) {
        const keys = Object.keys(armyInfo)
        let subArmy = []
        keys.forEach(soldier => {
            switch(soldier) {
                case 'horse': {
                    subArmy = [...subArmy, ...this.getByType('Horse', armyInfo.horse)]
                    break;
                }
                    case 'soldier': {
                    subArmy = [...subArmy, ...this.getByType('Soldier', armyInfo.soldier)]
                    break;
                }
                    case 'giant': {
                    subArmy = [...subArmy, ...this.getByType('Giant', armyInfo.giant)]
                    break;
                }
            }
        })
        subArmy.forEach(soldier => soldier.attack())
    }
}

(() => {
    const army = new ArmyFacade()
    army.attack({
        horse: 3,
        soldier: 5,
        giant: 1
    })
})()

Use cases


  • When we want to convert many lines of code, possibly repeating, into one simple function.

Opportunist


Adaptive - a template designed for efficient data transfer between many small objects. It is used to improve performance and save memory.

Suppose we want to control an army of white walkers. At the same time, our walkers can have three states:

  • alive
  • dead
  • resurrected

We will need the following:

  • WhiteWalker class
  • class WhiteWalkerFlyweight
  • client for the resurrection of white walkers

class WhiteWalker {
    constructor({
        sprite,
        someOtherBigInformation
    }) {
        this.sprite = sprite
        this.someOtherBigInformation = someOtherBigInformation
        this.state = 'alive'
        this.resurrect = this.resurrect.bind(this)
        this.kill = this.kill.bind(this)
        this.getState = this.getState.bind(this)
    }
    kill() {
        this.state = 'dead'
    }
    resurrect() {
        this.state = 'resurrected'
    }
    getState() {
        return this.state
    }
}

const whiteWalker = new WhiteWalker({
    sprite: Date.now()
})

class WhiteWalkerFlyweight {
    constructor(position, name) {
        this.position = position
        this.name = name
        this.whiteWalker = whiteWalker
    }
    getInfo() {
        console.log(`The White Walker ${this.name} whit sprite ${this.whiteWalker.sprite} is ${this.whiteWalker.state}`)
    }
    getFatherInstance() {
        return this.whiteWalker
    }
}

(() => {
    const myArmy = []
    for(let i = 0; i < 10; i++) {
        myArmy.push(new WhiteWalkerFlyweight({
            x: Math.floor(Math.random() * 200),
            y: Math.floor(Math.random() * 200),
        }, i + 1))
    }
    myArmy.forEach(soldier => soldier.getInfo())
    console.log('KILL ALL')
    const [onOffWhiteWalker] = myArmy
    onOffWhiteWalker.getFatherInstance().kill()
    myArmy.forEach(soldier => soldier.getInfo())
    console.log('RESURRECT ALL')
    onOffWhiteWalker.getFatherInstance().resurrect()
    myArmy.forEach(soldier => soldier.getInfo())
})()

Use cases


  • When we want to avoid creating a large number of objects
  • When we want to create objects that consume a large amount of memory
  • When we need objects whose creation requires complex calculations
  • When we have a limited supply of resources: computing power, memory, space, etc.

Proxies


The proxy template, as its name implies, is used as an add-in or a substitute for another object to control access to this object.

Imagine that Queen Cersei issued a decree banning the recruitment of more than 100 soldiers without her permission. How do we implement this?

We will need the following:

  • class soldier
  • ArmyProxy class for process control
  • instances of the Cercei class for permission

class Soldier {
    constructor(name) {
        this.name = name
    }
    attack() {
        console.log(`Soldier ${this.name} attack`)
    }
}

class Queen {
    constructor(name) {
        this.name = name
    }
    getConsent(casualNumber) {
        return casualNumber %2 === 0
    }
}

class ArmyProxy {
    constructor() {
        this.army = []
        this.available = 0
        this.queen = new Queen('Cercei')
        this.getQueenConsent = this.getQueenConsent.bind(this)
    }

    getQueenConsent() {
        return this.queen.getConsent(Math.floor(Math.random() * 200))
    }

    enrollSoldier() {
        if(!this.available) {
            const consent = this.getQueenConsent()
            if(!consent) {
                console.error(`The queen ${this.queen.name} deny the consent`)
                return
            }
            this.available = 100
        }
        this.army.push(new Soldier(this.army.length))
        this.available--
    }
}

(() => {
    const myArmy = new ArmyProxy()
    for(let i = 0; i < 1000; i++) {
        myArmy.enrollSoldier()
    }
    console.log(`I have ${myArmy.army.length} soldiers`)
})()

Use cases


  • When the object we want to use is far away (deeply nested) and save the logic in the proxy so as not to affect the client
  • When we want to give an approximate result in anticipation of a real result, the calculation of which takes a lot of time
  • When we want to control access or creation of an object without interfering with its logic

Github code .

Note trans: here is a great video on design patterns.

Thank you for attention.

All Articles