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]
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 responsibilityutil,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 instorepackages; 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:
- Multiple implementations (real + test double), or
- A package boundary you want to keep clean (domain knows nothing of postgres)