Skip to main content
  1. Articles/

Practical SOLID in Golang: Single Responsibility Principle

·2013 words·10 mins· loading · loading ·
Marko Milojevic
Author
Marko Milojevic
Software engineer and architect. Golang and LLM enthusiast. Awful chess player, gym rat, harmonica newbie and cat lover.
SOLID principles in Golang - This article is part of a series.
Part 1: This Article

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
#

SOLID principles in Golang - This article is part of a series.
Part 1: This Article

Related

Practical DDD in Golang: Specification

·1596 words·8 mins· loading · loading
There are not many code structures that bring me joy whenever I need to write them. The first time I implemented such code was with a lightweight ORM in Go, back when we didn’t have one. However, I used ORM for many years, and at some point, when you rely on ORM, using QueryBuilder becomes inevitable. Here, you may notice terms like “predicates”, and that’s where we can find the Specification pattern. It’s hard to find any pattern we use as Specification, yet we do not hear its name. I think the only thing harder is to write an application without using this pattern. The Specification has many applications. We can use it for querying, creation, or validation. We may provide a unique code that can do all this work or provide different implementations for different use cases. For Validation # The first use case for the Specification pattern is validation. Typically, we validate data in forms, but this is at the presentation level. Sometimes, we perform validation during creation, such as for Value Objects. In the context of the domain layer, we can use Specifications to validate the states of Entities and filter them from a collection. So, validation at the domain layer has a broader meaning than for user inputs. Base Product Specification type Product struct { ID uuid.UUID Material MaterialType IsDeliverable bool Quantity int } type ProductSpecification interface { IsValid(product Product) bool } A simple Product Specification type HasAtLeast struct { pieces int } func NewHasAtLeast(pieces int) ProductSpecification { return HasAtLeast{ pieces: pieces, } } func (h HasAtLeast) IsValid(product Product) bool { return product.Quantity >= h.pieces } In the example above, there is an interface called ProductSpecification. It defines only one method, IsValid, which expects instances of Product and returns a boolean value as a result if the Product passes validation rules. A simple implementation of this interface is HasAtLeast, which verifies the minimum quantity of the Product. Function as a Product Specification type FunctionSpecification func(product Product) bool func (fs FunctionSpecification) IsValid(product Product) bool { return fs(product) } func IsPlastic(product Product) bool { return product.Material == Plastic } func IsDeliverable(product Product) bool { return product.IsDeliverable } More interesting validators are two functions, IsPlastic and IsDeliverable. We can wrap those functions with a specific type, FunctionSpecification. This type embeds a function with the same signature as the two mentioned. Besides that, it provides methods that respect the ProductSpecification interface. This example is a nice feature of Go, where we can define a function as a type and attach a method to it so that it can implicitly implement some interface. In this case, it exposes the method IsValid, which executes the embedded function.

Practical DDD in Golang: Repository

·2108 words·10 mins· loading · loading
Today, it is hard to imagine writing an application without accessing some form of storage at runtime. This includes not only writing application code but also deployment scripts, which often need to access configuration files, which are also a type of storage in a sense. When developing applications to solve real-world business problems, connecting to databases, external APIs, caching systems, or other forms of storage is practically unavoidable. It’s no surprise, then, that Domain-Driven Design (DDD) includes patterns like the Repository pattern to address these needs. While DDD didn’t invent the Repository pattern, it added more clarity and context to its usage. The Anti-Corruption Layer # Domain-Driven Design (DDD) is a principle that can be applied to various aspects of software development and in different parts of a software system. However, its primary focus is on the domain layer, which is where our core business logic resides. While the Repository pattern is responsible for handling technical details related to external data storage and doesn’t inherently belong to the business logic, there are situations where we need to access the Repository from within the domain layer. Since the domain layer is typically isolated from other layers and doesn’t directly communicate with them, we define the Repository within the domain layer, but we define it as an interface. This interface serves as an abstraction that allows us to interact with external data storage without tightly coupling the domain layer to specific technical details or implementations. A Simple Repository example type Customer struct { ID uuid.UUID // // some fields // } type Customers []Customer type CustomerRepository interface { GetCustomer(ctx context.Context, ID uuid.UUID) (*Customer, error) SearchCustomers(ctx context.Context, specification CustomerSpecification) (Customers, int, error) SaveCustomer(ctx context.Context, customer Customer) (*Customer, error) UpdateCustomer(ctx context.Context, customer Customer) (*Customer, error) DeleteCustomer(ctx context.Context, ID uuid.UUID) (*Customer, error) } The interface that defines method signatures within our domain layer is referred to as a “Contract.” In the example provided, we have a simple Contract interface that specifies CRUD (Create, Read, Update, Delete) methods. By defining the Repository as this interface, we can use it throughout the domain layer. The Repository interface always expects and returns our Entities, such as Customer and Customers (collections with specific methods attached to them, as defined in Go). It’s important to note that the Entity Customer doesn’t contain any information about the underlying storage type, such as Go tags for defining JSON structures, Gorm columns, or anything of that sort. This kind of low-level storage configuration is typically handled in the infrastructure layer.

Practical DDD in Golang: Factory

·1023 words·5 mins· loading · loading
When I wrote the title of this article, I was trying to remember the first design pattern I had learned from “The Gang of Four”. I think it was one of the following: Factory Method, Singleton, or Decorator. I am sure that other software engineers have a similar story. When they started learning design patterns, either Factory Method or Abstract Factory was one of the first three they encountered. Today, any derivative of the Factory pattern is essential in Domain-Driven Design, and its purpose remains the same, even after many decades. Complex Creations # We use the Factory pattern for any complex object creation or to isolate the creation process from other business logic. Having a dedicated place in the code for such scenarios makes it much easier to test separately. In most cases, when I provide a Factory, it is part of the domain layer, allowing me to use it throughout the application. Below, you can see a simple example of a Factory. Simple example type Loan struct { ID uuid.UUID // // some fields // } type LoanFactory interface { CreateShortTermLoan(specification LoanSpecification) Loan CreateLongTermLoan(specification LoanSpecification) Loan } The Factory pattern goes hand-in-hand with the Specification pattern. Here, we have a small example with LoanFactory, LoanSpecification, and Loan. LoanFactory represents the Factory pattern in DDD, and more specifically, the Factory Method. It is responsible for creating and returning new instances of Loan that can vary depending on the payment period. Variations # As mentioned, we can implement the Factory pattern in many different ways. The most common form, at least for me, is the Factory Method. In this case, we provide some creational methods to our Factory struct. Loan Entity const ( LongTerm = iota ShortTerm ) type Loan struct { ID uuid.UUID Type int BankAccountID uuid.UUID Amount Money RequiredLifeInsurance bool } Loan Factory type LoanFactory struct{} func (f *LoanFactory) CreateShortTermLoan(bankAccountID uuid.UUID, amount Money) Loan { return Loan{ Type: ShortTerm, BankAccountID: bankAccountID, Amount: amount, } } func (f *LoanFactory) CreateLongTermLoan(bankAccountID uuid.UUID, amount Money) Loan { return Loan{ Type: LongTerm, BankAccountID: bankAccountID, Amount: amount, RequiredLifeInsurance: true, } } In the code snippet from above, LoanFactory is now a concrete implementation of the Factory Method. It provides two methods for creating instances of the Loan Entity. In this case, we create the same object, but it can have differences depending on whether the loan is long-term or short-term. The distinctions between the two cases can be even more complex, and each additional complexity is a new reason for the existence of this pattern.