Practical SOLID in Golang: Single Responsibility Principle
- 10 minutes read - 2008 wordsWe 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?
- When we need to change the table structure or the type of storage, we must modify the code for sending emails via SMTP.
- 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.
- If we opt for various email integration methods within the application, each integration needs to implement logic for database storage.
- 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.
- 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.