Arief Rahmansyah

SOLID Principles in Golang: A Practical Guide

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.

Table of Contents

What is SOLID?

SOLID stands for:

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

graph LR
    A[SOLID Principles] --> B[Single Responsibility]
    A --> C[Open/Closed]
    A --> D[Liskov Substitution]
    A --> E[Interface Segregation]
    A --> F[Dependency Inversion]

    B --> B1[One class/type<br/>one reason to change]
    C --> C1[Open for extension<br/>Closed for modification]
    D --> D1[Subtypes must be<br/>substitutable]
    E --> E1[Many specific interfaces<br/>better than one general]
    F --> F1[Depend on abstractions<br/>not concretions]

    style A fill:#e1f5ff
    style B fill:#b6d7a8
    style C fill:#b6d7a8
    style D fill:#b6d7a8
    style E fill:#b6d7a8
    style F fill:#b6d7a8

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:

graph LR
    A[UserService<br/>Before] --> B[Persistence]
    A --> C[Validation]
    A --> D[Email]

    style A fill:#ffcccc

After:

graph LR
    A[UserService] --> B[UserRepository]
    A --> C[EmailValidator]
    A --> D[EmailService]

    B --> B1[Persistence Only]
    C --> C1[Validation Only]
    D --> D1[Email Only]

    style A fill:#ffcccc
    style B fill:#e1f5ff
    style C fill:#e1f5ff
    style D fill:#e1f5ff

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:

classDiagram
    class PaymentProcessor {
        +ProcessPayment(amount: float64, method: PaymentMethod) error
    }

After:

classDiagram
    class PaymentMethod {
        <<interface>>
        +Process(amount float64) error
        +GetName() string
    }

    class CreditCardPayment {
        +Process(amount float64) error
        +GetName() string
    }

    class PayPalPayment {
        +Process(amount float64) error
        +GetName() string
    }

    class BankTransferPayment {
        +Process(amount float64) error
        +GetName() string
    }

    class CryptocurrencyPayment {
        -WalletAddress string
        +Process(amount float64) error
        +GetName() string
    }

    class PaymentProcessor {
        +ProcessPayment(amount float64, method PaymentMethod) error
    }

    PaymentMethod <|.. CreditCardPayment : implements
    PaymentMethod <|.. PayPalPayment : implements
    PaymentMethod <|.. BankTransferPayment : implements
    PaymentMethod <|.. CryptocurrencyPayment : implements
    PaymentProcessor --> PaymentMethod : uses

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:

classDiagram
    class Bird {
        <<interface>>
        +Fly() error
        +Eat() error
    }

    class Sparrow {
        +Fly() error
        +Eat() error
    }

    class Penguin {
        +Fly() error ⚠️ Returns error
        +Eat() error
    }

    class MakeBirdFly {
        <<function>>
        +MakeBirdFly(bird Bird) error
    }

    Bird <|.. Sparrow : implements
    Bird <|.. Penguin : implements ⚠️ LSP Violation
    MakeBirdFly ..> Bird : uses

    note for Penguin "LSP Violation: Penguin cannot fly,<br />breaking the expected behavior<br />of the Bird interface"
    note for Bird "Interface assumes all birds can fly,<br />which is not true for all bird types"

After:

classDiagram
    class Animal {
        <<interface>>
        +Eat() error
    }

    class Flyer {
        <<interface>>
        +Eat() error
        +Fly() error
    }

    class Swimmer {
        <<interface>>
        +Eat() error
        +Swim() error
    }

    class Sparrow {
        +Fly() error
        +Eat() error
    }

    class Duck {
        +Fly() error
        +Swim() error
        +Eat() error
    }

    class Penguin {
        +Swim() error
        +Eat() error
    }

    class MakeAnimalFly {
        <<function>>
        +MakeAnimalFly(flyer Flyer) error
    }

    class MakeAnimalSwim {
        <<function>>
        +MakeAnimalSwim(swimmer Swimmer) error
    }

    MakeAnimalFly ..> Flyer : uses
    MakeAnimalSwim ..> Swimmer : uses

    Animal <|-- Flyer : extends
    Animal <|-- Swimmer : extends

    Flyer <|.. Sparrow : implements
    Flyer <|.. Duck : implements
    Swimmer <|.. Duck : implements

    Swimmer <|.. Penguin : implements

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:

