SOLID in Go
SOLID Principles in Go
Overview
SOLID comes from class-heavy OOP, but its goals—cohesion, decoupling, testability—map cleanly onto Go. You implement them with packages, small interfaces, and constructor injection, not inheritance trees.
This chapter translates each letter into idiomatic Go, with production examples, checklists, and common misapplications.
Quick Map
| Letter | OOP slogan | Go practice |
|---|---|---|
| S | Single responsibility | Small packages; one reason to change |
| O | Open/closed | Accept interfaces; add types without editing core |
| L | Liskov substitution | Behavioral contracts for interface implementers |
| I | Interface segregation | Consumer-defined, tiny interfaces |
| D | Dependency inversion | Depend on abstractions at boundaries |
S — Single Responsibility
“A package (or type) should have one reason to change.”
Go’s package system is the first SRP tool.
| Avoid | Prefer |
|---|---|
package utils |
package clock, package billhash, package useremail |
God Service struct |
Separate Billing, Notifier, Store |
// Bad: one type owns HTTP, SQL, and SMTP
type App struct {
db *sql.DB
}
func (a *App) HandleRegister(w http.ResponseWriter, r *http.Request) { /* parse */ }
func (a *App) insertUser(...) error { /* sql */ }
func (a *App) sendWelcome(...) error { /* smtp */ }// Better: each concern is a type with a narrow job
type UserStore interface {
Create(ctx context.Context, u User) (UserID, error)
}
type Mailer interface {
SendWelcome(ctx context.Context, email string) error
}
type RegisterHandler struct {
Store UserStore
Mailer Mailer
}
func (h RegisterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// parse → Store.Create → Mailer.SendWelcome
}SRP does not mean “one function per file.” It means changes to email templates should not risk breaking SQL migrations in the same type.
O — Open/Closed
“Open for extension, closed for modification.”
In Go, extension is usually a new type satisfying an interface—not subclassing.
type Shape interface {
Area() float64
}
func TotalArea(shapes []Shape) float64 {
var total float64
for _, s := range shapes {
total += s.Area()
}
return total
}
type Circle struct{ R float64 }
func (c Circle) Area() float64 { return math.Pi * c.R * c.R }
type Rectangle struct{ W, H float64 }
func (r Rectangle) Area() float64 { return r.W * r.H }
// Add Triangle later without editing TotalArea.Another pattern: middleware stacks—wrap http.Handler without editing handlers.
func WithLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
slog.Info("request", "path", r.URL.Path, "dur", time.Since(start))
})
}L — Liskov Substitution
“Implementations must honor the contract callers rely on.”
Go has no subtypes, but interface satisfaction is a promise:
If w is an io.Writer, callers expect:
Writeeither writes or returns an error- No surprise panics on empty input (unless documented)
- No “always returns nil error but drops data” behavior
// Violates expectations of io.Writer users
type BrokenWriter struct{}
func (BrokenWriter) Write(p []byte) (int, error) {
// Pretends success without writing
return len(p), nil
}Document behavioral contracts:
// Bucket stores objects.
// Delete is idempotent: deleting a missing key returns nil.
type Bucket interface {
Put(ctx context.Context, key string, val []byte) error
Delete(ctx context.Context, key string) error
}When a mock in tests behaves differently from production (e.g., never returns ErrNotFound), tests lie—LSP applied to fakes.
I — Interface Segregation
“Clients must not depend on methods they do not use.”
This is Go’s superpower: define interfaces at the consumer, keep them tiny (often 1–3 methods).
Bad — fat interface
type UserService interface {
Create(ctx context.Context, u User) error
Delete(ctx context.Context, id UserID) error
SendEmail(ctx context.Context, id UserID, body string) error
Validate(u User) error
ExportCSV(ctx context.Context) ([]byte, error)
}
func NotifyWelcome(s UserService, id UserID) error {
return s.SendEmail(ctx, id, "welcome")
}
// NotifyWelcome is forced to mock Create/Delete/Export in testsGood — small consumer interface
type WelcomeMailer interface {
SendEmail(ctx context.Context, id UserID, body string) error
}
func NotifyWelcome(m WelcomeMailer, id UserID) error {
return m.SendEmail(ctx, id, "welcome")
}Stdlib examples: io.Reader, io.Writer, fs.FS, http.Handler—tiny and ubiquitous.
Accept interfaces, return structs (common guideline):
func NewServer(store *PostgresStore) *Server // concrete for construction
// methods of Server depend on small interfaces internally if neededOr inject interfaces at construction when tests require it:
func NewServer(store UserStore, mail WelcomeMailer) *ServerD — Dependency Inversion
“High-level policy should not depend on low-level details; both depend on abstractions.”
// Bad: business logic locked to sql.DB
type OrderService struct {
DB *sql.DB
}
func (s *OrderService) Place(ctx context.Context, o Order) error {
_, err := s.DB.ExecContext(ctx, `INSERT INTO orders ...`)
return err
}// Good: policy depends on a port
type OrderRepository interface {
Insert(ctx context.Context, o Order) error
}
type OrderService struct {
Repo OrderRepository
}
func (s *OrderService) Place(ctx context.Context, o Order) error {
if err := o.Validate(); err != nil {
return err
}
return s.Repo.Insert(ctx, o)
}
// Detail implements the port
type PostgresOrders struct{ DB *sql.DB }
func (p PostgresOrders) Insert(ctx context.Context, o Order) error {
_, err := p.DB.ExecContext(ctx, `INSERT INTO orders ...`, o.ID, o.Total)
return err
}Wiring stays in main / cmd:
func main() {
db := mustOpenDB()
svc := OrderService{Repo: PostgresOrders{DB: db}}
// http handlers get svc
}Tests inject fakes:
type fakeRepo struct{ last Order }
func (f *fakeRepo) Insert(ctx context.Context, o Order) error {
f.last = o
return nil
}
func TestPlace(t *testing.T) {
f := &fakeRepo{}
svc := OrderService{Repo: f}
if err := svc.Place(context.Background(), Order{ID: "1", Total: 10}); err != nil {
t.Fatal(err)
}
if f.last.ID != "1" {
t.Fatalf("got %+v", f.last)
}
}Composition Over Inheritance
Go has no inheritance. Use embedding carefully for data reuse, not for deep type hierarchies.
type Logger struct {
*slog.Logger
}
// Prefer explicit fields and small interfaces over deep embedding graphs.Package Design Checklist (SOLID-flavored)
- Package name describes a domain concept, not
util/common - Interfaces live with consumers (or in a thin
portlayer), not giantinterfaces.gowith 20 methods - Concrete adapters (
postgres,smtp,s3) sit at the edges mainwires dependencies- Domain logic has no import of frameworks it does not need
- Tests use fakes via interfaces, not live network by default
Anti-Patterns
- Interface on producer with 15 methods “for flexibility” — freezes implementations; violates I.
package interfacesshared by all — creates import cycles and meaningless abstractions.- Mocking everything — if there is no behavior branch, don’t introduce an interface.
- SRP as infinite micro-packages — 50 packages of 20 lines harm navigation; balance cohesion.
- Embedding
*sql.DBin every service — hidden dependency; prefer explicit fields. - Panicking implementers — violates L for interfaces that expect errors.
When Not to Abstract
// YAGNI: single use, no test seam needed yet
func loadConfig(path string) (Config, error) {
b, err := os.ReadFile(path)
// ...
}Introduce an interface when you have a second implementation (or a real test need), not on day one for every function.
Production Checklist
- New packages have a clear single purpose statement (README one-liner or doc comment)
- Public APIs prefer small interfaces at boundaries
- Handlers/services don’t import driver-specific packages when avoidable
- Fakes honor real contracts (LSP for tests)
- Dependency graph is acyclic: domain ← ports → adapters
- CI tests domain with fakes; integration tests cover one real adapter
Common Pitfalls
- Java-style interface hierarchies in Go — fighting the language.
- Returning interfaces from factories always — hides types; return concrete, accept interfaces.
- Circular imports — often a sign SRP/DIP layering is wrong.
- God
Appstruct — DIP violation magnet; split constructors. - Copy-paste SOLID blogs with class diagrams — rephrase in packages and interfaces.
Exercises
- Split the god type — Take a type with DB + HTTP + email methods; refactor into
Store,Mailer, and handler with interfaces. - Segregate — From a 6-method interface, derive two consumer interfaces used by different functions; update tests.
- Open/closed middleware — Add
WithAuthandWithLoggingwithout editing business handlers. - Fake honesty — Write a fake
Bucketthat violates idempotent delete; show a test that would pass with a bad fake and fix the fake. - Wire
main— Buildcmd/server/main.gothat constructs Postgres + SMTP adapters and injects them into services. - Package rename — Break
internal/utilsinto two named packages; update imports untilgo test ./...is green.
More examples
Depend on interfaces; swap fakes
mkdir -p /tmp/go-solid-iface && cd /tmp/go-solid-iface
go mod init example.com/solid-ifaceSave as main.go:
package main
import "fmt"
type Mailer interface {
Send(to, body string) error
}
type Notifier struct {
mail Mailer
}
func (n Notifier) Welcome(to string) error {
return n.mail.Send(to, "welcome")
}
type MemMailer struct {
sent []string
}
func (m *MemMailer) Send(to, body string) error {
m.sent = append(m.sent, to+":"+body)
return nil
}
func main() {
m := &MemMailer{}
n := Notifier{mail: m}
_ = n.Welcome("a@b.co")
_ = n.Welcome("c@d.co")
fmt.Println("sent:", len(m.sent), m.sent[0])
}go run .Expected output:
sent: 2 a@b.co:welcome
Functional options for constructors
mkdir -p /tmp/go-solid-opts && cd /tmp/go-solid-opts
go mod init example.com/solid-optsSave as main.go:
package main
import "fmt"
type Server struct {
addr string
tls bool
}
type Option func(*Server)
func WithAddr(a string) Option { return func(s *Server) { s.addr = a } }
func WithTLS(on bool) Option { return func(s *Server) { s.tls = on } }
func NewServer(opts ...Option) *Server {
s := &Server{addr: ":8080"}
for _, o := range opts {
o(s)
}
return s
}
func main() {
s := NewServer(WithAddr(":8443"), WithTLS(true))
fmt.Printf("addr=%s tls=%v\n", s.addr, s.tls)
}go run .Expected output:
addr=:8443 tls=true
Runnable example
SOLID in Go is small consumer interfaces and constructor injection. This program places an order through a service that depends on a OrderRepository port; main wires an in-memory adapter (swap for Postgres without changing policy).
mkdir -p /tmp/go-solid && cd /tmp/go-solid
go mod init example.com/solidSave as main.go:
package main
import (
"context"
"fmt"
"sync"
)
type Order struct {
ID string
Total int
}
func (o Order) Validate() error {
if o.ID == "" || o.Total <= 0 {
return fmt.Errorf("invalid order")
}
return nil
}
// Port: defined by the consumer (DIP + ISP).
type OrderRepository interface {
Insert(ctx context.Context, o Order) error
}
type OrderService struct {
Repo OrderRepository
}
func (s OrderService) Place(ctx context.Context, o Order) error {
if err := o.Validate(); err != nil {
return err
}
return s.Repo.Insert(ctx, o)
}
// Adapter: in-memory store (tests / demos).
type MemoryOrders struct {
mu sync.Mutex
byID map[string]Order
}
func NewMemoryOrders() *MemoryOrders {
return &MemoryOrders{byID: make(map[string]Order)}
}
func (m *MemoryOrders) Insert(ctx context.Context, o Order) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.byID[o.ID]; ok {
return fmt.Errorf("duplicate id %s", o.ID)
}
m.byID[o.ID] = o
return nil
}
func (m *MemoryOrders) Get(id string) (Order, bool) {
m.mu.Lock()
defer m.mu.Unlock()
o, ok := m.byID[id]
return o, ok
}
func main() {
repo := NewMemoryOrders()
svc := OrderService{Repo: repo} // wire in main
ctx := context.Background()
if err := svc.Place(ctx, Order{ID: "o1", Total: 42}); err != nil {
fmt.Println("place:", err)
return
}
if err := svc.Place(ctx, Order{ID: "bad", Total: 0}); err != nil {
fmt.Println("validate rejected:", err)
}
if err := svc.Place(ctx, Order{ID: "o1", Total: 10}); err != nil {
fmt.Println("duplicate rejected:", err)
}
if o, ok := repo.Get("o1"); ok {
fmt.Printf("stored: id=%s total=%d\n", o.ID, o.Total)
}
fmt.Println("solid wiring ok")
}go run .Expected output:
validate rejected: invalid order
duplicate rejected: duplicate id o1
stored: id=o1 total=42
solid wiring ok
What to notice
- Policy (
OrderService) never imports a database driver—only the port. - The interface is one method (ISP); tests inject fakes without mocking frameworks.
- Construction stays in
main(orcmd/); packages export structs, accept interfaces.
Try next
- Extract
OrderServicetests with a fake that recordslast Order. - Add a
WelcomeMailerinterface and call it after successfulInsertwithout growing a god service.