ariefrahmansyah.com
Deep Dives19 min readJanuary 14, 2026
Cover image for SOLID Principles in Golang: A Practical Guide

SOLID Principles in Golang: A Practical Guide

Learn SOLID principles with practical Go code examples and diagrams. Master object-oriented design principles in Go programming.

Deep Dive Series: Go Programming Language

SOLID is an acronym for five object-oriented design principles that help developers write maintainable, scalable, and robust code. While Go doesn't have traditional classes like object-oriented languages, these principles are still highly applicable and valuable. In this guide, we'll explore each SOLID principle with practical Go examples.

What is SOLID?

SOLID stands for:

These principles have become fundamental guidelines for writing clean, maintainable code.

Single Responsibility Principle (SRP)

Definition: A type, function, or package should have only one reason to change. It should have only one job or responsibility.

SRP can apply at the package level as well as the struct/function level. For example, having a package do too many unrelated things is unidiomatic. Dave Cheney1 notes that packages like utils or common often become dumping grounds for miscellaneous functionality and thus violate SRP by having many reasons to change.

Violation Example

Here's an example that violates SRP:

package main
 
import (
    "encoding/json"
    "fmt"
    "os"
)
 
// User represents a user in the system
type User struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}
 
// UserService violates SRP - it handles multiple responsibilities
type UserService struct {
    users []User
}
 
// SaveToFile handles persistence (one responsibility)
func (us *UserService) SaveToFile(filename string) error {
    data, err := json.Marshal(us.users)
    if err != nil {
        return err
    }
    return os.WriteFile(filename, data, 0644)
}
 
// ValidateEmail handles validation (another responsibility)
func (us *UserService) ValidateEmail(email string) bool {
    // Simple email validation
    return len(email) > 0 && contains(email, "@")
}
 
// SendEmail handles notification (yet another responsibility)
func (us *UserService) SendEmail(user User, message string) error {
    fmt.Printf("Sending email to %s: %s\n", user.Email, message)
    return nil
}
 
func contains(s, substr string) bool {
    return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
        (len(s) > len(substr) && (s[:len(substr)] == substr ||
        contains(s[1:], substr))))
}
 
func main() {
    service := &UserService{
        users: []User{
            {ID: 1, Name: "John Doe", Email: "[email protected]"},
            {ID: 2, Name: "Jane Smith", Email: "[email protected]"},
        },
    }
 
    // Multiple responsibilities mixed together
    service.ValidateEmail("[email protected]")
    service.SaveToFile("users.json")
    service.SendEmail(service.users[0], "Welcome!")
}

Problem: The UserService has three different responsibilities:

  1. Data persistence (saving to file)
  2. Validation (email validation)
  3. Communication (sending emails)

Correct Implementation

Let's refactor to follow SRP:

package main
 
import (
    "encoding/json"
    "fmt"
    "os"
)
 
// User represents a user in the system
type User struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}
 
// UserRepository handles data persistence (single responsibility)
type UserRepository struct {
    filename string
}
 
func NewUserRepository(filename string) *UserRepository {
    return &UserRepository{filename: filename}
}
 
func (ur *UserRepository) Save(users []User) error {
    data, err := json.Marshal(users)
    if err != nil {
        return err
    }
    return os.WriteFile(ur.filename, data, 0644)
}
 
func (ur *UserRepository) Load() ([]User, error) {
    data, err := os.ReadFile(ur.filename)
    if err != nil {
        return nil, err
    }
 
    var users []User
    err = json.Unmarshal(data, &users)
    return users, err
}
 
// EmailValidator handles email validation (single responsibility)
type EmailValidator struct{}
 
func (ev *EmailValidator) Validate(email string) bool {
    return len(email) > 0 && contains(email, "@")
}
 
// EmailService handles email communication (single responsibility)
type EmailService struct{}
 
