all posts
2025-01-20 · 9 min read

SOLID in Go

In the world of programming, you can often encounter developers who use various tools and design patterns without understanding their deeper essence. This leads to incorrect use of these tools and, as a result, to the creation of code that is difficult to maintain and develop.

Understanding fundamental principles and the reasons for their emergence allows making informed decisions about program architecture. When a developer deeply understands why a certain principle is important, they can better evaluate when it should be applied and when a simpler solution would suffice. This is especially important in the context of SOLID principles, which are often misinterpreted as dogmas rather than tools.

It's important to remember that any tool is a means of solving a specific problem. Blindly following principles without understanding their purpose can lead to excessive code complexity and the emergence of abstractions that provide no real benefit. Therefore, before applying any principle or pattern, it's worth clearly understanding what problem it solves and what advantages it provides in a specific case.

SOLID is an acronym for five object-oriented design principles that help create more maintainable, flexible, and scalable code.

Let's examine each principle separately with implementation examples in Golang.

Single Responsibility Principle (S - SRP)

The Single Responsibility Principle states that each class should have only one reason to change. In the context of Go, this means that each package, structure, or function should perform only one specific task.

Let's look at an example of incorrect implementation:

go
type User struct {
    Name string
    Email string
}
 
func (u *User) SaveToDatabase() error {
    // Logic for saving user
    return nil
 
func (u *User) SendEmail() error {
    // Logic for sending email
    return nil
}

As we can see from the example, the User structure has two methods SaveToDatabase and SendEmail. However, this doesn't comply with the single responsibility principle, as in this case the structure is overloaded with methods that are not inherent to it - working with the database and sending Email letters.

An example of correct implementation would be the following code:

go
type User struct {
    Name string
    Email string
}
 
type UserRepository struct {
    // Has DB connection
}
 
func (r *UserRepository) Save(user User) error {
    // Logic for saving user
    return nil
}
 
type EmailService struct {
    // Has Email sending configuration
}
 
func (s *EmailService) SendEmail(user User) error {
    // Logic for sending email
    return nil
}

In this example, we've divided responsibilities between different structures. Now the User structure is only responsible for storing user data, UserRepository handles database operations, and EmailService is responsible for sending emails. This approach makes the code more modular, easier to test and maintain.

Each component has a clearly defined responsibility, which adheres to the SRP principle. If we need to change the database logic or email sending system, we can do so without affecting other parts of the system.

Open/Closed Principle (O - OCP)

The Open/Closed Principle (OCP) is one of the fundamental principles of object-oriented design. It states that software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification. This means:

  • You can add new functionality without changing existing code
  • Existing code should not be modified to add new capabilities
  • Functionality extension happens through adding new code, not modifying the old one

This principle helps maintain system stability during extension and minimizes the risk of new errors in already working code.

go
// Bad example
type Calculator struct{}
 
func (c *Calculator) Calculate(operationType string, a, b int) int {
    switch operationType {
    case "add":
        return a + b
    case "subtract":
        return a - b
    // To add a new operation, existing code needs to be modified
    }
    return 0
}
 
// Good example
type Operation interface {
    Execute(int, int) int
}
 
type Addition struct{}
func (a Addition) Execute(x, y int) int { return x + y }
 
type Subtraction struct{}
func (s Subtraction) Execute(x, y int) int { return x - y }
 
// Can easily add new operation without changing existing code
type Multiplication struct{}
func (m Multiplication) Execute(x, y int) int { return x * y }

In this example, we see two approaches to implementing a calculator. The first approach violates the OCP principle because adding a new operation requires modifying the existing code of the Calculate method. This can lead to errors in already working code and makes testing more difficult.

The second approach demonstrates the correct application of the OCP principle:

  • Created an Operation interface that defines a common contract for all operations
  • Each operation is implemented as a separate structure that implements this interface
  • New operations (like Multiplication) can be added without changing existing code

This approach makes the code more flexible, easier to test and extend. Each new operation can be added by simply creating a new structure that implements the Operation interface, without the need to modify existing functionality.

Liskov Substitution Principle (L - LSP)

The Liskov Substitution Principle states that objects in a program should be replaceable with instances of their subtypes without altering the correctness of the program.

In other words, if a program is designed to work with a base type, it should work just as correctly with any of its subtypes. This means that subclasses should extend, not change, the behavior of base classes.

go
// Bad example
type Bird interface {
    Fly() string
}
 
type Penguin struct{}
 
func (p Penguin) Fly() string {
    return "I can't fly!" // Violates LSP
}
 
// Good example
type FlyingBird interface {
    Fly() string
}
 
type SwimmingBird interface {
    Bird
    Swim() string
}
 
type Duck struct{}
func (d Duck) Fly() string { return "Duck is flying" }
func (d Duck) Swim() string { return "Duck is swimming" }
 
type Penguin struct{}
func (p Penguin) Swim() string { return "Penguin is swimming" }

In the example with birds, we see a violation of LSP when a penguin implements the Bird interface with the Fly() method, even though penguins don't fly. This creates unexpected behavior and violates the substitution principle.

The correct approach is to split functionality into more specific interfaces. Now FlyingBird represents birds that can fly, and SwimmingBird represents those that can swim. This allows:

  • More accurate modeling of different bird types' real behavior
  • Avoiding situations where a subtype cannot correctly implement base type behavior
  • Ensuring predictable behavior when substituting one type for another

This design adheres to LSP, as each subtype fully satisfies its interface contract without violating expected behavior.

Interface Segregation Principle (I - ISP)

The Interface Segregation Principle states that clients should not depend on methods they don't use. This principle recommends breaking down "fat" interfaces into smaller and more specific ones. Instead of creating large interfaces containing many methods, it's better to create smaller interfaces that meet specific client needs.

Main advantages of applying ISP:

  • Reducing coupling between system components
  • Improving flexibility and code reusability
  • Simplifying testing and maintenance
  • Reducing the risk of violating the Liskov Substitution Principle
go
// Bad example
type Worker interface {
    Work()
    Eat()
    Sleep()
}
 
// Good example
type Workable interface {
    Work()
}
 
type Eatable interface {
    Eat()
}
 
type Sleepable interface {
    Sleep()
}
 
type Human struct{}
func (h Human) Work()  { /* ... */ }
func (h Human) Eat()   { /* ... */ }
func (h Human) Sleep() { /* ... */ }
 
type Robot struct{}
func (r Robot) Work() { /* ... */ } // Robot only needs to work

In this example, we can see how a large Worker interface is split into smaller, more specialized interfaces. This allows different types (like Robot) to implement only the methods they actually need, without forcing them to implement unnecessary functionality.

Dependency Inversion Principle (D - DIP)

The Dependency Inversion Principle states that high-level modules should not depend on low-level modules. Both types of modules should depend on abstractions. This principle also emphasises that abstractions should not depend on implementation details - implementation details should depend on abstractions.

Main aspects of DIP:

  • High-level modules define interfaces (abstractions) that meet their needs
  • Low-level modules implement these interfaces
  • This allows easy replacement of implementations without changing high-level code

In Go context, this means:

  • Using interfaces to define contracts between components
  • Injecting dependencies through constructors or setup methods
  • Ability to easily replace implementations for testing (mock objects) or behavior changes

Violating DIP often leads to:

  • Tight coupling between components
  • Testing difficulties
  • Problems when replacing system components
go
// Incorrect example
type MySQL struct{}
func (m *MySQL) Query() string { return "MySQL query" }
 
type BusinessLogic struct {
    mysql MySQL // Hard dependency on concrete implementation
}
 
// Correct example
type Database interface {
    Query() string
}
 
type BusinessLogic struct {
    db Database // Dependency on abstraction
}
 
type MySQL struct{}
func (m *MySQL) Query() string { return "MySQL query" }
 
type PostgreSQL struct{}
func (p *PostgreSQL) Query() string { return "PostgreSQL query" }
 
// Can easily change database implementation
func NewBusinessLogic(db Database) *BusinessLogic {
    return &BusinessLogic{db: db}
}

In the correct example shown above, the correct application of the dependency inversion principle is demonstrated through:

  • Defining an abstract Database interface that describes the required functionality without binding to a specific implementation
  • Using this interface in the BusinessLogic structure instead of a concrete database type
  • Injecting dependency through the NewBusinessLogic constructor, allowing flexible database implementation changes

This approach provides several important benefits:

  • Ability to easily replace the database without changes in business logic
  • Simplified unit testing thanks to the ability to use mock objects
  • Reduced coupling between system components
  • Improved flexibility when extending functionality (can add new database implementations)

Benefits of using SOLID Principles

SOLID principles are fundamental concepts of software design that remain relevant even in the context of Go. They help create higher quality, maintainable, and scalable code through:

  • Clear separation of responsibilities between components (SRP)
  • Flexibility in extending functionality without changing existing code (OCP)
  • Safe substitution of types with their subtypes (LSP)
  • Creation of specialized interfaces instead of large monolithic ones (ISP)
  • Reduced coupling through the use of abstractions (DIP)

Although Go is not a classical object-oriented language, applying these principles helps write more elegant and professional code. It's important to understand not only the principles themselves but also the context of their application to make well-informed architectural decisions.

Applying SOLID principles in Go projects contributes to creating a codebase that is easily extensible, testable, and maintainable, which is critical for the long-term success of the project.