SOLID in Go

Updated

July 30, 2026

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:

  • Write either 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 tests

Good — 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 needed

Or inject interfaces at construction when tests require it:

func NewServer(store UserStore, mail WelcomeMailer) *Server

D — 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 port layer), not giant interfaces.go with 20 methods
  • Concrete adapters (postgres, smtp, s3) sit at the edges
  • main wires dependencies
  • Domain logic has no import of frameworks it does not need
  • Tests use fakes via interfaces, not live network by default

Anti-Patterns

  1. Interface on producer with 15 methods “for flexibility” — freezes implementations; violates I.
  2. package interfaces shared by all — creates import cycles and meaningless abstractions.
  3. Mocking everything — if there is no behavior branch, don’t introduce an interface.
  4. SRP as infinite micro-packages — 50 packages of 20 lines harm navigation; balance cohesion.
  5. Embedding *sql.DB in every service — hidden dependency; prefer explicit fields.
  6. 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

  1. Java-style interface hierarchies in Go — fighting the language.
  2. Returning interfaces from factories always — hides types; return concrete, accept interfaces.
  3. Circular imports — often a sign SRP/DIP layering is wrong.
  4. God App struct — DIP violation magnet; split constructors.
  5. Copy-paste SOLID blogs with class diagrams — rephrase in packages and interfaces.

Exercises

  1. Split the god type — Take a type with DB + HTTP + email methods; refactor into Store, Mailer, and handler with interfaces.
  2. Segregate — From a 6-method interface, derive two consumer interfaces used by different functions; update tests.
  3. Open/closed middleware — Add WithAuth and WithLogging without editing business handlers.
  4. Fake honesty — Write a fake Bucket that violates idempotent delete; show a test that would pass with a bad fake and fix the fake.
  5. Wire main — Build cmd/server/main.go that constructs Postgres + SMTP adapters and injects them into services.
  6. Package rename — Break internal/utils into two named packages; update imports until go 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-iface

Save 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-opts

Save 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/solid

Save 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 (or cmd/); packages export structs, accept interfaces.

Try next

  • Extract OrderService tests with a fake that records last Order.
  • Add a WelcomeMailer interface and call it after successful Insert without growing a god service.