top of page
  • hakkiulku

Remarks from the Clean Architecture Part 2: SOLID Principles via Golang

Updated: Feb 17, 2023


SOLID principles are among the most famous principles in the software development world. It is first stated by Robert C. Martin who is the author of Clean Architecture book.


Every letter in “SOLID” represents a principle:

  • SRP: The Single Responsibility Principle

  • OCP: The Open-Closed Principle

  • LSP: The Liskov Substitution Principle

  • ISP: The Interface Segregation Principle

  • DIP: The Dependency Inversion Principle

In this post, we will be discussing all these principles by examples with Golang.


 

Single Responsibility Principle

This principle states that a class or a module should have only one responsibility to do and only one reason to change.


To exemplify:


type User struct {
   ID    string
   Name  string
   Email string
}

func (u User) CreateAndSendMail() {
   // code to create User
   // code to send email to the User about creation
}

Here there is a problem according to SRP. Could you see that? 🧐


The problem is Create() method should be only responsible for Creating a User but as you see above, there is a code also for emailing. So, this is not supposed to be there and any emailing and user-creating logic should be separated into different structs.


Instead of the above code, we can have something like this:


package main

type User struct {
 ID    string
 Name  string
 Email string
}

func (u User) Create() error {
 // code to create User
 return nil
}

type Email struct {
 From    string
 To      string
 Subject string
 Body    string
}

func (e Email) Send() error {
 // code to send email
 return nil
}

// below we implement the both logic (CreateAndSendMail())
func main() {

 user := User{
  ID:    "unique id",
  Name:  "user name",
  Email: "user@email.com",
 }

 // create user
 err := user.Create()
 if err != nil {
  // error handling logic
 }

 newEmail := Email{
  From:    "system",
  To:      user.Email,
  Subject: "User Creation",
  Body:    "The User is successfully created!",
 }
 // send email
 newEmail.Send()
}

In the above code, we totally separated the create and mail logic. So, both the User.Create() and Email.Send() are responsible for only one task and they have only one reason to change. However, there were two tasks in one function (CreateAndSendMail) in the previous version of the example which would make our system more complex over time.


By applying SRP, we have much more maintainable code so that our code will be easier to:

  • Understand

  • Extend

  • Maintain


The Open-Closed Principle

This principle states that a class or a module should be open for extension and closed for modification. Thanks to this principle, we don’t have to change our core logic when there is a new development. Instead, we extend the core logic.


To better understand the principle, let’s check an example:



type Shape interface {
   Area() float64
}

type Square struct {
   side float64
}

func (s Square) Area() float64  {
   return s.side * s.side
}

type Circle struct {
   radius float64
}

func (c Circle) Area() float64 {
   return math.Pi * c.radius * c.radius
}

type Drawing struct {
   shapes []Shape
}

func (d Drawing) TotalArea() float64  {
   total := 0.0
   for _, shape := range d.shapes {
      total += shape.Area()
   }
   return total
}

In this example, let’s assume that the TotalArea function is our business logic in the system. With the help of OCP, we should be able to open this business logic for extension but closed for modification.

Did you see that OCP is applied in the example so that the TotalArea function is open for extension but closed for modification? 🤔


Here is how:


Since the TotalArea function is developed independent of how the shape areas are calculated, we can use any shape within the function. So that we don’t have to change the logic in TotalArea (closed for modification) but we can calculate any type of shape (open for extension) whether it is a circle, triangle or rectangle.


Thanks to the OCP, our application would be easier to extend without a requirement for modification in the core logic.


The Liskov Substitution Principle This principle states that objects of a superclass should be replaced by objects of a subclass without affecting the correctness of the program.


To explain this principle, we will check an example:


Let’s assume that we have a code to place an order after payment and we want to accept any payment method.


type Payment interface {
   Process() error
}

type CreditCard struct{}

func (c CreditCard) Process() error {
   // code to process payment with credit card
}

type Paypal struct{}

func (p Paypal) Process() error {
   // code to process payment with PayPal
}

type Order struct {
   payment Payment
}

func (o Order) Place() error {
   // validate order
   return o.payment.Process()
}

In this example, as you can see Place() method can accept any type of payment method to process the payment. This code is compatible with LSP since the Process logic of the Payment interface can be replaced by any kind of method.


Thanks to LSP, we can write a super logic so that its sublogics (in this case credit card and Paypal) can be used interchangeably. This helps us to develop more flexible code.


The Interface Segregation Principle

ISP states that a client should not be forced to implement methods that it doesn’t need. According to this principle, interfaces should be segregated so that clients are implementing only what they need.


To better understand this, let’s check an example:

type Printable interface {
   Print() error
}

type Scannable interface {
   Scan() error
}

type Faxable interface {
   Fax() error
}

type Printer struct {}

func (p Printer) Print() error {
   // code to print
   return nil
}

type Scanner struct {}

func (s Scanner) Scan() error {
   // code to scan
   return nil
}

type Fax struct {}

func (f Fax) Fax() error {
   // code to fax
   return nil
}

In this example, as you can see all structs only implement the interfaces that they need.


In the opposite case, let’s assume Printable, Scannable and Faxable interfaces were written in one single interface. Then, the Printer struct would have to implement Scan and Fax methods which are totally unnecessary. Same for Scanner and Fax structs.


As a result, the ISP recommends us to segregate interfaces in a way that implementer modules are not implementing methods that they don’t need. Thanks to ISP, we can remove unnecessary dependencies and implementation as you see.


Dependency Inversion Principle

DIP states that high-level modules (like business logic) should not depend on low-level modules (such as database, mail service etc). Instead, dependencies should be on abstractions.


So that, core logic will be easier to maintain and new features can be added without affecting the core of the system.


In order to understand with an example, let’s check this:


type Database interface {
   Save(data string) error
}

type MySQL struct {}

func (m MySQL) Save(data string) error {
   // code to save data to MySQL
}

type User struct {
   db Database
}

func (u User) Save() error {
   return u.db.Save("user data")
}

In the above example, the User is not dependent on MySQL struct (low-level module in this case), instead, it is dependent on abstraction (Database interface).


Thanks to this, we do not have to depend on MySQL concretely, and we can use any database which implements the Database interface. So, the system can be more flexible thanks to DIP.


 

To conclude

Although these principles are not mandatory rules to implement, these are derived from the experiences of expert developers and implementing them is most of the time useful.


Here, I tried to state what I understand from the Clean Architecture with Golang examples.


I hope you find the blog helpful…


See you in upcoming posts 🤓

Коментари


bottom of page