func (es *EmailService) Send(user User, message string) error {
    fmt.Printf("Sending email to %s: %s\n", user.Email, message)
    return nil
}
 
// UserService now orchestrates but doesn't implement details
type UserService struct {
    repo     *UserRepository
    validator *EmailValidator
    emailer  *EmailService
}
 
func NewUserService(repo *UserRepository, validator *EmailValidator, emailer *EmailService) *UserService {
    return &UserService{
        repo:      repo,
        validator: validator,
        emailer:   emailer,
    }
}
 
func (us *UserService) RegisterUser(name, email string) error {
    if !us.validator.Validate(email) {
        return fmt.Errorf("invalid email: %s", email)
    }
 
    users, _ := us.repo.Load()
    newUser := User{
        ID:    len(users) + 1,
        Name:  name,
        Email: email,
    }
 
    users = append(users, newUser)
    if err := us.repo.Save(users); err != nil {
        return err
    }
 
    return us.emailer.Send(newUser, "Welcome to our service!")
}
 
func contains(s, substr string) bool {
    return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
        (len(s) > len(substr) && (s[:len(substr)] == substr ||
        contains(s[1:], substr))))
}
 
func main() {
    repo := NewUserRepository("users.json")
    validator := &EmailValidator{}
    emailer := &EmailService{}
 
    service := NewUserService(repo, validator, emailer)
 
    err := service.RegisterUser("John Doe", "[email protected]")
    if err != nil {
        fmt.Printf("Error: %v\n", err)
    }
}

Benefits:

Before:

After:

Open/Closed Principle (OCP)

Definition: Software entities should be open for extension but closed for modification. You should be able to add new functionality without changing existing code.

Violation Example

package main
 
import "fmt"
 
// PaymentProcessor violates OCP - needs modification for each new payment type
type PaymentProcessor struct{}
 
func (pp *PaymentProcessor) ProcessPayment(amount float64, method string) error {
    switch method {
    case "credit_card":
        fmt.Printf("Processing credit card payment: $%.2f\n", amount)
        // Credit card processing logic
        return nil
    case "paypal":
        fmt.Printf("Processing PayPal payment: $%.2f\n", amount)
        // PayPal processing logic
        return nil
    case "bank_transfer":
        fmt.Printf("Processing bank transfer: $%.2f\n", amount)
        // Bank transfer logic
        return nil
    default:
        return fmt.Errorf("unsupported payment method: %s", method)
    }
}
 
func main() {
    processor := &PaymentProcessor{}
    processor.ProcessPayment(100.0, "credit_card")
    processor.ProcessPayment(200.0, "paypal")
}

Problem: To add a new payment method (e.g., cryptocurrency), you must modify the ProcessPayment method, violating OCP.

Correct Implementation

package main
 
import "fmt"
 
// PaymentMethod defines the interface for payment processing
type PaymentMethod interface {
    Process(amount float64) error
    GetName() string
}
 
// CreditCardPayment implements credit card payments
type CreditCardPayment struct{}
 
func (cc *CreditCardPayment) Process(amount float64) error {
    fmt.Printf("Processing credit card payment: $%.2f\n", amount)
    // Credit card processing logic
    return nil
}
 
func (cc *CreditCardPayment) GetName() string {
    return "credit_card"
}
 
// PayPalPayment implements PayPal payments
type PayPalPayment struct{}
 
func (pp *PayPalPayment) Process(amount float64) error {
    fmt.Printf("Processing PayPal payment: $%.2f\n", amount)
    // PayPal processing logic
    return nil
}
 
func (pp *PayPalPayment) GetName() string {
    return "paypal"
}
 
// BankTransferPayment implements bank transfer payments
type BankTransferPayment struct{}
 
func (bt *BankTransferPayment) Process(amount float64) error {
    fmt.Printf("Processing bank transfer: $%.2f\n", amount)
    // Bank transfer logic
    return nil
}
 
