Files
skills/solid/references/go-adaptation.md
Mathias d6a71e370e
Some checks failed
release / tag (push) Has been cancelled
chore: bootstrap skills library — 19 skills + installer + CI auto-tag
Phase 1 of mathias/skills extraction (infra#62 Track D — homelab
next-step plan addendum). Imports ~/dev/.skills/ verbatim (19 skill
dirs + SKILLS_INDEX.md) and adds the installation surface:

- Taskfile.yml — install / update / list / release / check targets
- install.sh — bootstrap installer for hosts without Task. Idempotent
  symlink wirer; default checkout at ~/.local/share/skills/ on every
  host; SKILLS_REF env var pins a tag (default: main).
- .gitea/workflows/release.yml — auto-tag every push to main by
  Bump-Type footer (major/minor/patch, default patch). Skipped when
  commit contains [skip-release].
- README — usage, versioning, contribution flow, secret-hygiene rule.

Phase 1 wires Claude Code only (~/.claude/skills/<name> global +
<repo>/.claude/skills/<name> per-repo). Phase 2 adds Crush, opencode,
antigravity, and gitea-resident agents (cobalt-dingo, agentsquad)
once their skill conventions are researched.

Public repo, markdown-only — no secrets, no client names. Verified
via pre-push grep before initial push.

[skip-release]
2026-05-24 14:59:54 +02:00

9.4 KiB

SOLID Principles: Go Adaptation

This document rewrites each SOLID principle with idiomatic Go examples. Where Go idioms conflict with OOP-centric formulations, Go wins. Tension is noted.

S — Single Responsibility Principle in Go

OOP formulation: A class should have one reason to change.

Go formulation: A type or package should have one reason to change. Responsibility maps to the unit of deployment (package), not just types.

Go example

// Bad: Order type mixes domain, persistence, and notification
package order

type Order struct {
    ID       string
    Items    []Item
    Customer Customer
}

func (o *Order) Save(db *sql.DB) error { ... }       // persistence concern
func (o *Order) SendReceipt(smtp *mail.Client) error { ... } // notification concern
func (o *Order) CalculateTotal() Money { ... }       // domain concern (correct)

// Good: each type/package has one reason to change
package order

// Domain type: only reason to change = business rules change
type Order struct {
    ID       OrderID
    Items    []Item
    Customer Customer
}

func (o Order) Total() Money {
    total := Money{}
    for _, item := range o.Items {
        total = total.Add(item.Price.Multiply(item.Quantity))
    }
    return total
}

package orderstore

// Persistence: only reason to change = storage mechanism changes
type Store struct { db *sql.DB }

func (s *Store) Save(ctx context.Context, o order.Order) error { ... }

package ordernotify

// Notification: only reason to change = notification channel changes
type Notifier struct { mailer Mailer }

func (n *Notifier) SendReceipt(ctx context.Context, o order.Order) error { ... }

Package-level SRP

Package names should be nouns that describe one concept:

  • store, cache, handler, validator — single responsibility
  • util, common, misc, helpers — SRP violation waiting to happen

O — Open/Closed Principle in Go

OOP formulation: Open for extension via subclassing, closed for modification.

Go formulation: Open for extension by implementing an interface or adding new types; closed for modification of existing types. No subclassing needed.

Go example

// Bad: adding a new discount type requires modifying existing code
func ApplyDiscount(order *Order, discountType string) Money {
    switch discountType {
    case "percentage":
        return order.Total().Multiply(0.9)
    case "fixed":
        return order.Total().Subtract(Money{Amount: 10})
    // Must add case here every time
    }
    return order.Total()
}

// Good: add new discount types by implementing the interface
type Discounter interface {
    Apply(total Money) Money
}

type PercentageDiscount struct{ Percent float64 }
func (d PercentageDiscount) Apply(total Money) Money {
    return total.Multiply(1 - d.Percent/100)
}

type FixedDiscount struct{ Amount Money }
func (d FixedDiscount) Apply(total Money) Money {
    return total.Subtract(d.Amount)
}

// Adding SeniorDiscount requires zero changes to existing code
type SeniorDiscount struct{}
func (d SeniorDiscount) Apply(total Money) Money {
    return total.Multiply(0.85)
}

func ApplyDiscount(order *Order, d Discounter) Money {
    return d.Apply(order.Total())
}

Go tension

Go has no inheritance, so "closed for modification" is natural — you can't subclass a concrete type to override behavior. The extension point is always an interface. If you're adding switch cases to handle new types, that's the signal to introduce an interface.


L — Liskov Substitution Principle in Go

OOP formulation: Subtypes must be substitutable for their base types.

Go formulation: Any implementation of an interface must honor the interface's documented contract, not just its method signatures. Go's structural typing means LSP is enforced by convention and documentation, not the compiler.

Contract documentation

