Contact


Blog

Introduction to SOLID Design Principles in Golang

Posted by

Parikshit Gothwal on 22 Mar 2024

147
0

SOLID design principles were introduced by Robert C Martin, aka Uncle Bob, as a coding practice for writing code that is solid, easy to understand, maintain, extend, and test.

These are the 5 SOLID design principles.

Single Responsibility Principle (SRP)

Open Closed Principle (OCP)

Liskov Substitution Principle (LSP)

Interface Segregation Principle (ISP)

Dependency Inversion Principle (DIP)

Each of these five design principles solves a particular problem that might arise while writing the code. Following these design principles would reduce the code complexity and make it easier to add new functionality by reducing dependencies and impacting the legacy code.

We are going to see examples of each principle in Golang.

 

SOLID Design Principle #1- Single Responsibility Principle

The Single Responsibility Principle states that a type should have a single primary responsibility and, therefore, should have a single reason to change. Each function, module, or package should focus on a single task, meaning it should have only one responsibility.

Let’s see this with the help of an example:

 

type Role struct {
        Name        string
        Permissions []string
}

func (r *Role) AddPermission(p string) {
        r.Permissions = append(r.Permissions, p)
}

func (r *Role) String() string {
        return strings.Join(r.Permissions, ", ")
}

We have a structure ‘Role’ with the primary responsibility of adding, removing, and displaying permissions as a string. Let’s say we want to define another function that assigns a role to a user.

func (r *Role) AssignRole(u User) {
        // assigns role to a user
}

As shown above, defining a method on a struct ‘Role’ that adds a role to a user violates the SRP principle. When assigning a role to a user, we can define a new type that implements the method that assigns a role as follows.

type User struct {
// user fields
}

func (u *User) AssignRole(r Role) {
// assign role to a user
}

This way, SRP can help us design more definitive components or modules and make the code easy to maintain, read, and understand. It also increases the code reusability and makes it easy to test.

Some Go libraries that demonstrate SRP:

  1. time: Has all the methods and types needed for time manipulation
  2. encoding/json: Has methods related to encoding and decoding JSON objects
  3. net/http: It has methods that provide HTTP client-server implementations

 

SOLID Design Principle #2- Open Close Principle

The OCP principle states that a type should be open for extension but closed for modifications.

Let’s see this with the help of an example:

type Mobile struct {
        Brand  string
        Memory int
}

type Filter struct{}

func (f *Filter) FilterMobileByBrand(Mobiles []Mobile, brand string) []Mobile {
var matches []Mobile
for _, p := range Mobiles {
  if p.Brand == brand {
    matches = append(matches, p)
  }
}
return matches
}

func main() {
m1 := Mobile{"Apple", 512}
m2 := Mobile{"Samsung", 1024}

list := []Mobile{m1, m2}
f := &Filter{}

fmt.Println(f.FilterMobileByBrand(list, "Apple"))
}

As you can see, we have a type ‘Filter,’ which uses a method to filter mobile phones based on their brand. But in the future, we can have another method of type ‘Filter’ that filters mobiles based on their memory size, as shown below.

func (f* Filter) FilterMobileByMemory(Mobiles []Mobile, memory int) []Mobile {
var matches []Mobile
for _, p := range Mobiles {
  if p.Memory == memory {
    matches = append(matches, p)
  }
}
return matches
}

func main() {
m1 := Mobile{"Apple", 512}
m2 := Mobile{"Samsung", 1024}

list := []Mobile{m1, m2}
f := &Filter{}

fmt.Println(f.FilterMobileByMemory(list, 1024))
}

This violates the OCP principle as we have to modify the filter type and devise a new method. To fix this according to OCP, we can define an interface that consists of a method that gives whether the mobile phone matched or not, as shown below.

type Matcher interface {
        Matched(mobile Mobile) bool
}

type Brand struct {
        Name string
}

func (b *Brand) Matched(mobile Mobile) bool {
        return mobile.Brand == b.Name
}

type Memory struct {
        Size int
}

func (m *Memory) Matched(mobile Mobile) bool {
        return mobile.Memory == m.Size
}

func (f *Filter) Filter(mobiles []Mobile, matcher Matcher) []Mobile {
var matches []Mobile
for _, m := range mobiles {
  if matcher.Matched(m) {
    matches = append(matches, m)
  }
}
return matches
}

func main() {
m1 := Mobile{"Apple", 512}
m2 := Mobile{"Samsung", 1024}

list := []Mobile{m1, m2}
brand := &Brand{"Apple"}
memory := &Memory{1024}
f := &Filter{}

fmt.Println(f.Filter(list, brand))
fmt.Println(f.Filter(list, memory))
}

