Structural Design Patterns

space_gopher

In our OOP post, we briefly looked at how Go handles object-orientation and looked at the various types of embedding it supports. In this post, we’ll extend on that and look at structural design patterns and how they can be applied in real-life examples.

Structural design patterns describe the relationships between entities. They form large structures using classes and objects. They are used to create systems with different system blocks in a flexible manner. They are composed of different types: adapter, bridge, composite, decorator, facade, flyweight, private class data and proxy which are all commonly known as the Gang of Four (GoF).

Let’s take a look at them.

1. Adapter

Simply put, the adapter is a design pattern that allows incompatible objects to work together. What this means is that if we have an old component that we want to use in a new system or vice versa, we create layers that make all the required modifications for enabling the communication between the two interfaces. This layer is called the Adapter.

This pattern reveals to us every day in hardware rather than software. Just think of the number of times you’ve had to buy or use an “adapter” with your device because it wasn’t compatible with a certain interface. If you have a smartphone or tablet, you need to use the lightning connector with a USB adapter to connect it to your computer or if you have a laptop that came from the US or EU, you need to get an adapter to connect it your power source in order to use or charge it. They are literally everywhere! But how do you translate all this into code?

Concept

We have two objects, a Windows Laptop & a Mac. How do we connect the two yet they have two different interfaces: USB port & Lightning port respectively. This is where the pattern comes into the picture.

In the code below, we will translate the request from the client to the adaptee (Windows laptop) in a form that it understands. The adapter (Mac laptop) translates its signals into a USB format then passes them into the USB port in our Windows laptop.

type Computer interface {
    insertIntoLightningPort()
}

type Mac struct {
    adaptee Windows
}

func (adapter Mac) insertintoLightningPort() {
    fmt.Println("Lightning connector plugged into Mac")
    adapter.adaptee.insertintoUSBPort()
}

type Windows struct {
    adapterType *Windows
}

func (w Windows) insertintoUSBPort() {
    fmt.Println("Lightning signal converted to USB")
}

func main() {
    var processor Computer = Mac{}
    processor.insertintoLightningPort()
}

Output:

Lightning connector plugged into Mac
Lightning signal converted to USB

Explanation:

We have the Computer interface with a insertintoLightningPort method. The Mac class implements the insertintoLightningPort method and has a Windows instance as an attribute. The Windows class has a insertintoUSBPort method with an adapterType instance variable. While using our API, the client calls the insertintoLightningPort interface method to invoke insertintoUSBPort on Windows. That’s how you connect macOS and Windows systems together.

2. Bridge

Bridge decouples the implementation from the abstraction. It decouples huge classes into subclass hierarchies to provide different implementations that can be modified easily. It’s a clear demonstration of Go’s OOP principle i.e. composition over inheritance. The pattern’s components are abstraction, refined abstraction, implementer, and concrete implementer. The abstraction interface implements an abstract class that we can invoke with a method on our implementer. It maintains a has-a relationship instead of an is-a relationship with the respective components.

Concept

Let’s say you have two types of credit/debit cards: Visa and Mastercard, and two types of POS systems: mobile and terminal. Both the cards and the point-of-sale systems need to work with each other in any combination. The client doesn’t want to worry about the details of how the two will work together.

If we introduce new types of POS systems, we don’t want our code to grow exponentially. Instead of creating four structs in a matrix (2*2) fashion, we create two hierarchies:

  • Abstraction: for our cards
  • Implementation: for our POS systems

The two will communicate with each other via a Bridge where the abstraction contains a reference to the implementation. They can then be developed independently without affecting each other.

Let’s look at it in code.

type Card interface {
    process()
    callPOS(POS)
}

type Visa struct {
    pos POS
}

func (v *Visa) process() {
    fmt.Println("Payment request for Visa")
    v.pos.processPayment()
}

func (v *Visa) callPOS(p POS) {
    v.pos = p
}

type Mastercard struct {
    pos POS
}

func (m *Mastercard) process() {
    fmt.Println("Payment request for Mastercard")
    m.pos.processPayment()
}

