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:
- S - Single Responsibility Principle
- O - Open/Closed Principle
- L - Liskov Substitution Principle
- I - Interface Segregation Principle
- D - Dependency Inversion Principle
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:
- Data persistence (saving to file)
- Validation (email validation)
- 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:
- Each type has a single, well-defined responsibility
- Changes to file storage don't affect email sending
- Easier to test each component independently
- Better code organization and maintainability
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:
- New payment methods can be added without modifying existing code
- Each payment method is independently testable
- The
PaymentProcessorremains stable and doesn't need changes - Follows the strategy pattern2
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:
- Each interface represents a specific capability
- Types only implement interfaces they can fully satisfy
- No unexpected runtime errors
- Clear contracts for each behavior
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:
- Interfaces are focused and specific
- Types only implement what they need
- No empty or error-returning stub methods
- Better code organization and clarity
- Easier to understand what each type can do
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:
- High-level modules don't depend on low-level modules
- Easy to swap implementations (e.g., for testing)
- Follows dependency injection pattern
- More flexible and maintainable
- Better testability
Before:
After:
Summary
SOLID principles provide a foundation for writing maintainable, scalable, and robust Go code:
- Single Responsibility Principle: Each type should have one reason to change
- Open/Closed Principle: Open for extension, closed for modification
- Liskov Substitution Principle: Subtypes must be fully substitutable
- Interface Segregation Principle: Prefer many specific interfaces over one general interface
- Dependency Inversion Principle: Depend on abstractions, not concretions
Key Takeaways
- Go's interfaces make it easy to apply SOLID principles
- Composition over inheritance aligns well with SOLID
- Dependency injection is natural in Go
- Small, focused interfaces are idiomatic Go
- These principles work together to create robust systems
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.