As you can see, we have two types that implement the ‘Matcher’ interface. We can implement the filter using the methods defined by these types, which allows you to add new functionality to your code without changing existing code.

 

SOLID Principle #3- Liskov Substitution Principle

LSP states that subtypes must be substitutable for their base types. It primarily deals with inheritance; if we have a method that works with a base type, it should also work with the derived subtype. In Golang, we don’t have inheritance, but we can compose multiple structs to implement LSP.

Let’s see this with the help of an example.

type Rectangle struct {
  length, width int
}

func (r *Rectangle) Area() int {
  return r.length * r.width
}

type Square struct {
  Rectangle
}

func CalculateArea(r Rectangle) int {
  return r.Area()
}

func main() {
  rectangle := Square{Rectangle{3, 3}}
  CalculateArea(rectangle) // cannot use rectangle (variable of type Square) as Rectangle value in argument to Area
}

The above code has a type ‘Rectangle’ with a method ‘Area().’ We have another type, ‘Square,’ a subtype of ‘Rectangle.’ However, composition does not allow the substitution of the parent struct by the child struct, so this code will throw an error. But in Golang, we can define an interface, which is a definition or defines a behavior of a type. So we can define an interface with the method ‘Area,’ and both our types can implement that interface as shown below.

type Shape interface {
  Area() int
}

func CalculateArea(s Shape) int {
  return s.Area()
}

func main() {
  rectangle := Square{Rectangle{length: 4, width: 4}}
  fmt.Println(CalculateArea(rectangle))
}

This way, we can apply LSP as the ‘Square’ type could be substituted by the ‘Rectangle’ type. The Liskov Substitution Principle helps to ensure that your code is flexible, maintainable, and less prone to errors.

 

SOLID Principle #4- Interface Substitution Principle

It states that clients should not be forced to depend on interfaces they do not use. It says that you should not put too much into an interface but break it into smaller interfaces.

For example, we have an interface called ‘Object’ with the following methods.

type Object interface{
  MakeCall()
  ClickPhoto()
  SendEmail()
}

However, not all the clients that use this interface implement all the methods. Using ISP, we can break down our interface into smaller interfaces and define specific types that implement the methods they can perform, as shown below.

type Caller interface{
  MakeCall()
}

type Photographer interface{
  ClickPhoto()
}

type Emailer interface{
  SendEmail()
}

type Camera struct{} // implements Caller
func (c *Camera)ClickPhoto(){}

type Computer struct{} // implements Emailer
func (c *Computer) SendEmail(){}

type Mobile struct{} // implements Caller, Emailer, and Photographer
func (m *Mobile) MakeCall(){}
func (m *Mobile) ClickPhoto(){}
func (m *Mobile) SendEmail(){}

The type ‘Camera’ implements the method ‘CilckPhoto(),’ the type ‘Computer’ implements ‘SendEmail(), ‘ and the type ‘Mobile’ implements all the methods. In this way, using ISP makes the code more flexible, easy to understand, and maintain.

 

SOLID Principle #5- Dependency Inversion Principle

This principle states that high-level modules should not depend on low-level modules, but rather, both should depend on abstractions. Let’s understand this with an example.

type Parent struct {
  ID int
  Name string
}

func (p *Parent) GetID() int {
  return p.ID
}

func (p *Parent) GetName() string {
  return p.Name
}

type Child struct {
  ID int
  Name string
}

func (c *Child) GetID() int {
  return p.ID
}

func (p *Child) GetName() string {
  return p.Name
}

type Family struct {
  Parents []Parent
  Childs []Child
}

Suppose we have two structures, ‘Parent’ and ‘Child,’ and a high-level module called ‘Family,’ which consists of information about low-level modules ‘Parent’ and ‘Child.’
To fix this according to DIP so that the high-level module doesn’t depend on the low-level module, we can create an interface representing a ‘Person’ in a family and update the high-level module ‘Family’ to no longer depend on low-level modules.

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

type Family struct {
  Persons []Person
}

This helps to reduce the coupling between components and make the code more flexible and maintainable.

 

Conclusion

SOLID design principles are the foundation of good software as they enhance code readability and make it easier to understand and test. It also helps save time and effort in both development and maintenance. SOLID principles take some time to understand, but if you write code following them, you will improve code quality and create the most well-designed software. Gophers Lab offers top Golang Development Services to help businesses build reliable, scalable, and performant Golang applications. We follow industry best practices and superior engineering for your projects. Get in touch with us to know more about our offerings.



Leave a Reply

Your email address will not be published. Required fields are marked *

hire dedicated resource

Talk to Our Experts

    Get in Touch with us for a Walkthrough