chore: bootstrap skills library — 19 skills + installer + CI auto-tag
Some checks failed
release / tag (push) Has been cancelled
Some checks failed
release / tag (push) Has been cancelled
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]
This commit is contained in:
317
solid/references/go-adaptation.md
Normal file
317
solid/references/go-adaptation.md
Normal file
@@ -0,0 +1,317 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user