SOLID series: Single Responsibility in Go (part 1)

SOLID series: Single Responsibility in Go (part 1)

SOLID series:

Part 1: Single Responsibility Principle <- You are here ~~

Part 2: Open-closed Principle

Part 3: Liskov Substitution Principle

Part 4: Interface Segregation Principle

Part 5: Dependency Inversion Principle

1. What is Single Responsibility?

The SOLID principles are a set of fundamental principles that guide software developers in writing clean, maintainable, and extensible code. One of these principles, the Single Responsibility Principle (SRP), emphasizes the importance of having a single responsibility for each module, class, or function.

The Single Responsibility Principle states that a module should have only one reason to change. In other words, it should have a single responsibility or purpose. This principle helps us create code that is easier to understand, test, and maintain. By adhering to SRP, we prevent modules from becoming too large, complex, or entangled with unrelated functionalities.

Single Responsibility Principle. The Single Responsibility Principle… | by  Vlad Ungureanu | Medium

2. Single Responsibility example

To apply the Single Responsibility Principle effectively in Go, consider the following steps:

  1. Identify Responsibilities: Start by identifying the responsibilities of a particular module or function. A responsibility can be defined as a reason for change. For example, if we have a module responsible for handling user authentication and data persistence, we can identify two distinct responsibilities: authentication and data persistence.

  2. Separate Concerns: Once the responsibilities are identified, separate them into individual modules or functions. In our example, we would split the authentication and data persistence functionalities into separate modules. This separation ensures that each module focuses on a single responsibility and does not have knowledge of unrelated concerns.

  3. Define Interfaces: By defining clear and concise interfaces, we can enforce the separation of concerns and ensure that implementations adhere to specific responsibilities

  4. Favor Composition over Inheritance: Inheritance can introduce additional responsibilities and violate the single responsibility principle. In Go, we can achieve composition by combining smaller, single-responsibility modules to create more complex behavior. This approach keeps the codebase modular and allows for more flexibility and extensibility.

  5. Testability: Adhering to the Single Responsibility Principle inherently makes your code more testable. With smaller modules that have single responsibilities, you can write focused unit tests that verify each responsibility independently.

Single Responsibility in SOLID Design Principle - GeeksforGeeks

package main

import (
    "fmt"
)

type User struct {
    ID       int
    Username string
    Email    string
}

func (u *User) Register() error {
    // Perform user registration logic
    fmt.Printf("Registering user: %s\n", u.Username)

    // Validate input, create user in the database, send confirmation email, etc.

    return nil
}

func (u *User) Authenticate() error {
    // Perform user authentication logic
    fmt.Printf("Authenticating user: %s\n", u.Username)

    // Validate credentials, check against the database, etc.

    return nil
}

func main() {
    user := &User{
        ID:       1,
        Username: "john_doe",
        Email:    "john@example.com",
    }

    err := user.Register()
    if err != nil {
        fmt.Println(err)
        return
    }

    err = user.Authenticate()
    if err != nil {
        fmt.Println(err)
        return
    }
}

In the above example, the User struct has two methods: Register() and Authenticate(). This violates the Single Responsibility Principle because the User struct has multiple responsibilities. It handles both user registration and user authentication logic, which are distinct responsibilities.

Refactored code:

package main

import (
    "fmt"
)

type User struct {
    ID       int
    Username string
    Email    string
}

type UserRegistration struct {
}

func (ur *UserRegistration) Register(user *User) error {
    // Perform user registration logic
    fmt.Printf("Registering user: %s\n", user.Username)

    // Validate input, create user in the database, send confirmation email, etc.

    return nil
}

type UserAuthenticator struct {
}

func (ua *UserAuthenticator) Authenticate(user *User) error {
    // Perform user authentication logic
    fmt.Printf("Authenticating user: %s\n", user.Username)

    // Validate credentials, check against the database, etc.

    return nil
}

func main() {
    user := &User{
        ID:       1,
        Username: "john_doe",
        Email:    "john@example.com",
    }

    ur := &UserRegistration{}
    err := ur.Register(user)
    if err != nil {
        fmt.Println(err)
        return
    }

    ua := &UserAuthenticator{}
    err = ua.Authenticate(user)
    if err != nil {
        fmt.Println(err)
        return
    }
}

In the refactored code, we have introduced two new structs: UserRegistration and UserAuthenticator. Each struct now encapsulates a single responsibility: UserRegistration handles user registration logic, and UserAuthenticator handles user authentication logic.

3. Conclusion

It's important to note that Single Responsibility pattern should be applied in conjunction with other SOLID principles and design patterns to achieve a robust and maintainable codebase. While Single Responsibility pattern promotes code clarity and separation, it's essential to strike a balance and avoid creating an excessive number of tiny components, as it can lead to code complexity and decrease overall readability.