SOLID Go: Single Responsibility Principle
Intro Single Responsibility Principle
According to the Single Responsibility Principle, you should design a building block of your software so that it is responsible for one task only. In other words: To have just one job only.
In object-oriented programming, e.g. packages, classes or methods are considered building blocks of a codebase. Great designed building blocks will increase the overall cohesion throughout the software system. Furthermore, having a good level of cohesion per building block will lower the amount of coupling between the building blocks. Caring for these two criteria positively affects the inner software quality of your code. You will have fewer building block changes during software maintenance and refactoring.
When it comes to Golang, the Single Responsibility Principle is directly applicable.
The palette of Golang’s primary building blocks essentially contains packages, structs, interfaces and functions. So when applying the Single Responsibility Principle, you can manage their essential scope of responsibility.
Single Responsibility Principle applied to Golang Functions
See the listing below for an invalid example of the Single Responsibility Principle regarding Golang functions.
1// This function is violating the Single Responsibility Principle
2
3package main
4
5import (
6 "fmt"
7 "math"
8)
9
10// Function that violates the Single Responsibility Principle
11// Reason: The function contains three different responsibilities.
12// Calculating an area of square, area of rectangle and area of circle.
13
14func CalculateShapesArea(shape string, sideA int64, sideB int64) (int64, error) {
15 if shape == "square" {
16 return sideA * sideA, nil
17 }
18
19 if shape == "rectangle" {
20 return sideA * sideB, nil
21 }
22
23 if shape == "circle" {
24 rr := float64(sideA * sideA)
25 a := math.Round(math.Pi * rr)
26 return int64(a), nil
27 }
28
29 return -1, fmt.Errorf("Unknown shape: %s", shape)
30}
31
32func main() {
33 area, _ := CalculateShapesArea("circle", 6, -1)
34 fmt.Println("Circle Area: ", area)
35
36 area, _ = CalculateShapesArea("square", 5, -1)
37 fmt.Println("Square Area: ", area)
38
39 area, _ = CalculateShapesArea("rect", 5, 10)
40 fmt.Println("Reactangle Area: ", area)
41}
In software engineering, there can be many ways to solve a particular problem. Therefore consider the following example as just one way to refactor the above code.
1// These functions are compliant to the Single Responsibility Principle
2
3package main
4
5import (
6 "math"
7 "fmt"
8)
9
10func CalculateSquareArea(sideA int64) int64 {
11 return sideA * sideA
12}
13
14func CalculateRectangleArea(sideA int64, sideB int64) int64 {
15 return sideA * sideB
16}
17
18func CalculateCircleArea(r int64) int64 {
19 rr := float64(r * r)
20 a := math.Round(math.Pi * rr)
21 return int64(a)
22}
23
24func main() {
25 area := CalculateCircleArea(6)
26 fmt.Println("Circle Area: ", area)
27
28 area = CalculateSquareArea(5)
29 fmt.Println("Square Area: ", area)
30
31 area = CalculateRectangleArea(5, 10)
32 fmt.Println("Reactangle Area: ", area)
33}
Single Responsibility applied to Structs
You can use structs to group variables and functions into an object-like structure. Since structs usually come with defining a new struct-datatype, people use them the most for creating models or service layer concepts like services, factories, repositories and so on.
This idea of grouping things makes it very easy to violate the Single Responsibility Principle when using structs in your code. But no worries. As long as you initially analyse and continuously refine your mental model of the software or domain you are working on, it becomes easier to split clunky structs that perform too many jobs into several more focused structs.
1package main
2
3import (
4 "encoding/json"
5 "fmt"
6 "time"
7)
8
9// Struct that violates the Single Responsibility Principle
10
11type FriendModel struct {
12 Name string `json:"name"`
13 FriendsSince *time.Time `json:"friendsSince"`
14 SharesSnacks bool `json:"sharesSnacks"`
15}
16
17func (m *FriendModel) IsValid() (bool, error) {
18 if len(m.Name) < 1 {
19 return false, fmt.Errorf("Friend model requires a name")
20 }
21
22 if m.FriendsSince == nil {
23 return false, fmt.Errorf("Friend model requires a friends since date")
24 }
25
26 if m.SharesSnacks == false {
27 return false, fmt.Errorf("True friends share snacks")
28 }
29
30 return true, nil
31}
32
33func (m *FriendModel) CongratulateBirthday() {
34 fmt.Printf("\n\nGo %s! It's your birthday!\n\n", m.Name)
35}
36
37func (m *FriendModel) JSON() string {
38 buffer, _ := json.MarshalIndent(&m, "", " ")
39 return string(buffer)
40}
41
42func main() {
43 friendsSince := time.Date(1999, time.Month(6), 23, 0, 0, 0, 0, time.UTC)
44 shorty := FriendModel{
45 Name: "Shorty",
46 FriendsSince: &friendsSince,
47 SharesSnacks: true,
48 }
49 isValid, _ := shorty.IsValid()
50
51 fmt.Printf("%s is a %v friend.", shorty.Name, isValid)
52 shorty.CongratulateBirthday()
53 fmt.Println(shorty.JSON())
54}
The above struct of the type FriendModel violates the Single Responsibility Principle. Because from the point of view of the SRP, the primary job of a model is to organise data. Another job is to validate the model by a given domain logic. Therefore, more complex operations whose notion already sounds like a different job (saving the model, displaying model aspects to the user etc.) can be an excellent indicator for separating responsibilities into other concepts.
The view representations responsibilities in the above example should belong to a different concept. With Golang, you can get creative on splitting and organising your code into building blocks. Introducing two new structs into this example, for just rendering the congratulations HTML or just serialising a model into JSON, might be overkill. Gophers, that’s what the people in the Golang community like to call themselves, love simplicity. In an actual Golang project, structs might be used less often than organised packages containing only functions.
1package main
2
3import (
4 "encoding/json"
5 "fmt"
6 "time"
7)
8
9// Clean struct adhering the Single Responsibility Principle.
10// Reason: The struct acts as model and implements only data/logic specific aspects.
11// Rendering into different formats and implementing application logic (like congratulating) are extracted to other concepts
12type FriendModel struct {
13 Name string `json:"name"`
14 FriendsSince *time.Time `json:"friendsSince"`
15 SharesSnacks bool `json:"sharesSnacks"`
16}
17
18func (m *FriendModel) IsValid() (bool, error) {
19 if len(m.Name) < 1 {
20 return false, fmt.Errorf("Friend model requires a name")
21 }
22
23 if m.FriendsSince == nil {
24 return false, fmt.Errorf("Friend model requires a friends since date")
25 }
26
27 if m.SharesSnacks == false {
28 return false, fmt.Errorf("True friends share snacks")
29 }
30
31 return true, nil
32}
33
34func CongratulateBirthday(m *FriendModel) {
35 fmt.Printf("\n\nGo %s! It's your birthday!\n\n", m.Name)
36}
37
38func SerializeFriend(m *FriendModel) string {
39 buffer, _ := json.MarshalIndent(&m, "", " ")
40 return string(buffer)
41}
42
43func EvaluationFriendship(m *FriendModel) {
44 isValid, _ := m.IsValid()
45 fmt.Printf("%s is a %v friend.", m.Name, isValid)
46}
47
48func main() {
49 friendsSince := time.Date(1999, time.Month(6), 23, 0, 0, 0, 0, time.UTC)
50 shorty := FriendModel{
51 Name: "Shorty",
52 FriendsSince: &friendsSince,
53 SharesSnacks: true,
54 }
55
56 CongratulateBirthday(&shorty)
57 fmt.Println(SerializeFriend(&shorty))
58}
Single Responsibility applied to Packages
Golang, which does not classify as an object-oriented programming language, uses structs as just one tool. A struct is only sometimes the building block of choice for some Golang developers. In the Golang space, it seems that programmers try to avoid too complex object models and hierarchies. Golang structs are often used for grouping similar functionality into one service struct, parsing structured data documents like JSON, XML, and YAML or passing down reference structs, as you might already know from using the contexts.Context interface in Golang.
A well-conceptualised Golang package offers a place to live for your functional or technical building blocks. There are multiple soft conventions and community best practices about packaging structures. However, they all have in common that they try to build meaningful subunits with the best scope for Single Responsibility. Here is an approach the Gophers at SOLID Go found most helpful over the years, applied to friendship examples (which is very overkilled for such a small example project).
Single Responsibility Principle with Golang packages: Architectural diagram hierarchy: Main package, then domain packages, then technical packages.
As you might have recognised, the Single Responsibility Principle softens a bit regarding packages. Just having a package “domain” or “util” is ok because computer scientists invented the concept of packages, so packages encapsulate several building blocks simultaneously. Anyways, you want to avoid struggling over a too fine-grained folder/package structure. If you feel at some point that an additional subdivision of the “utils” folder will be helpful to you in managing the packaging complexity, do so. If the details level of your package structure overwhelms you or keeps you from maintaining your codebase properly, reduce some complexity. Whatever you do, have technical and functional boundaries in mind to create a codebase that follows the Single Responsibility Principle.
Conculsion
An essential requirement to easily apply the Single Responsibility Principle in Golang is to build up experience where a particular technical or functional responsibility ends. To clarify domain boundaries, methods like Domain Driven Design in Golang can help you create a clean software architecture. Getting the technological context boundaries straight makes it easier since, as a software developer, you already have a precise mental model of where to allocate, e.g. service logic, database logic or view logic. You can also align along well know software design patterns.