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

318 lines
9.4 KiB
Markdown

# 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
```go
// 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
```go
// 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
```go
// 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
```go
// 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
```go
// 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
```go
// 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
```go
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
```go
// 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
```go
// 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)