Practical SOLID in Golang: Single Responsibility Principle

- 10 minutes read - 2008 words

We start a journey through the essential principles in software development by presenting the most well-known one: The Single Responsibility Principle.

There aren’t too many opportunities for a breakthrough in software development. They usually arise from either rewiring our logic after initial misunderstandings or filling in gaps in our knowledge. I appreciate that feeling of deeper understanding. It can happen during a coding session, while reading a book or an online article, or even while sitting on a bus. An internal voice follows, saying, “Ah, yes, that’s how it works.”

Suddenly, all past mistakes seem to have a logical reason, and future requirements take shape. I experienced such a breakthrough with the SOLID principles, which were first introduced in a document by Uncle Bob and later expounded upon in his book, “Clean Architecture.” In this article, I intend to embark on a journey through all the SOLID principles, providing examples in Go. The first principle on the list, representing the letter ‘S’ in SOLID, is the Single Responsibility Principle.

When we do not respect Single Responsibility

The Single Responsibility Principle (SRP) asserts that each software module should serve a single, specific purpose that could lead to change.

The sentence above comes directly from Uncle Bob himself. Initially, its application was linked to modules and the practice of segregating responsibilities based on the organization’s daily tasks. Nowadays, SRP has a broader scope, influencing various aspects of software development. We can apply its principles to classes, functions, modules, and naturally, in Go, even to structs.

Some Frankenstein of EmailService

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
}

Let’s analyze the code block above. In this code, we have a struct called EmailService, which contains only one method, Send. This service is intended for sending emails. Although it may seem okay at first glance, upon closer inspection, we realize that this code violates the Single Responsibility Principle (SRP) in several ways.

The responsibility of the EmailService is not limited to sending emails; it also involves storing an email message in the database and sending it via the SMTP protocol. Pay attention to the sentence above where the word “and” is emphasized. Using such an expression suggests that we are describing more than one responsibility. When describing the responsibility of a code struct necessitates the use of the word “and”, it already indicates a violation of the Single Responsibility Principle.

In our example, SRP is violated on multiple code levels. First, at the function level, the Send function is responsible for both storing a message in the database and sending an email via the SMTP protocol. Second, at the struct level, EmailService also carries two responsibilities: database storage and email sending.

What are the consequences of such code?

  1. When we need to change the table structure or the type of storage, we must modify the code for sending emails via SMTP.
  2. If we decide to integrate with different email service providers like Mailgun or Mailjet, we must alter the code responsible for storing data in the MySQL database.
  3. If we opt for various email integration methods within the application, each integration needs to implement logic for database storage.
  4. If we divide the application’s responsibilities into two teams, one for managing the database and the other for integrating email providers, they will need to work on the same code.
  5. Writing unit tests for this service becomes challenging, making it practically untestable.

So, let’s proceed to refactor this code.

How we do respect Single Responsibility

To separate the responsibilities and ensure that each code block has only one reason to exist, we should create a distinct struct for each responsibility. This entails having a separate struct for storing data in a storage system and another struct for sending emails through email service providers. Here’s the updated code block:

EmailRepository

type EmailGorm 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 EmailDBRepository 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 := EmailGorm{
		From:    from,
		To:      to,
		Subject: subject,
		Message: message,
	}

	err := r.db.Create(&email).Error
	if err != nil {
		log.Println(err)
		return err
	}

	return nil
}

EmailSender

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 {
	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
}

EmailService

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 introduce two new structs. The first one is EmailDBRepository, which serves as an implementation for the EmailRepository interface. It is responsible for persisting data in the underlying database. The second structure is EmailSMTPSender, implementing the EmailSender interface, and exclusively handling email sending over the SMTP protocol.

Now, you might wonder if EmailService still carries multiple responsibilities since it appears to involve both storing and sending emails. Have we merely abstracted the responsibilities without actually eliminating them? In this context, that is not the case. EmailService no longer bears the responsibility of storing and sending emails itself. Instead, it delegates these tasks to the underlying structs. Its sole responsibility is to forward email processing requests to the appropriate services. There is a clear distinction between holding and delegating responsibility. If removing a specific piece of code would render an entire responsibility meaningless, it’s a case of holding. However, if the responsibility remains intact even after removing certain code, it’s a matter of delegation. If we were to remove EmailService entirely, we would still have code responsible for storing data in a database and sending emails over SMTP. Therefore, we can confidently state that EmailService no longer holds these two responsibilities.

Some more examples

As we saw earlier, SRP applies to various coding aspects beyond just structs. We observed how it can be violated within a function, although that example was overshadowed by the broken SRP within a struct. To gain a better understanding of how the SRP principle applies to functions, let’s examine the example below:

SRP broken by a function

import "github.com/dgrijalva/jwt-go"

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)
}

The function extractUsername doesn’t have too many lines. It currently handles extracting a raw JWT token from the HTTP header and returning a value for the username if it’s present within the token. Once again, you may notice the use of the word “and”. This method has multiple responsibilities, and no matter how we rephrase its description, we can’t avoid using the word “and” to describe its actions. Instead of focusing on rephrasing its purpose, we should consider restructuring the method itself. Below, you’ll find a proposed new code:

SRP respected by the function

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
}

Now we have two new functions. The first one, extractRawToken, is responsible for extracting a raw JWT token from the HTTP header. If we ever need to change the key in the header that holds the token, we would only need to modify this one method. The second function, extractClaims, handles the extraction of claims from a raw JWT token. Finally, our old function extractUsername retrieves the specific value from the claims after delegating the tasks of token extraction to the underlying methods. There are many more examples of such refactoring possibilities, and we often encounter them in our daily work. We sometimes use suboptimal approaches because of frameworks that dictate the wrong approach or due to our reluctance to provide a proper implementation.

SRP broken by Active Record

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 ...", u.Username, u.Firstname, u.Lastname, u.Birthday).Error
}

The example above illustrates the typical implementation of the Active Record pattern. In this case, we have also included business logic within the User struct, not just data storage in the database. Here, we have combined the purposes of the Active Record and Entity patterns from Domain-Driven Design. To write clean code, we should use separate structs: one for persisting data in the database and another to serve as an Entity. The same mistake is evident in the example below:

SRP broken by Data Access Object

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
}

Once again, we encounter two responsibilities in the code. However, this time, the second responsibility (mapping to a database table using the Gorm package) is not explicitly expressed as an algorithm but through Go tags. Even in this case, the Wallet struct violates the SRP principle as it serves multiple purposes. If we modify the database schema, we must make changes to this struct. Likewise, if we need to update the business rules for withdrawing money, we would need to modify this class.

Struct for everything

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"`
}

The code snippet provided above is yet another example of violating the SRP, and in my opinion, it’s the most unfortunate one! It’s challenging to come up with a smaller struct that takes on even more responsibilities. When we examine the Transaction struct, we realize that it’s meant to serve as a mapping to a database table, act as a holder for JSON responses in a REST API, and, due to the validation part, it can also function as a JSON body for API requests. It’s essentially trying to do it all in one struct. All of these examples require adjustments sooner or later. As long as we maintain them in our code, they are silent issues that will eventually start causing problems in our logic.

Conclusion

The Single Responsibility Principle is the first of the SOLID principles, representing the letter “S” in the acronym. It asserts that a single code structure should have only one distinct reason to exist, which we interpret as responsibilities. A structure can either hold a responsibility or delegate it. When a structure encompasses multiple responsibilities, it’s a signal that we should consider refactoring that piece of code.

Useful Resources

comments powered by Disqus