Skip to main content

SOLID Principles in Go

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 8 min read

SOLID is a set of five design principles that make software easier to maintain, test, and extend. They were formulated for object-oriented languages but apply directly to Go — often more cleanly, because Go’s implicit interfaces and lack of inheritance eliminate many of the awkward patterns that SOLID was partly a reaction to.

This guide applies each principle to concrete Go scenarios with the rationale for why it matters, not just what the pattern looks like.

For design patterns that build on these principles see Go behavioral design patterns and Go dependency injection.

Single Responsibility Principle: One Reason to Change

A type or function should have one reason to change. When a type handles multiple concerns, a change to any one of them risks breaking the others — and the type is harder to test because you have to set up all its dependencies to test any part of it.

A common violation: a service that validates input, applies business logic, sends notifications, and writes to the database. Change the notification format and you’re editing the same file as the database schema.

The fix: separate into focused types where each has clear ownership:

// UserValidator: only concerned with "is this input valid?"
type UserValidator struct{}

func (v *UserValidator) Validate(name, email string) error {
    if strings.TrimSpace(name) == "" {
        return fmt.Errorf("name: required")
    }
    if !strings.Contains(email, "@") {
        return fmt.Errorf("email: invalid format")
    }
    return nil
}

// UserRepository: only concerned with persistence
type UserRepository interface {
    Save(ctx context.Context, u User) error
    GetByID(ctx context.Context, id string) (*User, error)
}

// Notifier: only concerned with sending messages
type Notifier interface {
    Send(ctx context.Context, email, subject, body string) error
}

// UserService: orchestrates — delegates to above
type UserService struct {
    repo      UserRepository
    notifier  Notifier
    validator *UserValidator
}

func (s *UserService) CreateUser(ctx context.Context, name, email string) (*User, error) {
    if err := s.validator.Validate(name, email); err != nil {
        return nil, err
    }
    u := &User{ID: newID(), Name: name, Email: email}
    if err := s.repo.Save(ctx, u); err != nil {
        return nil, err
    }
    s.notifier.Send(ctx, email, "Welcome", "Account created.")
    return u, nil
}

Now you can test UserValidator without a database, test UserService with mock dependencies, and change the notification template without touching UserRepository.

Open/Closed Principle: Open for Extension, Closed for Modification

Code should be open for adding new behavior without modifying existing code. The classic violation: a switch statement that you have to edit every time a new case is added.

In Go, the open/closed principle is achieved with interfaces. Define the stable abstraction; add new implementations without touching what exists:

// The abstraction is closed — don't change this interface
type Exporter interface {
    Export(data []Row) ([]byte, error)
    ContentType() string
}

// New formats are open — add without touching existing code
type CSVExporter  struct{}
type JSONExporter struct{}
type XLSXExporter struct{}

func (e *CSVExporter)  Export(rows []Row) ([]byte, error) { /* ... */ }
func (e *JSONExporter) Export(rows []Row) ([]byte, error) { /* ... */ }
func (e *XLSXExporter) Export(rows []Row) ([]byte, error) { /* ... */ }

// The handler never changes when new formats are added
func handleExport(w http.ResponseWriter, r *http.Request, exp Exporter, data []Row) {
    out, err := exp.Export(data)
    if err != nil {
        http.Error(w, "export failed", 500)
        return
    }
    w.Header().Set("Content-Type", exp.ContentType())
    w.Write(out)
}

Adding an AVRO exporter means writing one new struct — nothing else changes. Compare this to a switch format { case "csv": ... case "json": ... } block that requires modification every time.

Liskov Substitution Principle: Subtypes Must Honor the Contract

Any type that satisfies an interface must honor the semantic contract, not just the syntactic one. If your interface implies certain behavior, all implementations must deliver that behavior — not throw panics or return surprising results for cases the interface is expected to handle.

The classic bird/flying example: if you define a Flyer interface, Penguin shouldn’t implement it with return "can't fly". That breaks every function that calls Fly() expecting something to actually fly.

The Go-idiomatic fix: define interfaces that match what each type can actually do:

// ❌ Penguin can't satisfy this contract honestly
type Bird interface {
    Fly() error
}

// ✅ Separate capabilities into separate interfaces
type Walker interface { Walk() error }
type Swimmer interface { Swim() error }
type Flyer  interface { Fly() error }

type Penguin struct{}
func (p *Penguin) Walk() error  { return nil }
func (p *Penguin) Swim() error  { return nil }
// Penguin doesn't implement Flyer — honest and correct

type Sparrow struct{}
func (s *Sparrow) Walk() error  { return nil }
func (s *Sparrow) Fly() error   { return nil }