// UserStore: all implementations must honor this contract:
//   - Save: persists user, returns ErrDuplicateEmail if email already exists
//   - GetByEmail: returns ErrNotFound if user does not exist
//   - Both methods must be safe for concurrent use
type UserStore interface {
    Save(ctx context.Context, u User) error
    GetByEmail(ctx context.Context, email string) (User, error)
}

var (
    ErrDuplicateEmail = errors.New("duplicate email")
    ErrNotFound       = errors.New("not found")
)

// PostgresUserStore: honors contract
type PostgresUserStore struct { db *sql.DB }
func (s *PostgresUserStore) Save(ctx context.Context, u User) error {
    _, err := s.db.ExecContext(ctx, "INSERT INTO users ...", u.Email)
    if isUniqueViolation(err) {
        return ErrDuplicateEmail // Returns documented error
    }
    return err
}

// InMemoryUserStore: honors contract (for tests)
type InMemoryUserStore struct {
    mu    sync.Mutex
    users map[string]User
}
func (s *InMemoryUserStore) Save(ctx context.Context, u User) error {
    s.mu.Lock()
    defer s.mu.Unlock()
    if _, exists := s.users[u.Email]; exists {
        return ErrDuplicateEmail // Same documented error
    }
    s.users[u.Email] = u
    return nil
}

LSP violations to watch for

// Bad: violates contract — does not return ErrDuplicateEmail
type CachedUserStore struct { ... }
func (s *CachedUserStore) Save(ctx context.Context, u User) error {
    return errors.New("cache: duplicate key") // Different error type breaks callers
}

// Bad: panics on some inputs — violates contract
type LazyUserStore struct { ... }
func (s *LazyUserStore) GetByEmail(ctx context.Context, email string) (User, error) {
    panic("not implemented yet") // Violates LSP
}

I — Interface Segregation Principle in Go

OOP formulation: Clients should not depend on methods they do not use.

Go formulation: Define the narrowest interface possible at the point of use. Go's structural typing makes this natural — you don't need the implementation to declare what it implements.

io.Reader as the canonical example

// io.Reader is a single-method interface
type Reader interface {
    Read(p []byte) (n int, err error)
}

// Any type with Read() satisfies this — os.File, bytes.Buffer, net.Conn, etc.
// Callers that only need to read accept Reader, not a fat interface
func parseConfig(r io.Reader) (Config, error) { ... }

Define interfaces where you consume them

// package report — only needs to read invoices
package report

// Define the interface here, at the point of use — not in the invoice package
type InvoiceReader interface {
    GetByID(ctx context.Context, id InvoiceID) (Invoice, error)
    ListByCustomer(ctx context.Context, customerID CustomerID) ([]Invoice, error)
}

func NewReporter(invoices InvoiceReader) *Reporter { ... }

// The invoice package's PostgresStore has 10+ methods
// This interface only exposes what the reporter needs
// Adding new methods to PostgresStore never forces changes to the reporter

Composing interfaces

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

// Compose at the call site, not in the definition
type ReadWriter interface {
    Reader
    Writer
}

D — Dependency Inversion Principle in Go

OOP formulation: High-level modules should not depend on low-level modules. Both depend on abstractions.

Go formulation: Accept interfaces, not concrete types. Domain packages import nothing from infrastructure packages. Infrastructure packages import from domain packages.

The dependency direction rule

cmd/ → internal/handler → internal/service → internal/domain
internal/store (postgres) → internal/domain
internal/mailer (smtp) → internal/domain

domain imports nothing from store, mailer, handler

Constructor injection pattern

// Good: domain service accepts interfaces
package service

type UserService struct {
    store  UserStore  // interface
    mailer Mailer     // interface
    logger *slog.Logger
}

// NewUserService constructs with injected dependencies.
// All parameters are interfaces — callers provide implementations.
func NewUserService(store UserStore, mailer Mailer, logger *slog.Logger) *UserService {
    return &UserService{store: store, mailer: mailer, logger: logger}
}

// Bad: domain service imports infrastructure
package service

import "github.com/example/myapp/internal/store/postgres"

type UserService struct {
    store *postgres.Store // Locked to PostgreSQL — breaks DIP
}

Wire it up at the boundary

// cmd/server/main.go — the composition root
func main() {
    db := mustOpenDB(cfg.DatabaseURL)
    store := postgres.NewUserStore(db)
    mailer := smtp.NewMailer(cfg.SMTP)
    logger := slog.Default()
    svc := service.NewUserService(store, mailer, logger)
    handler := handler.NewUserHandler(svc)
    // ...
}

The main function (or a dependency injection framework) is the only place that names concrete implementations. All other code depends on interfaces.

When NOT to extract an interface

Don't extract an interface prematurely. These are fine as concrete dependencies:

  • *slog.Logger — no interface needed; it already accepts a Handler interface internally
  • *sql.DB — acceptable in store packages; extract an interface at the service boundary
  • Standard library types that are stable and have no test double need

Only extract an interface when you have:

  1. Multiple implementations (real + test double), or
  2. A package boundary you want to keep clean (domain knows nothing of postgres)