# 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)