A more practical LSP violation in Go: an io.Writer implementation that writes to a buffer but silently discards data when the buffer is full, rather than blocking or returning an error. Callers assume all bytes were written when err == nil — that assumption must hold.

The rule: when you implement an interface, read its documentation. If the docs say Write must return n == len(p) on success, returning fewer bytes is an LSP violation.

Interface Segregation Principle: Keep Interfaces Small

Clients shouldn’t be forced to depend on methods they don’t use. A 15-method interface that’s passed to a function using only 2 of those methods creates unnecessary coupling — and makes testing painful because you have to stub 13 methods you don’t care about.

Go’s implicit interfaces make this easy to do right. Define the interface at the point of use with only what that function actually needs:

// ❌ Fat interface — callers are forced to depend on everything
type Storage interface {
    Get(key string) ([]byte, error)
    Set(key string, val []byte) error
    Delete(key string) error
    List(prefix string) ([]string, error)
    Flush() error
    Stats() StorageStats
    Close() error
}

// ✅ Define what each consumer actually needs
type Getter  interface { Get(key string) ([]byte, error) }
type Setter  interface { Set(key string, val []byte) error }
type Deleter interface { Delete(key string) error }

// Cache only needs to read
func cacheHandler(store Getter) http.HandlerFunc { ... }

// Cleanup job only needs to delete
func expireOldKeys(store Deleter, keys []string) error { ... }

// A concrete Redis client implements all of them — still fine to pass anywhere
type RedisClient struct { ... }
func (r *RedisClient) Get(key string) ([]byte, error) { ... }
func (r *RedisClient) Set(key string, val []byte) error { ... }
func (r *RedisClient) Delete(key string) error { ... }

The cacheHandler test mock only needs to implement Get — one method instead of seven. The real RedisClient satisfies all three interfaces without knowing they exist.

Dependency Inversion Principle: Depend on Abstractions

High-level business logic shouldn’t import low-level implementation details. The OrderService shouldn’t import postgres.DB directly — it should depend on a UserRepository interface. This is what makes business logic testable and portable.

In Go, constructor injection is the idiomatic implementation:

// High-level module — depends only on abstractions
type OrderService struct {
    orders    OrderRepository
    inventory InventoryClient
    payments  PaymentGateway
    events    EventPublisher
    log       *slog.Logger
}

// Constructor — dependencies injected at creation time
func NewOrderService(
    orders    OrderRepository,
    inventory InventoryClient,
    payments  PaymentGateway,
    events    EventPublisher,
    log       *slog.Logger,
) *OrderService {
    return &OrderService{
        orders:    orders,
        inventory: inventory,
        payments:  payments,
        events:    events,
        log:       log,
    }
}

func (s *OrderService) PlaceOrder(ctx context.Context, req PlaceOrderRequest) (*Order, error) {
    // Uses only interfaces — no postgres, no stripe, no kafka packages
    if err := s.inventory.Reserve(ctx, req.Items); err != nil {
        return nil, fmt.Errorf("reserve inventory: %w", err)
    }
    txnID, err := s.payments.Charge(ctx, req.PaymentMethod, req.Total)
    if err != nil {
        s.inventory.Release(ctx, req.Items)
        return nil, fmt.Errorf("charge payment: %w", err)
    }
    // ...
}

Production wiring happens at main() — assemble the concrete implementations and inject them into the constructors. Test wiring happens in tests — inject fakes or mocks. OrderService doesn’t know or care which it gets.

The google/wire library and uber-go/fx automate this wiring for large codebases, but the principle works with plain constructor calls for most services.

How They Work Together

Each principle addresses a different failure mode:

Principle Problem it solves
SRP “I changed X and Y broke”
OCP “Every new feature requires modifying core code”
LSP “Calling interface methods has surprising results”
ISP “My mock needs to implement 12 methods I don’t care about”
DIP “I can’t test my business logic without a real database”

In practice, applying all five consistently produces code where every function is testable in isolation, every dependency is swappable, and adding a new feature means writing new code rather than modifying existing code.

The trap is over-applying them — creating interfaces for everything, splitting every function into a separate type. Go’s pragmatic culture values readable, direct code. Apply SOLID where it pays off: around external dependencies (databases, APIs, queues), around business logic that changes frequently, and around code that needs different behaviors in test vs production.

Summary

  • SRP: separate validation, persistence, notification, and orchestration into distinct types
  • OCP: define a stable interface; add new implementations without modifying the interface or existing code
  • LSP: implement interfaces honestly — don’t panic or silently misbehave for cases the interface is expected to handle
  • ISP: define interfaces at the consumer, with only the methods that consumer needs — small interfaces are easier to mock and satisfy
  • DIP: inject dependencies via constructors; business logic depends only on interfaces, never on concrete packages

Resources

Comments

👍 Was this article helpful?