func (bt *BankTransferPayment) GetName() string {
    return "bank_transfer"
}
 
// CryptocurrencyPayment - NEW payment method added without modifying existing code
type CryptocurrencyPayment struct {
    WalletAddress string
}
 
func (cp *CryptocurrencyPayment) Process(amount float64) error {
    fmt.Printf("Processing cryptocurrency payment: $%.2f to %s\n", amount, cp.WalletAddress)
    // Cryptocurrency processing logic
    return nil
}
 
func (cp *CryptocurrencyPayment) GetName() string {
    return "cryptocurrency"
}
 
// PaymentProcessor is now closed for modification but open for extension
type PaymentProcessor struct{}
 
func (pp *PaymentProcessor) ProcessPayment(amount float64, method PaymentMethod) error {
    return method.Process(amount)
}
 
func main() {
    processor := &PaymentProcessor{}
 
    // Existing payment methods
    processor.ProcessPayment(100.0, &CreditCardPayment{})
    processor.ProcessPayment(200.0, &PayPalPayment{})
    processor.ProcessPayment(300.0, &BankTransferPayment{})
 
    // New payment method added without modifying PaymentProcessor
    processor.ProcessPayment(400.0, &CryptocurrencyPayment{
        WalletAddress: "0x1234567890abcdef",
    })
}

Benefits:

Before:

After:

Liskov Substitution Principle (LSP)

Definition: Objects of a supertype should be replaceable with objects of its subtypes without breaking the application. In Go, this means that types implementing an interface should be fully substitutable.

Who is Liskov? Barbara Liskov

Violation Example

package main
 
import "fmt"
 
// Bird represents a bird
type Bird interface {
    Fly() error
    Eat() error
}
 
// Sparrow implements Bird correctly
type Sparrow struct{}
 
func (s *Sparrow) Fly() error {
    fmt.Println("Sparrow is flying")
    return nil
}
 
func (s *Sparrow) Eat() error {
    fmt.Println("Sparrow is eating")
    return nil
}
 
// Penguin violates LSP - it's a bird but can't fly
type Penguin struct{}
 
func (p *Penguin) Fly() error {
    return fmt.Errorf("penguins cannot fly")
}
 
func (p *Penguin) Eat() error {
    fmt.Println("Penguin is eating")
    return nil
}
 
// MakeBirdFly violates LSP - breaks when given a Penguin
func MakeBirdFly(bird Bird) error {
    return bird.Fly() // This will fail for Penguin!
}
 
func main() {
    sparrow := &Sparrow{}
    penguin := &Penguin{}
 
    // Works fine
    MakeBirdFly(sparrow)
 
    // Breaks the contract - penguin can't fly
    if err := MakeBirdFly(penguin); err != nil {
        fmt.Printf("Error: %v\n", err)
    }
}

Problem: Penguin implements Bird but cannot fulfill the Fly() contract, breaking LSP.

Correct Implementation

package main
 
import "fmt"
 
// Animal represents a basic animal
type Animal interface {
    Eat() error
}
 
// Flyer represents animals that can fly
type Flyer interface {
    Animal
    Fly() error
}
 
// Swimmer represents animals that can swim
type Swimmer interface {
    Animal
    Swim() error
}
 
// Sparrow can fly and eat
type Sparrow struct{}
 
func (s *Sparrow) Fly() error {
    fmt.Println("Sparrow is flying")
    return nil
}
 
func (s *Sparrow) Eat() error {
    fmt.Println("Sparrow is eating")
    return nil
}
 
// Penguin can swim and eat, but not fly
type Penguin struct{}
 
func (p *Penguin) Swim() error {
    fmt.Println("Penguin is swimming")
    return nil
}
 
func (p *Penguin) Eat() error {
    fmt.Println("Penguin is eating")
    return nil
}
 
// Duck can fly, swim, and eat
type Duck struct{}
 
func (d *Duck) Fly() error {
    fmt.Println("Duck is flying")
    return nil
}
 
