Skip to main content

SOLID principles in Go

S — Single Responsibility Principle (SRP)

A type should have one reason to change.

Go way: keep structs focused; split concerns; compose.

// Bad: mixes business logic + persistence + logging
type UserService struct {
db *sql.DB
}
func (s *UserService) Register(u User) error { /* save + log + email */ return nil }

// Good: split into collaborators; service orchestrates them.
type UserRepo interface{ Save(User) error }
type Mailer interface{ SendWelcome(User) error }
type Logger interface{ Info(msg string) }

type UserService struct {
repo UserRepo
mail Mailer
log Logger
}
func (s *UserService) Register(u User) error {
if err := s.repo.Save(u); err != nil { return err }
s.log.Info("user registered")
return s.mail.SendWelcome(u)
}

O — Open/Closed Principle (OCP)

Code is open for extension, closed for modification.

Go way: add new behavior by implementing interfaces; avoid editing core logic.

type PriceRule interface{ Apply(total float64) float64 }

type Cart struct{ rules []PriceRule }
func (c Cart) Final(total float64) float64 {
for _, r := range c.rules { total = r.Apply(total) }
return total
}

// Extend by adding new rule types—no change to Cart.
type BlackFriday struct{}
func (BlackFriday) Apply(total float64) float64 { return total * 0.7 }

L — Liskov Substitution Principle (LSP)

Subtypes must be substitutable for their base types.

Go way: if a type implements an interface, it must behave as promised (not just compile). Avoid surprising pre/post-conditions.

type Store interface {
Get(key string) ([]byte, error) // returns (nil, ErrNotFound) if missing
Put(key string, v []byte) error
}

type MemStore struct{ m map[string][]byte }
func (s MemStore) Get(k string) ([]byte, error) { /* honor the contract */ return nil, ErrNotFound }

If MemStore.Get panicked on missing keys, it would violate LSP.

I — Interface Segregation Principle (ISP)

Prefer small, focused interfaces over "fat" ones.

Go way: consumer-defined, tiny interfaces.

// Consumer needs only reading:
type Reader interface{ Read(p []byte) (int, error) }

func Load(r Reader) error { /* ... */ } // accepts many producers (os.File, bytes.Buffer, net.Conn, ...)

Avoid producer-defined mega-interfaces that force consumers to implement unused methods.

D — Dependency Inversion Principle (DIP)

High-level code should depend on abstractions, not details.

Go way: accept interfaces, return structs (or small interfaces when hiding internals).

type UserRepo interface{ GetByID(int) (User, error) }

type Service struct{ repo UserRepo } // depends on abstraction
func (s Service) Find(id int) (User, error) { return s.repo.GetByID(id) }

// Low-level depends on the same abstraction (implements it):
type SQLRepo struct{ db *sql.DB }
func (r SQLRepo) GetByID(id int) (User, error) { /* ... */ return User{}, nil }

Go idioms that reinforce SOLID

  • Composition over inheritance: Go has no classes/inheritance; use composition & embedding to assemble behavior.
  • Small, consumer interfaces: Keep them minimal (io.Reader, fmt.Stringer). Define them where used.
  • Error values over exceptions: Keep flows explicit; SRP/OCP benefit from clear control paths.
  • Package boundaries as modules: Group cohesive code; expose minimum API surface.
  • Functional options: Extend constructors without breaking callers (OCP).
type Server struct{ addr string; log Logger }
type Option func(*Server)
func WithAddr(a string) Option { return func(s *Server){ s.addr = a } }
func WithLogger(l Logger) Option { return func(s *Server){ s.log = l } }
func New(opts ...Option) *Server {
s := &Server{ addr: ":8080", log: noop{} }
for _, o := range opts { o(s) }
return s
}

Real-world example: configuration provider

Examples from config library: