Practical SOLID in Golang: Single Responsibility Principle

Relia Software

Relia Software

Thuoc Nguyen

Relia Software

Software Development

The Single Responsibility Principle (SRP) states that each software module should have one and only one reason to change.

The Single Responsibility Principle (SRP)

Table of Contents

SOLID principles were first introduced in documentation from Uncle Bob and later on he wrote the book Clean Architecture. We will learn about SOLID principles that start with the letter S, which is the Single Responsibility principle. Let's begin our journey through the most known principle in software development - The Single Responsibility Principle via this article!

>> You may consider:

What is Single Responsibility Principle?

The Single Responsibility Principle (SRP) states that each software module should have one and only one reason to change.

It entails creating module bindings and separating responsibilities by mapping them to day-to day tasks. SRP covers several areas of programming and may be applied to classes, functions, modules, and structs in the Go programming language.

When We Do Not Respect Single Responsibility?

type EmailService struct {
    db *gorm.DB
    smtpHost string
    smtpPassword string
    smtpPort int
}

func NewEmailService(db *gorm.DB, smtpHost string, smtpPassword string, smtpPort int) *EmailService {
    return &EmailService{
        db:             db,
        smtpHost:       smtpHost,
        smtpPassword:   smtpPassword,
        smtpPort:       smtpPort,
    }
}

func (s *EmailService) Send(from string, to string, subject string, message string) error {
    email := EmailGorm{
        From:      from,
        To:        to,
        Subject:   subject,
        Message:   message,
    }
    err := s.db.Create(&email).Error
    if err != nil {
        log.Println(err)
        return err
    }
    
    auth := smtp.PlainAuth("", from, s.smtpPassword, s.smtpHost)
    server := fmt.Sprintf("%s:%d", s.smtpHost, s.smtpPort)
    err = smtp.SendMail(server, auth, from, []string{to}, []byte(message))
    if err != nil {
        log.Println(err)
        return err
    }
    return nil
}

We have a struct, EmailService, with only one method Send.The service is used to send emails. Although it appears to work, we can see that when we screw with the surface, this code breaches every part of SRP. EmailService in charge of not only sending emails, but also storing them in the database and sending them over the SMTP protocol. This is not the same as decribing someone as having exlusive responsibility.

As soon as describing the responsibility of some code struct requires the usage of the word "and", it already breaks the Single Responsibility Principle.

In the example, we broke SRP in several levels of code. The first is the level of function. The Send function is in charge of saving the message text in the database and transmitting it over the SMTP protocol. The struct level EmailService is the second. As we have seen, it has two functions: storing data in the database and sending emails. What are the consequences of that code?

  1. When we change a table structure or type of storage, we need to change a code for sending emails over SMTP..
  2. When we want to integrate Mailgun or Mailjet, we need to change a code for storing data in the MySQL database.
  3. If we choose different integration of sending emails in the application, each integration needs to have a logic to store data in the database.
  4. Suppose we decide to split the application's responsibility into two teams, one for maintaining a database and the second one for integrating email providers. In that case, they will work on the same code.
  5. This service is practically untestable with unit tests.

So let's rewrite the code.

How Do We Respect Single Responsibility?

We need construct struct for each fragment to separate the responsibilities in this situation and make the code have just one purpose to exist. That implies a separate structure must be created for keeping data in memory and another for delivering emails via email service provider integration. The code will be as follows:

type EmailGora struct {
	gorm.Model
	From string
	To string
	Subject string
	Message string
}

type EmailRepository interface {
	Save(from string, to string, subject string, message string) error
}

type EmaLLDBRepository struct {
    db *gorm.DB
}

func NewEmailRepository(db *gorm.DB) EmailRepository {
    return &EmaiLDBRepository{
        db: db,
    }
}

func (r *EmaiLDBRepository) Save(from string, to string, subject string, message string) error {
	email := EmailGoraf{
		From: from,
		To:   to,
		Subject: subject,
		Message: message,
	}
	err := r.db.Create(&email).Error
	if err != nil {
		log.Println(err)
		return err
	}
	return nil
}

type EmailSender interface {
	Send(from string, to string, subject string, message string) error
}

type EmailSMTPSender struct {
	smtpHost    string
	smtpPassword string
	smtpPort    int
}

func NewEmailSender(smtpHost string, smtpPassword string, smtpPort int) EmailSender {
    return &EmailSMTPSender{
        smtpHost: smtpHost,
        smtpPassword: smtpPassword,
        smtpPort: smtpPort,
    }
}
func (s *EmailSMTPSender) Send(from string, to string, subject string, message string) error {
	server := fmt.Sprintf("%s:%d", s.smtpHost, s.smtpPort)
	auth := smtp.PlainAuth("", from, s.smtpPassword, server)
	err := smtp.SendMail(server, auth, from, []string{to}, []byte(message))
	if err != nil {
		log.Println(err)
		return err
	}
	return nil
}

type EmailService struct {
	repository EmailRepository
	sender     EmailSender
}

func NewEmailService(repository EmailRepository, sender EmailSender) *EmailService {
	return &EmailService{
		repository: repository,
		sender:     sender,
    }
}

func (s *EmailService) Send(from string, to string, subject string, message string) error {
    err := s.repository.Save(from, to, subject, message)
    if err != nil {
        return err
    }
    return s.sender.Send(from, to, subject, message)
}