func (d *Duck) Swim() error {
    fmt.Println("Duck is swimming")
    return nil
}
 
func (d *Duck) Eat() error {
    fmt.Println("Duck is eating")
    return nil
}
 
// MakeAnimalFly only accepts animals that can fly
func MakeAnimalFly(flyer Flyer) error {
    return flyer.Fly()
}
 
// MakeAnimalSwim only accepts animals that can swim
func MakeAnimalSwim(swimmer Swimmer) error {
    return swimmer.Swim()
}
 
func main() {
    sparrow := &Sparrow{}
    penguin := &Penguin{}
    duck := &Duck{}
 
    // All work correctly - each type fulfills its contract
    MakeAnimalFly(sparrow)
    MakeAnimalSwim(penguin)
    MakeAnimalFly(duck)
    MakeAnimalSwim(duck)
}

Benefits:

Before:

After:

Interface Segregation Principle (ISP)

Definition: Clients should not be forced to depend on interfaces they don't use. It's better to have many specific interfaces than one general-purpose interface.

Violation Example

package main
 
import "fmt"
 
// Worker interface is too large - violates ISP
type Worker interface {
    Work() error
    Eat() error
    Sleep() error
    Code() error
    Design() error
    Manage() error
}
 
// Developer is forced to implement all methods, even unused ones
type Developer struct{}
 
func (d *Developer) Work() error {
    fmt.Println("Developer is working")
    return nil
}
 
func (d *Developer) Eat() error {
    fmt.Println("Developer is eating")
    return nil
}
 
func (d *Developer) Sleep() error {
    fmt.Println("Developer is sleeping")
    return nil
}
 
func (d *Developer) Code() error {
    fmt.Println("Developer is coding")
    return nil
}
 
// Developer doesn't design or manage, but must implement these
func (d *Developer) Design() error {
    return fmt.Errorf("developer cannot design")
}
 
func (d *Developer) Manage() error {
    return fmt.Errorf("developer cannot manage")
}
 
// Designer also forced to implement unused methods
type Designer struct{}
 
func (des *Designer) Work() error {
    fmt.Println("Designer is working")
    return nil
}
 
func (des *Designer) Eat() error {
    fmt.Println("Designer is eating")
    return nil
}
 
func (des *Designer) Sleep() error {
    fmt.Println("Designer is sleeping")
    return nil
}
 
func (des *Designer) Design() error {
    fmt.Println("Designer is designing")
    return nil
}
 
// Designer doesn't code or manage
func (des *Designer) Code() error {
    return fmt.Errorf("designer cannot code")
}
 
func (des *Designer) Manage() error {
    return fmt.Errorf("designer cannot manage")
}
 
func main() {
    dev := &Developer{}
    des := &Designer{}
 
    dev.Work()
    dev.Code()
    des.Work()
    des.Design()
}

Problem: The Worker interface is too large. Types are forced to implement methods they don't need or can't use.

Correct Implementation

package main
 
import "fmt"
 
// Basic behaviors that everyone has
type Human interface {
    Eat() error
    Sleep() error
}
 
// Workable represents entities that can work
type Workable interface {
    Work() error
}
 
// Codable represents entities that can code
type Codable interface {
    Code() error
}
 
// Designable represents entities that can design
type Designable interface {
    Design() error
}
 
// Manageable represents entities that can manage
type Manageable interface {
    Manage() error
}
 
// Developer implements only relevant interfaces
type Developer struct{}
 
func (d *Developer) Eat() error {
    fmt.Println("Developer is eating")
    return nil
}
 
func (d *Developer) Sleep() error {
    fmt.Println("Developer is sleeping")
    return nil
}
 
func (d *Developer) Work() error {
    fmt.Println("Developer is working")
    return nil
}
 
func (d *Developer) Code() error {
    fmt.Println("Developer is coding")
    return nil
}
 
