SOLID principles guide writing maintainable, scalable code. Go’s design naturally supports these principles. For more context, see Go Installation Guide, Go Ecosystem Overview, Go Best Practices.
Single Responsibility Principle (SRP)
Each type should have one reason to change.
Good: Single Responsibility
package main
import (
"fmt"
"log"
"os"
)
// Responsible only for user data
type User struct {
ID int
Name string
Email string
}
// Responsible only for persistence
type UserRepository struct {
filename string
}
func (ur *UserRepository) Save(user User) error {
// Save user to file
data := fmt.Sprintf("%d,%s,%s\n", user.ID, user.Name, user.Email)
return os.WriteFile(ur.filename, []byte(data), 0644)
}
// Responsible only for business logic
type UserService struct {
repo *UserRepository
}
func (us *UserService) CreateUser(name, email string) error {
user := User{ID: 1, Name: name, Email: email}
return us.repo.Save(user)
}
func main() {
repo := &UserRepository{filename: "users.txt"}
service := &UserService{repo: repo}
err := service.CreateUser("Alice", "[email protected]")
if err != nil {
log.Fatal(err)
}
}
Bad: Multiple Responsibilities
// ❌ AVOID: Multiple responsibilities
package main
type User struct {
ID int
Name string
Email string
}
// Handles data, persistence, AND business logic
func (u *User) Save() error {
// Validate
if u.Name == "" {
return fmt.Errorf("name required")
}
// Persist
data := fmt.Sprintf("%d,%s,%s\n", u.ID, u.Name, u.Email)
return os.WriteFile("users.txt", []byte(data), 0644)
}
func (u *User) SendEmail() error {
// Send email
return nil
}
Open/Closed Principle (OCP)
Open for extension, closed for modification.
Good: Open/Closed
package main
import (
"fmt"
)
// Closed for modification
type PaymentProcessor interface {
Process(amount float64) error
}
// Open for extension
type CreditCardProcessor struct{}
func (cp *CreditCardProcessor) Process(amount float64) error {
fmt.Printf("Processing credit card payment: $%.2f\n", amount)
return nil
}
type PayPalProcessor struct{}
func (pp *PayPalProcessor) Process(amount float64) error {
fmt.Printf("Processing PayPal payment: $%.2f\n", amount)
return nil
}
type Order struct {
processor PaymentProcessor
amount float64
}
func (o *Order) Pay() error {
return o.processor.Process(o.amount)
}
func main() {
// Can add new payment methods without modifying Order
order := &Order{
processor: &CreditCardProcessor{},
amount: 99.99,
}
order.Pay()
}
Bad: Closed for Extension
// ❌ AVOID: Requires modification for new payment types
package main
type Order struct {
paymentType string
amount float64
}
func (o *Order) Pay() error {
switch o.paymentType {
case "credit_card":
// Process credit card
return nil
case "paypal":
// Process PayPal
return nil
// Must modify this function for new payment types!
default:
return fmt.Errorf("unknown payment type")
}
}
Liskov Substitution Principle (LSP)
Subtypes must be substitutable for their base types.
Good: Liskov Substitution
package main
import (
"fmt"
)
type Bird interface {
Move() string
}
type Sparrow struct{}
func (s *Sparrow) Move() string {
return "Flying"
}
type Penguin struct{}
func (p *Penguin) Move() string {
return "Swimming"
}
func describeBird(b Bird) {
fmt.Println(b.Move())
}
func main() {
describeBird(&Sparrow{}) // Works
describeBird(&Penguin{}) // Works - substitutable
}
Bad: Violates Liskov Substitution
// ❌ AVOID: Penguin can't fly like Bird suggests
package main
type Bird interface {
Fly() string
}
type Sparrow struct{}
func (s *Sparrow) Fly() string {
return "Flying"
}
type Penguin struct{}
func (p *Penguin) Fly() string {
return "Can't fly!" // Violates contract
}
func makeBirdFly(b Bird) {
fmt.Println(b.Fly())
}
Interface Segregation Principle (ISP)
Clients shouldn’t depend on interfaces they don’t use.
Good: Interface Segregation
package main
import (
"fmt"
)
// Segregated interfaces
type Reader interface {
Read() ([]byte, error)
}
type Writer interface {
Write([]byte) error
}
type Closer interface {
Close() error
}
type File struct {
name string
}
func (f *File) Read() ([]byte, error) {
return []byte("file content"), nil
}
func (f *File) Write(data []byte) error {
fmt.Printf("Writing %d bytes\n", len(data))
return nil
}
func (f *File) Close() error {
fmt.Println("File closed")
return nil
}
// Clients depend only on what they need
func readData(r Reader) {
data, _ := r.Read()
fmt.Println(string(data))
}
func writeData(w Writer) {
w.Write([]byte("data"))
}
func main() {
f := &File{name: "test.txt"}
readData(f)
writeData(f)
}
Bad: Fat Interface
// ❌ AVOID: Clients forced to implement unused methods
package main
type FileInterface interface {
Read() ([]byte, error)
Write([]byte) error
Close() error
Delete() error
Rename(string) error
// Many more methods...
}
// Must implement all methods even if not needed
type ReadOnlyFile struct{}
func (rf *ReadOnlyFile) Read() ([]byte, error) {
return []byte("data"), nil
}
func (rf *ReadOnlyFile) Write([]byte) error {
panic("not implemented")
}
func (rf *ReadOnlyFile) Close() error {
panic("not implemented")
}
// ... more unimplemented methods
Dependency Inversion Principle (DIP)
Depend on abstractions, not concretions.
Good: Dependency Inversion
package main
import (
"fmt"
)
// Depend on abstraction
type Logger interface {
Log(message string)
}
type ConsoleLogger struct{}
func (cl *ConsoleLogger) Log(message string) {
fmt.Println(message)
}
type FileLogger struct{}
func (fl *FileLogger) Log(message string) {
fmt.Printf("Logging to file: %s\n", message)
}
type UserService struct {
logger Logger // Depends on abstraction
}
func (us *UserService) CreateUser(name string) {
us.logger.Log(fmt.Sprintf("Creating user: %s", name))
}
func main() {
// Can inject any Logger implementation
service := &UserService{logger: &ConsoleLogger{}}
service.CreateUser("Alice")
service = &UserService{logger: &FileLogger{}}
service.CreateUser("Bob")
}
Bad: Concrete Dependencies
// ❌ AVOID: Depends on concrete type
package main
type UserService struct {
logger *ConsoleLogger // Concrete dependency
}
func (us *UserService) CreateUser(name string) {
us.logger.Log(fmt.Sprintf("Creating user: %s", name))
}
// Can't easily switch to FileLogger without modifying UserService
Applying SOLID Together
Good: SOLID Architecture
package main
import (
"fmt"
)
// SRP: Each type has single responsibility
type User struct {
ID int
Name string
}
// ISP: Segregated interfaces
type UserRepository interface {
Save(user User) error
GetByID(id int) (*User, error)
}
type UserValidator interface {
Validate(user User) error
}
// DIP: Depend on abstractions
type UserService struct {
repo UserRepository
validator UserValidator
}
func (us *UserService) CreateUser(name string) error {
user := User{ID: 1, Name: name}
if err := us.validator.Validate(user); err != nil {
return err
}
return us.repo.Save(user)
}
// OCP: Open for extension
type InMemoryRepository struct {
users map[int]*User
}
func (ir *InMemoryRepository) Save(user User) error {
ir.users[user.ID] = &user
return nil
}
func (ir *InMemoryRepository) GetByID(id int) (*User, error) {
return ir.users[id], nil
}
type SimpleValidator struct{}
func (sv *SimpleValidator) Validate(user User) error {
if user.Name == "" {
return fmt.Errorf("name required")
}
return nil
}
func main() {
repo := &InMemoryRepository{users: make(map[int]*User)}
validator := &SimpleValidator{}
service := &UserService{repo: repo, validator: validator}
service.CreateUser("Alice")
}
Best Practices
- Start Simple: Don’t over-engineer initially
- Refactor When Needed: Apply SOLID as code evolves
- Use Interfaces: Go’s implicit interfaces support SOLID
- Small Interfaces: Keep interfaces focused
- Dependency Injection: Inject dependencies
- Test Thoroughly: SOLID makes testing easier
- Document Design: Explain architectural decisions
- Review Code: Get feedback on design
Common Pitfalls
- Over-Engineering: Applying SOLID too early
- Too Many Abstractions: Complexity without benefit
- Ignoring Pragmatism: SOLID is guidance, not law
- Poor Naming: Unclear interface purposes
- Tight Coupling: Not using dependency injection
Resources
Summary
SOLID principles guide writing maintainable code. Apply Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion. Use Go’s interfaces to depend on abstractions. Start simple and refactor as needed. SOLID principles make code more testable, flexible, and maintainable.
Comments