SOLID Principles in Go: Writing Code That Lasts

The Nightmare Codebase

Every developer knows it. The codebase where changing one line breaks three unrelated features. Where adding a feature requires modifying files across ten packages. Where testing means testing everything because nothing is isolated.
SOLID principles prevent this nightmare. They're five guidelines for writing code that's easy to understand, extend, and maintain. Go's design makes some of these principles feel natural. Others require conscious effort.

What SOLID Stands For

Go blog diagram 1

Go blog diagram 1

Single Responsibility Principle (SRP)

A type should have only one reason to change. If a struct does multiple unrelated things, changes to one affect the other.

The Problem

go
// WRONG: User struct does too many things type User struct { ID int Name string Email string } func (u *User) Save() error { // Database logic here return nil } func (u *User) SendWelcomeEmail() error { // Email logic here return nil } func (u *User) GenerateReport() string { // Reporting logic here return "" }
This User struct has three reasons to change: database changes, email changes, and report format changes.

The Solution

go
// Filename: srp_example.go package main import "fmt" // User only holds user data // Why: Single responsibility - represent a user type User struct { ID int Name string Email string } // UserRepository handles database operations // Why: Single responsibility - data persistence type UserRepository struct { // database connection would be here } func (r *UserRepository) Save(u *User) error { fmt.Printf("Saving user %s to database\n", u.Name) return nil } func (r *UserRepository) Find(id int) (*User, error) { return &User{ID: id, Name: "Found User"}, nil } // EmailService handles email operations // Why: Single responsibility - sending emails type EmailService struct { // SMTP config would be here } func (e *EmailService) SendWelcome(u *User) error { fmt.Printf("Sending welcome email to %s\n", u.Email) return nil } // ReportGenerator handles report creation // Why: Single responsibility - generating reports type ReportGenerator struct{} func (r *ReportGenerator) UserReport(u *User) string { return fmt.Sprintf("Report for %s (ID: %d)", u.Name, u.ID) } func main() { user := &User{ID: 1, Name: "Alice", Email: "alice@example.com"} repo := &UserRepository{} repo.Save(user) email := &EmailService{} email.SendWelcome(user) reports := &ReportGenerator{} fmt.Println(reports.UserReport(user)) }
Expected Output:
Saving user Alice to database Sending welcome email to alice@example.com Report for Alice (ID: 1)
Go blog diagram 2

Go blog diagram 2

Open/Closed Principle (OCP)

Types should be open for extension but closed for modification. Add new behavior without changing existing code.

The Problem

go
// WRONG: Adding new notification type requires modifying this func SendNotification(notificationType string, message string) { switch notificationType { case "email": // send email case "sms": // send sms case "push": // send push - had to modify this function! } }

The Solution

go
// Filename: ocp_example.go package main import "fmt" // Notifier interface defines behavior // Why: New notifiers extend without modifying existing code type Notifier interface { Notify(message string) error } // EmailNotifier sends emails type EmailNotifier struct { Address string } func (e *EmailNotifier) Notify(message string) error { fmt.Printf("Email to %s: %s\n", e.Address, message) return nil } // SMSNotifier sends SMS type SMSNotifier struct { Phone string } func (s *SMSNotifier) Notify(message string) error { fmt.Printf("SMS to %s: %s\n", s.Phone, message) return nil } // SlackNotifier sends Slack messages - ADDED WITHOUT MODIFYING ABOVE type SlackNotifier struct { Channel string } func (s *SlackNotifier) Notify(message string) error { fmt.Printf("Slack to #%s: %s\n", s.Channel, message) return nil } // NotificationService uses any Notifier // Why: Closed for modification, open for extension type NotificationService struct { notifiers []Notifier } func (n *NotificationService) Add(notifier Notifier) { n.notifiers = append(n.notifiers, notifier) } func (n *NotificationService) NotifyAll(message string) { for _, notifier := range n.notifiers { notifier.Notify(message) } } func main() { service := &NotificationService{} service.Add(&EmailNotifier{Address: "user@example.com"}) service.Add(&SMSNotifier{Phone: "+1234567890"}) service.Add(&SlackNotifier{Channel: "alerts"}) service.NotifyAll("System alert!") }
Expected Output:
Email to user@example.com: System alert! SMS to +1234567890: System alert! Slack to #alerts: System alert!

Liskov Substitution Principle (LSP)

If S is a subtype of T, then objects of type T may be replaced with objects of type S without breaking the program.
In Go, this means any type implementing an interface must behave as expected.

The Problem

go
// WRONG: Square breaks expectations of Rectangle behavior type Rectangle interface { SetWidth(w int) SetHeight(h int) Area() int } type Square struct { side int } func (s *Square) SetWidth(w int) { s.side = w // Also changes height - breaks expectations! } func (s *Square) SetHeight(h int) { s.side = h // Also changes width - breaks expectations! }