// Designer implements only relevant interfaces
type Designer struct{}
 
func (des *Designer) Eat() error {
    fmt.Println("Designer is eating")
    return nil
}
 
func (des *Designer) Sleep() error {
    fmt.Println("Designer is sleeping")
    return nil
}
 
func (des *Designer) Work() error {
    fmt.Println("Designer is working")
    return nil
}
 
func (des *Designer) Design() error {
    fmt.Println("Designer is designing")
    return nil
}
 
// Manager implements management capabilities
type Manager struct{}
 
func (m *Manager) Eat() error {
    fmt.Println("Manager is eating")
    return nil
}
 
func (m *Manager) Sleep() error {
    fmt.Println("Manager is sleeping")
    return nil
}
 
func (m *Manager) Work() error {
    fmt.Println("Manager is working")
    return nil
}
 
func (m *Manager) Manage() error {
    fmt.Println("Manager is managing")
    return nil
}
 
// FullStackDeveloper can do everything
type FullStackDeveloper struct{}
 
func (fs *FullStackDeveloper) Eat() error {
    fmt.Println("FullStackDeveloper is eating")
    return nil
}
 
func (fs *FullStackDeveloper) Sleep() error {
    fmt.Println("FullStackDeveloper is sleeping")
    return nil
}
 
func (fs *FullStackDeveloper) Work() error {
    fmt.Println("FullStackDeveloper is working")
    return nil
}
 
func (fs *FullStackDeveloper) Code() error {
    fmt.Println("FullStackDeveloper is coding")
    return nil
}
 
func (fs *FullStackDeveloper) Design() error {
    fmt.Println("FullStackDeveloper is designing")
    return nil
}
 
// Functions that work with specific capabilities
func MakeCode(coder Codable) error {
    return coder.Code()
}
 
func MakeDesign(designer Designable) error {
    return designer.Design()
}
 
func MakeManage(manager Manageable) error {
    return manager.Manage()
}
 
func main() {
    dev := &Developer{}
    des := &Designer{}
    mgr := &Manager{}
    fs := &FullStackDeveloper{}
 
    // Each type only implements what it needs
    MakeCode(dev)
    MakeDesign(des)
    MakeManage(mgr)
 
    // FullStackDeveloper can do multiple things
    MakeCode(fs)
    MakeDesign(fs)
}

Benefits:

Before:

After:

Dependency Inversion Principle (DIP)

Definition: High-level modules should not depend on low-level modules. Both should depend on abstractions (interfaces). In Go, this means depending on interfaces rather than concrete types.

Violation Example

package main
 
import (
    "fmt"
    "time"
)
 
// MySQLDatabase is a low-level module
type MySQLDatabase struct{}
 
func (db *MySQLDatabase) Save(data string) error {
    fmt.Printf("Saving '%s' to MySQL database\n", data)
    // MySQL-specific implementation
    return nil
}
 
func (db *MySQLDatabase) Load(id string) (string, error) {
    fmt.Printf("Loading '%s' from MySQL database\n", id)
    // MySQL-specific implementation
    return "data from MySQL", nil
}
 
// UserService is a high-level module that directly depends on MySQLDatabase
type UserService struct {
    db *MySQLDatabase // Direct dependency on concrete type - violates DIP
}
 
func NewUserService() *UserService {
    return &UserService{
        db: &MySQLDatabase{}, // Tightly coupled to MySQL
    }
}
 
func (us *UserService) CreateUser(name string) error {
    return us.db.Save(fmt.Sprintf("user:%s", name))
}
 
func (us *UserService) GetUser(id string) (string, error) {
    return us.db.Load(id)
}
 
func main() {
    service := NewUserService()
    service.CreateUser("John Doe")
    service.GetUser("user-123")
}

Problem: UserService directly depends on MySQLDatabase. To switch to PostgreSQL or another database, you must modify UserService.

Correct Implementation

package main
 