classDiagram
    class Worker {
        <<interface>>
        +Work() error
        +Eat() error
        +Sleep() error
        +Code() error
        +Design() error
        +Manage() error
    }

    class Developer {
        +Work() error
        +Eat() error
        +Sleep() error
        +Code() error
        +Design() error ⚠️ Returns error
        +Manage() error ⚠️ Returns error
    }

    class Designer {
        +Work() error
        +Eat() error
        +Sleep() error
        +Code() error ⚠️ Returns error
        +Design() error
        +Manage() error ⚠️ Returns error
    }

    Worker <|.. Developer : implements ⚠️ ISP Violation
    Worker <|.. Designer : implements ⚠️ ISP Violation

    note for Worker "Fat Interface Problem:<br />Forces implementers to define<br />methods they don't support"
    note for Developer "Forced to implement Design() and Manage()<br />even though developers don't perform<br />these functions"
    note for Designer "Forced to implement Code() and Manage()<br />even though designers don't perform<br />these functions"

After:

classDiagram
    class Human {
        <<interface>>
        +Eat() error
        +Sleep() error
    }

    class Workable {
        <<interface>>
        +Work() error
    }

    class Codable {
        <<interface>>
        +Code() error
    }

    class Designable {
        <<interface>>
        +Design() error
    }

    class Manageable {
        <<interface>>
        +Manage() error
    }

    class Developer {
        +Eat() error
        +Sleep() error
        +Work() error
        +Code() error
    }

    class Designer {
        +Eat() error
        +Sleep() error
        +Work() error
        +Design() error
    }

    class Manager {
        +Eat() error
        +Sleep() error
        +Work() error
        +Manage() error
    }

    class FullStackDeveloper {
        +Eat() error
        +Sleep() error
        +Work() error
        +Code() error
        +Design() error
    }

    class MakeCode {
        <<function>>
        +MakeCode(coder Codable) error
    }

    class MakeDesign {
        <<function>>
        +MakeDesign(designer Designable) error
    }

    class MakeManage {
        <<function>>
        +MakeManage(manager Manageable) error
    }

    Human <|.. Developer : implements
    Human <|.. Designer : implements
    Human <|.. Manager : implements
    Human <|.. FullStackDeveloper : implements

    Workable <|.. Developer : implements
    Workable <|.. Designer : implements
    Workable <|.. Manager : implements
    Workable <|.. FullStackDeveloper : implements

    Codable <|.. Developer : implements
    Codable <|.. FullStackDeveloper : implements

    Designable <|.. Designer : implements
    Designable <|.. FullStackDeveloper : implements

    Manageable <|.. Manager : implements

    MakeCode ..> Codable : uses
    MakeDesign ..> Designable : uses
    MakeManage ..> Manageable : uses

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:

classDiagram
    class MySQLDatabase {
        +Save(data string) error
        +Load(id string) (string, error)
    }

    class UserService {
        -db *MySQLDatabase
        +CreateUser(name string) error
        +GetUser(id string) (string, error)
    }

    class NewUserService {
        <<function>>
        +NewUserService() *UserService
    }

    UserService --> MySQLDatabase : depends on<br />⚠️ DIP Violation
    NewUserService ..> UserService : creates
    NewUserService ..> MySQLDatabase : instantiates

After:

classDiagram
    class Database {
        <<interface>>
        +Save(data string) error
        +Load(id string) (string, error)
    }

    class MySQLDatabase {
        +Save(data string) error
        +Load(id string) (string, error)
    }

    class PostgreSQLDatabase {
        +Save(data string) error
        +Load(id string) (string, error)
    }

    class InMemoryDatabase {
        -data map[string]string
        +Save(data string) error
        +Load(id string) (string, error)
    }

    class UserService {
        -db Database
        +CreateUser(name string) error
        +GetUser(id string) (string, error)
    }

    class NewUserService {
        <<function>>
        +NewUserService(db Database) *UserService
    }

    class NewInMemoryDatabase {
        <<function>>
        +NewInMemoryDatabase() *InMemoryDatabase
    }

    Database <|.. MySQLDatabase : implements
    Database <|.. PostgreSQLDatabase : implements
    Database <|.. InMemoryDatabase : implements

    UserService --> Database : depends on abstraction ✓

    NewUserService ..> UserService : creates
    NewUserService ..> Database : accepts
    NewInMemoryDatabase ..> InMemoryDatabase : creates

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
graph LR
    A[SOLID Principles] --> B[Better Code Quality]
    B --> C[Maintainability]
    B --> D[Testability]
    B --> E[Flexibility]
    B --> F[Scalability]

    style A fill:#e1f5ff
    style B fill:#90ee90

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.


This post is written or assisted by AI. Read more about it here.

References

Footnotes

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

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

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