paint-brush
GO Design Patterns: An Introduction to SOLIDby@danstenger
10,885 reads
10,885 reads

GO Design Patterns: An Introduction to SOLID

by DanielFebruary 15th, 2023
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

SOLID stands for: Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. These principles provide a foundation for creating maintainable and scalable software. Understanding the SOLID design pattern is an essential part of writing high-quality code.
featured image - GO Design Patterns: An Introduction to SOLID
Daniel HackerNoon profile picture

Software development is a complex and ever-evolving field, and it's important to have a set of design principles to guide you in creating high-quality, maintainable, and scalable code. One such set of principles is the SOLID.


SOLID stands for: Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. Together, these principles provide a foundation for creating maintainable and scalable software, and are widely considered best practices in software development.


In this blog post, I'll take a closer look at each of the SOLID principles, exploring what they mean and how they can be applied in GO. Understanding the SOLID design pattern is an essential part of writing high-quality code. So let's dive into the world of SOLID design and learn how these principles can help us create better software.


S - Single Responsibility Principle (SRP)

This principle states that a struct should have only one reason to change, meaning that a struct should have only one responsibility. This helps to keep the code clean and maintainable, as changes to the struct only need to be made in one place.

Let's say I have a struct Employee that keeps track of an employee's name, salary, and address:


type Employee struct {
    Name    string
    Salary  float64
    Address string
}


According to the SRP, each struct should have only one responsibility, so in this case, it would be better to split the responsibilities of the Employee struct into two separate structs: EmployeeInfo and EmployeeAddress.


type EmployeeInfo struct {
    Name   string
    Salary float64
}

type EmployeeAddress struct {
    Address string
}


Now, we can have separate functions that handle the different responsibilities of each struct:


func (e EmployeeInfo) GetSalary() float64 {
    return e.Salary
}

func (e EmployeeAddress) GetAddress() string {
    return e.Address
}


By following the SRP, I've made the code more maintainable and easier to understand, as each struct now has a clear and specific responsibility. If I need to make changes to the salary calculation or address handling, I know exactly where to look, without having to wade through a lot of unrelated code.


O - Open-Closed Principle (OCP)

This principle states that a struct should be open for extension but closed for modification, meaning that the behavior of a struct can be extended without changing its code. This helps to keep the code flexible and adaptable to changing requirements.


Let’s say I have a task to build a payment system that will be able to process credit card payments. It should also be flexible enough to accept different types of payment methods in future.


package main

import "fmt"

type PaymentMethod interface {
  Pay()
}

type Payment struct{}

func (p Payment) Process(pm PaymentMethod) {
  pm.Pay()
}

type CreditCard struct {
  amount float64
}

func (cc CreditCard) Pay() {
  fmt.Printf("Paid %.2f using CreditCard", cc.amount)
}

func main() {
  p := Payment{}
  cc := CreditCard{12.23}
  p.Process(cc)
}


As per OCP, my Payment struct is open for extension and closed for modification. Since I’m using PaymentMethod interface, I don’t have to edit Payment behavior when adding new payment methods. Adding something like PayPal would look like this:


type PayPal struct {
  amount float64
}

func (pp PayPal) Pay() {
  fmt.Printf("Paid %.2f using PayPal", pp.amount)
}

// then in main()
pp := PayPal{22.33}
p.Process(pp)

L - Liskov Substitution Principle (LSP)

Let's consider a struct Animal:

type Animal struct {
  Name string
}

func (a Animal) MakeSound() {
  fmt.Println("Animal sound")
}


Now, let's say we want to create a new struct Bird that represents a specific type of animal:


type Bird struct {
  Animal
}

func (b Bird) MakeSound() {
  fmt.Println("Chirp chirp")
}


This principle states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. This helps to ensure that the relationships between classes are well-defined and maintainable.


type AnimalBehavior interface {
  MakeSound()
}

// MakeSound represent a program that works with animals and is expected
// to work with base class (Animal) or any subclass (Bird in this case)
func MakeSound(ab AnimalBehavior) {
  ab.MakeSound()
}