import (
    "fmt"
    "time"
)
 
// Database defines the abstraction (interface)
type Database interface {
    Save(data string) error
    Load(id string) (string, error)
}
 
// MySQLDatabase is a low-level module implementing Database
type MySQLDatabase struct{}
 
func (db *MySQLDatabase) Save(data string) error {
    fmt.Printf("Saving '%s' to MySQL database\n", data)
    // MySQL-specific implementation
    return nil
}
 
func (db *MySQLDatabase) Load(id string) (string, error) {
    fmt.Printf("Loading '%s' from MySQL database\n", id)
    return "data from MySQL", nil
}
 
// PostgreSQLDatabase is another low-level module implementing Database
type PostgreSQLDatabase struct{}
 
func (db *PostgreSQLDatabase) Save(data string) error {
    fmt.Printf("Saving '%s' to PostgreSQL database\n", data)
    // PostgreSQL-specific implementation
    return nil
}
 
func (db *PostgreSQLDatabase) Load(id string) (string, error) {
    fmt.Printf("Loading '%s' from PostgreSQL database\n", id)
    return "data from PostgreSQL", nil
}
 
// InMemoryDatabase is another implementation (useful for testing)
type InMemoryDatabase struct {
    data map[string]string
}
 
func NewInMemoryDatabase() *InMemoryDatabase {
    return &InMemoryDatabase{
        data: make(map[string]string),
    }
}
 
func (db *InMemoryDatabase) Save(data string) error {
    fmt.Printf("Saving '%s' to in-memory database\n", data)
    db.data[data] = data
    return nil
}
 
func (db *InMemoryDatabase) Load(id string) (string, error) {
    fmt.Printf("Loading '%s' from in-memory database\n", id)
    if data, ok := db.data[id]; ok {
        return data, nil
    }
    return "", fmt.Errorf("not found: %s", id)
}
 
// UserService is a high-level module that depends on Database interface
type UserService struct {
    db Database // Depends on abstraction, not concrete type
}
 
func NewUserService(db Database) *UserService {
    return &UserService{
        db: db, // Accept any Database implementation
    }
}
 
func (us *UserService) CreateUser(name string) error {
    return us.db.Save(fmt.Sprintf("user:%s", name))
}
 
func (us *UserService) GetUser(id string) (string, error) {
    return us.db.Load(id)
}
 
func main() {
    // Can easily switch between different database implementations
    mysqlDB := &MySQLDatabase{}
    postgresDB := &PostgreSQLDatabase{}
    memoryDB := NewInMemoryDatabase()
 
    // Same service works with any database
    service1 := NewUserService(mysqlDB)
    service1.CreateUser("John Doe")
    service1.GetUser("user-123")
 
    service2 := NewUserService(postgresDB)
    service2.CreateUser("Jane Smith")
    service2.GetUser("user-456")
 
    // In-memory database is great for testing
    service3 := NewUserService(memoryDB)
    service3.CreateUser("Test User")
    service3.GetUser("user:Test User")
}

Benefits:

Before:

After:

Summary

SOLID principles provide a foundation for writing maintainable, scalable, and robust Go code:

  1. Single Responsibility Principle: Each type should have one reason to change
  2. Open/Closed Principle: Open for extension, closed for modification
  3. Liskov Substitution Principle: Subtypes must be fully substitutable
  4. Interface Segregation Principle: Prefer many specific interfaces over one general interface
  5. Dependency Inversion Principle: Depend on abstractions, not concretions

Key Takeaways

By following SOLID principles, you'll write code that is easier to understand, test, and maintain, making your Go applications more robust and adaptable to change.

References


This post is written/assisted by AI and reviewed by human. Read more about it here.

Footnotes

  1. https://dave.cheney.net/2016/08/20/solid-go-design

  2. https://refactoring.guru/design-patterns/strategy

#go#golang#solid#design-patterns#software-architecture#best-practices