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]
260 lines
9.3 KiB
Markdown
260 lines
9.3 KiB
Markdown
---
|
|
name: solid
|
|
description: Apply SOLID design principles in Go. Use during architecture decisions, design reviews, and when adding new abstractions.
|
|
---
|
|
|
|
# SOLID Principles
|
|
|
|
## Overview
|
|
|
|
SOLID helps structure software to be flexible, maintainable, and testable. These principles reduce coupling and increase cohesion.
|
|
|
|
In Go, several principles manifest differently than in classical OOP languages. Where Go idioms conflict with OOP-centric SOLID, **Go wins**. The tension is noted explicitly.
|
|
|
|
## Quick Reference
|
|
|
|
| Principle | One-Liner | Question to Ask |
|
|
|-----------|-----------|-----------------|
|
|
| **S**RP | One reason to change | "Does this type have ONE reason to change?" |
|
|
| **O**CP | Open for extension, closed for modification | "Can I extend without modifying?" |
|
|
| **L**SP | Subtypes are substitutable | "Can any implementation replace another safely?" |
|
|
| **I**SP | Small, focused interfaces | "Are clients forced to depend on unused methods?" |
|
|
| **D**IP | Depend on abstractions | "Do I accept interfaces, not concrete types?" |
|
|
|
|
## S — Single Responsibility Principle
|
|
|
|
> "A class should have one, and only one, reason to change."
|
|
|
|
In Go: a **type** or **package** should have one reason to change. A package that handles both domain logic and database queries has two reasons to change.
|
|
|
|
**Detection:**
|
|
- Can you describe the type's responsibility without using "and"?
|
|
- Would different stakeholders (product, ops, DBA) request changes to different parts?
|
|
|
|
**Go example:**
|
|
```go
|
|
// Bad: multiple responsibilities
|
|
type UserHandler struct {}
|
|
|
|
func (h *UserHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ... }
|
|
func (h *UserHandler) SaveToDatabase(u User) error { ... }
|
|
func (h *UserHandler) SendWelcomeEmail(u User) error { ... }
|
|
|
|
// Good: single responsibility each
|
|
type UserHandler struct {
|
|
store UserStore
|
|
mailer Mailer
|
|
}
|
|
|
|
type PostgresUserStore struct { db *sql.DB }
|
|
func (s *PostgresUserStore) Save(ctx context.Context, u User) error { ... }
|
|
|
|
type SMTPMailer struct { client *smtp.Client }
|
|
func (m *SMTPMailer) SendWelcome(ctx context.Context, email string) error { ... }
|
|
```
|
|
|
|
## O — Open/Closed Principle
|
|
|
|
> "Software entities should be open for extension but closed for modification."
|
|
|
|
In Go: add new behavior by implementing an interface or adding a new type, not by modifying existing code. The canonical Go pattern is a `switch` on a string type that needs a new case every time — replace it with an interface.
|
|
|
|
**Go example:**
|
|
```go
|
|
// Bad: must modify to add new payment method
|
|
func ProcessPayment(method string, amount Money) error {
|
|
switch method {
|
|
case "stripe":
|
|
return stripeCharge(amount)
|
|
case "paypal":
|
|
return paypalCharge(amount)
|
|
// Must add cases here for every new method!
|
|
}
|
|
return errors.New("unknown payment method")
|
|
}
|
|
|
|
// Good: open for extension via new types
|
|
type PaymentProcessor interface {
|
|
Charge(ctx context.Context, amount Money) error
|
|
}
|
|
|
|
type StripeProcessor struct { client *stripe.Client }
|
|
func (p *StripeProcessor) Charge(ctx context.Context, amount Money) error { ... }
|
|
|
|
type PayPalProcessor struct { client *paypal.Client }
|
|
func (p *PayPalProcessor) Charge(ctx context.Context, amount Money) error { ... }
|
|
|
|
// Add new payment method: implement PaymentProcessor. No existing code changes.
|
|
```
|
|
|
|
## L — Liskov Substitution Principle
|
|
|
|
> "Subtypes must be substitutable for their base types without altering program correctness."
|
|
|
|
In Go: **interfaces are structural**. Any type that implements the method set satisfies the interface. This means callers should be able to use any implementation interchangeably without knowing the difference.
|
|
|
|
The key question: does your implementation honor the **contract** (documented behavior, not just method signatures)?
|
|
|
|
**Go example:**
|
|
```go
|
|
// UserStore contract: Save persists a user and returns ErrDuplicate if email exists
|
|
type UserStore interface {
|
|
Save(ctx context.Context, u User) error
|
|
GetByEmail(ctx context.Context, email string) (User, error)
|
|
}
|
|
|
|
// Good: both implementations honor the contract
|
|
type PostgresUserStore struct { ... }
|
|
func (s *PostgresUserStore) Save(ctx context.Context, u User) error { ... }
|
|
|
|
type InMemoryUserStore struct { users map[string]User }
|
|
func (s *InMemoryUserStore) Save(ctx context.Context, u User) error {
|
|
if _, exists := s.users[u.Email]; exists {
|
|
return ErrDuplicate // Must return ErrDuplicate, not some other error
|
|
}
|
|
s.users[u.Email] = u
|
|
return nil
|
|
}
|
|
|
|
// Bad: violates contract — callers of UserStore cannot substitute this
|
|
type BrokenStore struct { ... }
|
|
func (s *BrokenStore) Save(ctx context.Context, u User) error {
|
|
panic("not implemented") // Violates contract
|
|
}
|
|
```
|
|
|
|
**Go tension:** Go has no inheritance, so "refused bequest" (subclass ignoring parent methods) doesn't apply. The LSP concern in Go is about interface implementations that partially satisfy the contract through panics or no-ops.
|
|
|
|
## I — Interface Segregation Principle
|
|
|
|
> "Clients should not be forced to depend on methods they do not use."
|
|
|
|
In Go: this is idiomatic. The `io.Reader`, `io.Writer`, and `io.Closer` pattern is the model — small, focused interfaces that can be composed when needed.
|
|
|
|
**Go example:**
|
|
```go
|
|
// Bad: fat interface — callers that only read must depend on write methods too
|
|
type FileStore interface {
|
|
Read(name string) ([]byte, error)
|
|
Write(name string, data []byte) error
|
|
Delete(name string) error
|
|
List(dir string) ([]string, error)
|
|
Stats(name string) (FileInfo, error)
|
|
}
|
|
|
|
// Good: segregated interfaces — callers depend only on what they need
|
|
type FileReader interface {
|
|
Read(name string) ([]byte, error)
|
|
}
|
|
|
|
type FileWriter interface {
|
|
Write(name string, data []byte) error
|
|
}
|
|
|
|
type FileDeleter interface {
|
|
Delete(name string) error
|
|
}
|
|
|
|
// Compose when needed
|
|
type FileReadWriter interface {
|
|
FileReader
|
|
FileWriter
|
|
}
|
|
|
|
// Handler that only reads: depends on FileReader only
|
|
func NewReportHandler(store FileReader) *ReportHandler { ... }
|
|
```
|
|
|
|
**Go idiom:** Define interfaces at the point of use, not at the point of implementation. The implementation package should not define the interface — the package that consumes it should.
|
|
|
|
```go
|
|
// In the consumer package (handler)
|
|
type UserStore interface {
|
|
GetByID(ctx context.Context, id UserID) (User, error)
|
|
}
|
|
|
|
// The postgres package doesn't need to know about this interface
|
|
// It just implements the method, and Go's structural typing handles the rest
|
|
```
|
|
|
|
## D — Dependency Inversion Principle
|
|
|
|
> "High-level modules should not depend on low-level modules. Both should depend on abstractions."
|
|
|
|
In Go: pass dependencies as interface parameters. Never instantiate concrete dependencies inside a function or type that contains business logic.
|
|
|
|
```go
|
|
// Bad: high-level order service depends on low-level email implementation
|
|
type OrderService struct {
|
|
emailClient *sendgrid.Client // Locked to SendGrid
|
|
db *sql.DB // Locked to PostgreSQL
|
|
}
|
|
|
|
// Good: depends on abstractions
|
|
type Mailer interface {
|
|
Send(ctx context.Context, to, subject, body string) error
|
|
}
|
|
|
|
type OrderRepository interface {
|
|
Save(ctx context.Context, o Order) error
|
|
GetByID(ctx context.Context, id OrderID) (Order, error)
|
|
}
|
|
|
|
type OrderService struct {
|
|
repo OrderRepository
|
|
mailer Mailer
|
|
}
|
|
|
|
func NewOrderService(repo OrderRepository, mailer Mailer) *OrderService {
|
|
return &OrderService{repo: repo, mailer: mailer}
|
|
}
|
|
```
|
|
|
|
**The Dependency Rule:** Source code dependencies point **inward** toward domain logic, never outward toward infrastructure.
|
|
|
|
```
|
|
HTTP handlers → Application services → Domain types
|
|
Database layer → Application services → Domain types
|
|
|
|
Domain types know nothing about HTTP, SQL, or external APIs.
|
|
```
|
|
|
|
## Applying SOLID at Architecture Level
|
|
|
|
| Principle | Package/Module Application |
|
|
|-----------|---------------------------|
|
|
| SRP | Each package has one clear purpose |
|
|
| OCP | New features = new packages/types, not edits to existing |
|
|
| LSP | All implementations of an interface are interchangeable |
|
|
| ISP | Interfaces defined at point of use, as narrow as possible |
|
|
| DIP | Domain packages import nothing from infrastructure packages |
|
|
|
|
## Go Tensions with OOP-centric SOLID
|
|
|
|
| OOP SOLID | Go reality |
|
|
|-----------|-----------|
|
|
| Inheritance hierarchies for OCP | Use interfaces + new types instead |
|
|
| Abstract base classes for LSP | No inheritance; use interface contracts |
|
|
| Explicit interface declarations | Interfaces are implicit; define where consumed |
|
|
| "Program to an interface" as ritual | Only extract interface when you have 2+ implementations or need testability |
|
|
|
|
**Don't over-abstract.** A function that takes a `*sql.DB` directly is fine if there's only one implementation and it's never tested in isolation. Extract an interface when you need it.
|
|
|
|
## Red Flags
|
|
|
|
| Flag | Likely Violation |
|
|
|------|-----------------|
|
|
| Type that "handles X and Y and Z" | SRP |
|
|
| Large `switch` on a type string | OCP |
|
|
| Implementation that panics on some methods | LSP |
|
|
| Interface with 10+ methods | ISP |
|
|
| `new(ConcreteType)` inside business logic | DIP |
|
|
| Package imports something from `infrastructure/` | DIP |
|
|
|
|
## References
|
|
|
|
- `references/solid-principles.md` — canonical SOLID reference with TypeScript examples
|
|
- `references/go-adaptation.md` — this workspace's Go-specific rewrite of each principle
|
|
- Load `clean-code` skill for naming and structure
|
|
- Load `code-review` skill for detecting violations during review
|