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:
- SRP: Provider[T] orchestrates config lifecycle only; file IO/env parsing/streams are delegated helpers.
- OCP: Add features via functional options (WithPersistence, WithModel, WithStreams) without modifying core.
- LSP: Any streams adapter must honor the IOStreams contract (no unexpected nils, blocking, etc.).
- ISP: IOStreams is tiny; consumers can define even smaller facades if needed.
- DIP: Provider depends on abstractions (IOStreams, modellib.Model[T]), not concrete loggers/validators.