Here we have two structures. First is EmailDBRepository is an implementation for EmailRepository interface. It includes support for persistent data in the database. The second structure is EmailSMTPSender implementation EmailSender interface. This structure is only responsible for sending email through the SMTP protocol. Finally EmailService contains the above interface that authorizes the request to send email.

There is a question that: whether EmailService still has many responsibilities, does it still keep a logic to store and send emails? Looks like we just made an abstraction, but the tasks are still there? EmailService isn't in charge of email storage or transmission. They are delegated to the underlying structures. Its job is to delegate email processing to fundamental services.

There is a difference between holding and delegating responsibility. If an adaptation of a particular code can remove the whole purpose of responsibility, we talk about holding. If that responsibility still exists even after removing a specific code, then we talk about delegation.

Even if we delete EmailService, we still need to add code to store data and send email over SMTP. That means EmailService does not hold two responsibilities.

Some More Single Responsibility Principle Examples

SRP applies to many various features of code, not only structure, as we have shown. We can see that we could break it for the function, but that example was already cast in the shadow of the structure broken SRP. Let's look at an example of how the SRP principle is applied to functions to have a better understanding:

func extractUsername(header http.Header) string {
    raw := header.Get("Authorization")
    parser := &jwt.Parser{}
    token, _, err := parser.ParseUnverified(raw, jwt.MapClaims{})
    if err != nil {
        return
    }
    claims, ok := token.Claims.(jwt.MapClaims)
    if !ok {
        return
    }
    return claims["username"].(string)
}

Function exatractUsername without too many lines. It provides support to extract the raw JWT token from the HTTP header and returns a value for the username if present inside. The words in bold and can be seen once more. No matter how we alter its description, this function has a lot of obligations.

To express what the procedure performs, we can't resist using the word and. We should be more concerned in redesigning the method itself rather than rewriting a way to communicate the function purpose. There is one idea for the new code:

func extractUsername(header http.Header) string {
    raw := extractRawToken(header)
    claims := extractClaims(raw)
    if claims == nil {
        return
    }
    return claims["username"].(string)
}

func extractRawToken(header http.Header) string {
    return header.Get("Authorization")
}

func extractClaims(raw string) jwt.MapClaims {
    parser := &jwt.Parser{}
    token, err := parser.ParseUnverified(raw, jwt.MapClaims{})
    if err != nil {
        return nil
    }
    claims, ok := token.Claims.(jwt.MapClaims)
    if !ok {
        return nil
    }
    return claims
}

We now have two new function. First is extracRawToken, responsible for extracting the raw JWT token from the HTTP header. If we change a key in the header, we should change only one method. Next is extractClaims. This method is responsible for extracting claims from the raw JWT token. Finally, our old function extracts Username get specific value from claims after delegating token extraction requests to underlying methods.

More examples are available. We utilize several of them on a regular basis. Certain of them are used because some frameworks mandate the incorrect technique or because we are too lazy to come up with an appropriate implementation.

type User struct {
    db *gorm.DB
    Username string
    Firstname string
    Lastname string
    Birthday time.Time
    //
    // some more fields
    //
}

func (u User) IsAdult() bool {
    return u.Birthday.AddDate(18, 0, 0).Before(time.Now())
}

func (u *User) Save() error {
    return u.db.Exec("INSERT INTO users (Username, Firstname, Lastname, Birthday) VALUES (?, ?, ?, ?)",
        u.Username, u.Firstname, u.Lastname, u.Birthday).Error
}

The example above shows a typical implementation of Active Record. In this case, we add business logic inside the structure User, do not just store data in the database. Here we have mixed the purpose of Active Record and Entity from design Domain-Driven. To effectively distribute the code, we need to create two different structures: one for database data and the other for the role of Entity. The next example makes the same error:

type Wallet struct {
    gorm.Model

    Amount int  `gorm:"column:amount"`
    CurrencyID int `gorm:"column:currency_id"`
}

func (w *Wallet) Withdraw(amount int) error {
    if amount > w.Amount {
        return errors.New("there is no enough money in wallet")
    }

    w.Amount -= amount
    return nil
}

We still have two responsibilities, but the second one (mapping for a database table using the Gorm package) is now defined as Go tags rather than an algorithm. Because it layers several roles, the Wallet structure violates the SRP principle. This structure must be adjusted if the database schema is changed. This class must be adjusted if the withdrawal business rules are changed.

type Transaction struct {
    gorm.Model

    Amount int          `gorm:"column: amount" json:"amount" validate:"required"`
    CurrencyID int       `gorm:"column:currency_id" json:"currency_id" validate:"required"`
    Time time.Time `gorm:"column:time" json:"time" validate:"required"`
}

Another example of breaching SRP is the code above. And this is, a tragedy. We cannot entrust more responsibility to a small structure. Looking at the transaction structure, we can see that it is there to explain the mapping to the database table and to store the JSON response in the REST API, but it may also be the body of the JSON for the request API due to the validation section. Everything is contained in a single structure.

All of the above wallets require adaptation sooner or later. As long as we keep them in our code, they are silent problems that will soon break our logic.

>> Read more about topics related to Golang:

Conclusion

The first of the SOLID principles is the Single Responsibility Principle. It turns out that a coding structure only exists for one reason. Those reasons are viewed as duties by us. A structure might be responsible for something or delegate authority to it. When a piece of code in our structure has a lot of responsibilities, we should rework it.

>>> Follow and Contact Relia Software for more information!

  • golang
  • coding
  • development