The Solution

go
// Filename: lsp_example.go package main import "fmt" // Shape interface defines what shapes can do // Why: Focus on common behavior, not implementation details type Shape interface { Area() float64 } // Rectangle is a shape type Rectangle struct { Width float64 Height float64 } func (r Rectangle) Area() float64 { return r.Width * r.Height } // Square is a shape (not a special rectangle) type Square struct { Side float64 } func (s Square) Area() float64 { return s.Side * s.Side } // Circle is a shape type Circle struct { Radius float64 } func (c Circle) Area() float64 { return 3.14159 * c.Radius * c.Radius } // TotalArea works with any shapes // Why: All shapes are interchangeable for Area calculation func TotalArea(shapes []Shape) float64 { var total float64 for _, shape := range shapes { total += shape.Area() } return total } func main() { shapes := []Shape{ Rectangle{Width: 10, Height: 5}, Square{Side: 4}, Circle{Radius: 3}, } fmt.Printf("Total area: %.2f\n", TotalArea(shapes)) }
Expected Output:
Total area: 94.27

Interface Segregation Principle (ISP)

Clients should not be forced to depend on interfaces they don't use. Many small interfaces are better than one large one.

The Problem

go
// WRONG: Printers shouldn't need Scan or Fax type AllInOneDevice interface { Print(doc string) error Scan(doc string) error Fax(doc string) error Copy(doc string) error } // SimplePrinter can only print but must implement all methods type SimplePrinter struct{} func (p *SimplePrinter) Print(doc string) error { return nil } func (p *SimplePrinter) Scan(doc string) error { return errors.New("not supported") } // Awkward! func (p *SimplePrinter) Fax(doc string) error { return errors.New("not supported") } // Awkward! func (p *SimplePrinter) Copy(doc string) error { return errors.New("not supported") } // Awkward!

The Solution

go
// Filename: isp_example.go package main import "fmt" // Printer interface - just printing type Printer interface { Print(doc string) error } // Scanner interface - just scanning type Scanner interface { Scan() (string, error) } // Faxer interface - just faxing type Faxer interface { Fax(doc string, number string) error } // SimplePrinter only needs Printer type SimplePrinter struct { Name string } func (p *SimplePrinter) Print(doc string) error { fmt.Printf("[%s] Printing: %s\n", p.Name, doc) return nil } // MultiFunctionPrinter implements all interfaces type MultiFunctionPrinter struct { Name string } func (m *MultiFunctionPrinter) Print(doc string) error { fmt.Printf("[%s] Printing: %s\n", m.Name, doc) return nil } func (m *MultiFunctionPrinter) Scan() (string, error) { fmt.Printf("[%s] Scanning...\n", m.Name) return "scanned document", nil } func (m *MultiFunctionPrinter) Fax(doc string, number string) error { fmt.Printf("[%s] Faxing to %s: %s\n", m.Name, number, doc) return nil } // PrintDocument only requires Printer // Why: Function uses only what it needs func PrintDocument(p Printer, doc string) { p.Print(doc) } // ScanAndPrint requires both Printer and Scanner // Why: Compose interfaces for specific needs type PrinterScanner interface { Printer Scanner } func ScanAndPrint(ps PrinterScanner) { doc, _ := ps.Scan() ps.Print(doc) } func main() { simple := &SimplePrinter{Name: "HP Basic"} multi := &MultiFunctionPrinter{Name: "Canon Pro"} // Both work for printing PrintDocument(simple, "Invoice.pdf") PrintDocument(multi, "Report.pdf") // Only multi works for scan and print ScanAndPrint(multi) }
Expected Output:
[HP Basic] Printing: Invoice.pdf [Canon Pro] Printing: Report.pdf [Canon Pro] Scanning... [Canon Pro] Printing: scanned document
Go blog diagram 3

Go blog diagram 3

Dependency Inversion Principle (DIP)

High level modules should not depend on low level modules. Both should depend on abstractions.

The Problem

go
// WRONG: OrderService directly depends on MySQLDatabase type MySQLDatabase struct{} func (db *MySQLDatabase) Save(data string) error { // MySQL specific code return nil } type OrderService struct { db *MySQLDatabase // Tight coupling! } func (o *OrderService) CreateOrder(order string) error { return o.db.Save(order) // Can't use any other database }

The Solution