func (m *Mastercard) callPOS(p POS) {
    m.pos = p
}

type POS interface {
    processPayment()
}

type PDQ struct {}

func (p *PDQ) processPayment() {
    fmt.Println("Processing payment by PDQ")
}

type Terminal struct {}

func (t *Terminal) processPayment() {
    fmt.Println("Processing payment by cashier")
}

func main() {
    pos := &PDQ{}
    terminal := &Terminal{}

    visa := &Visa{}

    visa.callPOS(pos)
    visa.process()
    fmt.Println()

    visa.callPOS(terminal)
    visa.process()
    fmt.Println()

    mastercard := &Mastercard{}

    mastercard.callPOS(pos)
    mastercard.process()
    fmt.Println()

    mastercard.callPOS(terminal)
    mastercard.process()
    fmt.Println()
}

Output:

Payment request for Visa
Processing payment by PDQ

Payment request for Visa
Processing payment by cashier

Payment request for Mastercard
Processing payment by PDQ

Payment request for Mastercard
Processing payment by cashier

3. Decorator

The decorator pattern is used to remove or add class responsibilities by dynamically placing them inside special wrappers. It achieves the single-responsibility principle naturally. It helps with modifying existing instance attributes and adding new methods at run-time.

Concept

Say you’re a fan of burgers and your favorite joint provides you with an option to customize your own burger by adding toppings, choosing your patty or even your bun. We can abstract our classes by first defining a burger interface to determine the final cost of what we have made using a totalPrice method and enabling our various components to implement our burger interface and get the final price of our burger.

Let’s look at it in code.

type burger interface {
    totalPrice() int
}

type gourmetBurger struct {}

func (b *gourmetBurger) totalPrice() int {
    return 8
}

type avocadoTopping struct {
    burger burger
}

func (a *avocadoTopping) totalPrice() int {
    burgerPrice := a.burger.totalPrice()
    return burgerPrice + 1
}

type eggTopping struct {
    burger burger
}

func (e *eggTopping) totalPrice() int {
    burgerPrice := e.burger.totalPrice()
    return burgerPrice + 2
}

func main() {
    burger := &gourmetBurger{}
    burgerWithAvocado := &avocadoTopping{
        burger: burger,
    }
    burgerWithAvocadoAndEgg := &eggTopping{
        burger: burgerWithAvocado
    }
    fmt.Printf("Total price of burger with egg and avocado topping is %d\n", burgerWithAvocadoAndEgg.totalPrice())
}

Output:

Total price of burger with egg and avocado topping is 11

4. Facade

Facade is used to abstract subsystem interfaces with a helper. It’s used in scenarios when the number of interfaces increases and the system gets complicated. The design pattern is used to improve poorly designed APIs. In a SOA, it can be used to incorporate changes to the contract and implementation.

Concept

It’s easy to underestimate the complexities that happen behind the scenes when you’re creating a bank account or depositing money into it. There are dozens of subsystems that are acting in this process. Here’s just a shortlist of them:

  • Verify branch manager’s credentials
  • Create customer account
  • Create transaction
  • Verify transaction
  • Deposit money into customer’s account

In a complex system like this, it’s easy to get lost in the code and break stuff if you don’t know what you’re doing. That’s why in this pattern there’s the facade class, module classes, and client that lets the client work with dozens of components using a simple interface. The client only needs to enter the amount they need to deposit and they’re done.

Let’s look at it in code.

type Account struct {
	id          string
	accountType string
}

// Account class method create - creates account given accountType
func (account *Account) create(accountType string) *Account {
	account.accountType = accountType
	return account
}

// Account class method getById given id string
func (account *Account) getById(id string) *Account {
	fmt.Println("Getting account by ID")
	return account
}

// Account class method deleteById given id string
func (account *Account) deleteById(id string) *Account {
	fmt.Println("Delete account by ID")
	return account
}

// Customer struct
type Customer struct {
	name string
	id   int
}

// Customer class method create - create Customer using name
func (customer *Customer) create(name string) *Customer {
	fmt.Println("Creating customer...")
	customer.name = name
	return customer
}