a := Animal{}
b := Bird{}
MakeSound(a)
MakeSound(b)


This demonstrates inheritance in Go, as well as the Liskov Substitution Principle, as objects of a subtype Bird can be used wherever objects of the base type Animal are expected, without affecting the correctness of the program.


I - Interface Segregation Principle (ISP)

ISP states that clients should not be forced to depend on interfaces they do not use, meaning that the interfaces should be designed to be as small and specific as possible. This helps to keep the code flexible and avoids unnecessary coupling between classes.


D - Dependency Inversion Principle (DIP)

This principle states that high-level modules should not depend on low-level modules, but rather both should depend on abstractions. This helps to reduce the coupling between components and make the code more flexible and maintainable.

Suppose we have a struct Worker that represents a worker in a company, and a struct Supervisor that represents a supervisor:


type Worker struct {
  ID int
  Name string
}

func (w Worker) GetID() int {
  return w.ID
}

func (w Worker) GetName() string {
  return w.Name
}

type Supervisor struct {
  ID int
  Name string
}

func (s Supervisor) GetID() int {
  return s.ID
}

func (s Supervisor) GetName() string {
  return s.Name
}


Now, for the anti-pattern, let's say we have a high-level module Department that represents a department in a company, and needs to store information about the workers and supervisors, which are considered a low-level modules:


type Department struct {
  Workers []Worker
  Supervisors []Supervisor
}


According to the Dependency Inversion Principle, high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. To fix my anti-pattern example, I can create an interface Employee that represents both, worker and supervisor:


type Employee interface {
  GetID() int
  GetName() string
}


Now I can update the Department struct so it no longer depends on low-level modules:


type Department struct {
  Employees []Employee
}


And for a full working example:


package main

import "fmt"

type Worker struct {
  ID   int
  Name string
}

func (w Worker) GetID() int {
  return w.ID
}

func (w Worker) GetName() string {
  return w.Name
}

type Supervisor struct {
  ID   int
  Name string
}

func (s Supervisor) GetID() int {
  return s.ID
}

func (s Supervisor) GetName() string {
  return s.Name
}

type Employee interface {
  GetID() int
  GetName() string
}

type Department struct {
  Employees []Employee
}

func (d *Department) AddEmployee(e Employee) {
  d.Employees = append(d.Employees, e)
}

func (d *Department) GetEmployeeNames() (res []string) {
  for _, e := range d.Employees {
    res = append(res, e.GetName())
  }
  return
}

func (d *Department) GetEmployee(id int) Employee {
  for _, e := range d.Employees {
    if e.GetID() == id {
      return e
    }
  }
  return nil
}

func main() {
  dep := &Department{}
  dep.AddEmployee(Worker{ID: 1, Name: "John"})
  dep.AddEmployee(Supervisor{ID: 2, Name: "Jane"})

  fmt.Println(dep.GetEmployeeNames())

  e := dep.GetEmployee(1)
  switch v := e.(type) {
  case Worker:
    fmt.Printf("found worker %+v\n", v)
  case Supervisor:
    fmt.Printf("found supervisor %+v\n", v)
  default:
    fmt.Printf("could not find an employee by id: %d\n", 1)
  }
}


This demonstrates the Dependency Inversion Principle, as the Department struct depends on an abstraction (the Employee interface), rather than on a specific implementation (the Worker or Supervisor struct). This makes the code more flexible and easier to maintain, as changes to the implementation of workers and supervisors will not affect the Department struct.


Adopting SOLID principles requires a shift in the way you think about software design, but the benefits are well worth the effort. By following SOLID principles, you can improve the quality of your code, reduce the time and effort required to make changes and increase the overall maintainability and longevity of the project.


While these principles can be complex and challenging to implement, they provide a foundation for writing robust, scalable software that will continue to meet your needs for years to come.

So, take the time to understand the SOLID principles and incorporate them into your next project. Your future self will thank you!