go
// Filename: dip_example.go package main import "fmt" // Database is an abstraction // Why: High-level code depends on this, not concrete databases type Database interface { Save(data string) error Find(id string) (string, error) } // MySQLDatabase is a concrete implementation type MySQLDatabase struct{} func (m *MySQLDatabase) Save(data string) error { fmt.Println("MySQL: Saving", data) return nil } func (m *MySQLDatabase) Find(id string) (string, error) { return "MySQL result for " + id, nil } // PostgresDatabase is another implementation type PostgresDatabase struct{} func (p *PostgresDatabase) Save(data string) error { fmt.Println("Postgres: Saving", data) return nil } func (p *PostgresDatabase) Find(id string) (string, error) { return "Postgres result for " + id, nil } // MockDatabase for testing type MockDatabase struct { SavedData []string } func (m *MockDatabase) Save(data string) error { m.SavedData = append(m.SavedData, data) return nil } func (m *MockDatabase) Find(id string) (string, error) { return "Mock result", nil } // OrderService depends on abstraction, not concrete database type OrderService struct { db Database // Interface, not concrete type } func NewOrderService(db Database) *OrderService { return &OrderService{db: db} } func (o *OrderService) CreateOrder(orderID string, details string) error { return o.db.Save(fmt.Sprintf("Order %s: %s", orderID, details)) } func main() { // Production: use MySQL mysqlDB := &MySQLDatabase{} orderService := NewOrderService(mysqlDB) orderService.CreateOrder("001", "Widget x 5") // Development: use Postgres postgresDB := &PostgresDatabase{} devService := NewOrderService(postgresDB) devService.CreateOrder("002", "Gadget x 3") // Testing: use Mock mockDB := &MockDatabase{} testService := NewOrderService(mockDB) testService.CreateOrder("003", "Test item") fmt.Println("Mock saved:", mockDB.SavedData) }
Expected Output:
MySQL: Saving Order 001: Widget x 5 Postgres: Saving Order 002: Gadget x 3 Mock saved: [Order 003: Test item]
Go blog diagram 4

Go blog diagram 4

SOLID in Practice: Complete Example

go
// Filename: solid_complete.go package main import "fmt" // --- Interfaces (Abstractions) --- type UserRepository interface { Save(user User) error FindByID(id int) (User, error) } type Notifier interface { Notify(user User, message string) error } type Logger interface { Log(message string) } // --- Domain --- type User struct { ID int Name string Email string } // --- Implementations --- type InMemoryUserRepo struct { users map[int]User } func NewInMemoryUserRepo() *InMemoryUserRepo { return &InMemoryUserRepo{users: make(map[int]User)} } func (r *InMemoryUserRepo) Save(user User) error { r.users[user.ID] = user return nil } func (r *InMemoryUserRepo) FindByID(id int) (User, error) { return r.users[id], nil } type EmailNotifier struct{} func (e *EmailNotifier) Notify(user User, message string) error { fmt.Printf("Email to %s: %s\n", user.Email, message) return nil } type ConsoleLogger struct{} func (c *ConsoleLogger) Log(message string) { fmt.Println("[LOG]", message) } // --- Service (High-level module) --- type UserService struct { repo UserRepository notifier Notifier logger Logger } func NewUserService(repo UserRepository, notifier Notifier, logger Logger) *UserService { return &UserService{ repo: repo, notifier: notifier, logger: logger, } } func (s *UserService) RegisterUser(user User) error { s.logger.Log(fmt.Sprintf("Registering user: %s", user.Name)) if err := s.repo.Save(user); err != nil { return err } s.notifier.Notify(user, "Welcome to our platform!") s.logger.Log(fmt.Sprintf("User %s registered successfully", user.Name)) return nil } func main() { // Wire dependencies repo := NewInMemoryUserRepo() notifier := &EmailNotifier{} logger := &ConsoleLogger{} service := NewUserService(repo, notifier, logger) // Use service user := User{ID: 1, Name: "Alice", Email: "alice@example.com"} service.RegisterUser(user) }
Expected Output:
[LOG] Registering user: Alice Email to alice@example.com: Welcome to our platform! [LOG] User Alice registered successfully

SOLID Summary

PrincipleKey IdeaGo Implementation
SRPOne reason to changeSeparate structs for different responsibilities
OCPExtend without modifyingInterfaces for extensibility
LSPSubstitutable typesConsistent interface implementations
ISPSmall focused interfacesMany small interfaces, composed as needed
DIPDepend on abstractionsAccept interfaces, inject dependencies

What You Learned

You now understand that:
  • SRP keeps code focused: One type, one responsibility
  • OCP enables extension: Add features without modifying existing code
  • LSP ensures reliability: Implementations behave as expected
  • ISP avoids bloat: Small interfaces clients actually need
  • DIP enables flexibility: High level code doesn't know low level details

Your Next Steps

  • Refactor: Apply SRP to a complex struct in your codebase
  • Read Next: Learn about design patterns that build on SOLID
  • Practice: Create an interface for a dependency and write a mock implementation
SOLID principles aren't rules to follow blindly. They're guidelines that, when applied thoughtfully, lead to code that's easier to understand, test, and maintain. In Go, interfaces make most of these principles feel natural. Start applying them today.
All Blogs
Tags:golangsoliddesign-patternsclean-code