// Transaction struct
type Transaction struct {
	id            string
	amount        float32
	srcAccountId  string
	destAccountId string
}

// Transaction class method create - creates Transaction
func (transaction *Transaction) create(srcAccountId string, destAccountID string, amount float32) *Transaction {
	fmt.Println("Creating transaction...")
	transaction.srcAccountId = srcAccountId
	transaction.destAccountId = destAccountID
	transaction.amount = amount
	return transaction
}

// BranchManager struct
type BranchManager struct {
	account     *Account
	customer    *Customer
	transaction *Transaction
}

// NewBranchManager method
func NewBranchManager() *BranchManager {
	return &BranchManager{&Account{}, &Customer{}, &Transaction{}}
}

// BranchManager class method createCustomerAccount
func (facade *BranchManager) createCustomerAccount(customerName string, accountType string) (*Customer, *Account) {
	var customer = facade.customer.create(customerName)
	var account = facade.account.create(accountType)
	return customer, account
}

// BranchManager class method createTransaction
func (facade *BranchManager) createTransaction(srcAccountId string, destAccountId string, amount float32) *Transaction {
	var transaction = facade.transaction.create(srcAccountId, destAccountId, amount)
	return transaction
}

func main() {
	var facade = NewBranchManager()
	var customer *Customer
	var account *Account
	customer, account = facade.createCustomerAccount("Customer Name: Variety Jones", "Account Type: Checking")
	fmt.Println(customer.name)
	fmt.Println(account.accountType)
	var transaction = facade.createTransaction("21456", "87345", 1000)
	fmt.Println("$", transaction.amount, "has been successfully deposited into your checking account")
}

Output:

Creating customer...
Customer Name: Variety Jones
Account Type: Savings
Creating transaction...
$ 1000 has been successfully deposited into your savings account

5. Flyweight

Flyweight design pattern is used to manage the state of objects with high variation. It allows us to save on memory usage and consumption by sharing common parts of object states instead of each object storing it.

The pattern helps reduce overall memory usage and the initialization overhead by creating interclass relationships and save RAM by caching the same data used by different objects.

Concept

Suppose you’re building a game, let’s say Fortnite. Each player has a different type of clothing and customizations. You can embed the clothing object in the player object as shown below:

type player struct {
    playerType string
    clothing clothing
}

There are multiple players in the game and each scenario produces a new option concerning the type of clothing. E.g.

  1. Each player object creates a different clothing customization and embeds them. A total of 100 clothing objects will be created.

  2. We create two dress objects depending on the type of player:

    • Premium: Shared across 50 players
    • Basic: Shared across 50 players

Approach 1: 100 clothing objects are created Approach 2: 2 clothing objects are created

In the Flyweight design pattern, we go with Approach 2 and the clothing objects we’ve created are what are called flyweight objects. The flyweight objects are then shared among the players drastically reducing the number of clothing objects that even if you create more players, only 2 clothing objects will be sufficient.

Flyweight objects are immutable meaning that we store them in a map ensuring one instance per value.

Let’s see that in code.

type clothingFactory struct {
    pool map[string]clothing
}

func (factory clothingFactory) getClothingByType(playerType string) clothing {
    var cloth = factory.pool[playerType]
    if cloth == nil {
        fmt.Println("New object instance: " + playerType)
        switch playerType {
            case "premium account":
                factory.pool[playerType] = Clothing{color: "green"}
            case "basic account":
                factory.pool[playerType] = Clothing{color: "blue"}
        }
        cloth = factory.pool[playerType]
    }
    return cloth
}

type clothing interface {
    getColor() string
}

type Clothing struct {
    color string
}

func (clothing Clothing) getColor() string {
    return clothing.color
}

func main() {
    var factory = clothingFactory{make(map[string]clothing)}
    var premium clothing = factory.getClothingByType("premium account")
    fmt.Println("Color:", premium.getColor())
    var basic clothing = factory.getClothingByType("basic account")
    fmt.Println("Color:", basic.getColor())
}

Output:

New object instance: premium account
Color: green
New object instance: basic account
Color: blue