Step 09 of 24

SOLID principles

Apply single responsibility, open–closed, Liskov substitution, interface segregation and dependency inversion in Go and TypeScript.

19 min 9 sections

SOLID is five principles collected by Robert C. Martin for designing classes and modules that tolerate change. They were written with object-oriented languages in mind, but none of them needs inheritance: they're about responsibilities, contracts and the direction of dependencies, and Go's small implicit interfaces make several of them easier to follow than in Java. This lesson takes each principle in turn — what it says, the problem it prevents, and how it looks in Go and TypeScript — and ends with where SOLID is overkill.

S — Single responsibility

A module should have one, and only one, reason to change.

"Reason to change" is the key phrase, and Martin later sharpened it: a module should be responsible to one actor — one group of people who might ask for a change. Consider:

go
type Payslip struct{ /* ... */ }

func (p *Payslip) CalculatePay() Money      { /* rules owned by finance */ }
func (p *Payslip) RenderPDF() []byte        { /* layout owned by HR */ }
func (p *Payslip) Save(db *sql.DB) error    { /* schema owned by engineering */ }

Three actors share one type. When finance changes overtime rules, you edit the same file HR's layout lives in, and both changes ship in one review and one test run. Worse, if RenderPDF and CalculatePay share a helper (say, regularHours), a change for finance can silently alter the PDF.

Split along the actors:

go
func CalculatePay(ts Timesheet, rates PayRates) Money { /* finance */ }

type PayslipRenderer struct{ /* fonts, templates */ }
func (r PayslipRenderer) Render(p Payslip) ([]byte, error) { /* HR */ }

type PayslipRepository struct{ db *sql.DB }
func (r PayslipRepository) Save(ctx context.Context, p Payslip) error { /* engineering */ }

SRP doesn't mean "one function per type" or "types should be tiny". A cohesive type with ten methods that all change for the same reason is fine. The single responsibility note has more on spotting mixed actors.

O — Open–closed

Software entities should be open for extension but closed for modification.

You should be able to add new behaviour by adding new code, not by editing existing, tested code. The anti-pattern is a switch on a type that grows with every new case and is copied into several places:

go
func ShippingCost(method string, weightKg float64) (Money, error) {
	switch method {
	case "standard":
		return Money(10_000 + 2_000*weightKg), nil
	case "express":
		return Money(25_000 + 5_000*weightKg), nil
	// every new courier means editing this function — and the
	// matching switches in EstimateDays and TrackingURL
	default:
		return 0, fmt.Errorf("unknown method %q", method)
	}
}

Make the variation an interface, and each new case a new type:

go
type ShippingMethod interface {
	Cost(weightKg float64) Money
	EstimatedDays() int
}

type Standard struct{}

func (Standard) Cost(w float64) Money { return Money(10_000 + 2_000*w) }
func (Standard) EstimatedDays() int   { return 4 }

type Express struct{}

func (Express) Cost(w float64) Money { return Money(25_000 + 5_000*w) }
func (Express) EstimatedDays() int   { return 1 }

// Adding SameDay is a new type and one registry entry; no existing method changes.
var methods = map[string]ShippingMethod{
	"standard": Standard{},
	"express":  Express{},
}

Be honest about which axis will change. If new couriers arrive monthly, the interface pays for itself. If there have been two methods for five years, the switch is simpler, and simpler wins. OCP applies to the parts of a system you have evidence will vary.

L — Liskov substitution

If S is a subtype of T, then anything that works with a T must keep working when given an S.

An implementation must honour the whole contract of the interface — not just its method signatures, but its behaviour, errors and guarantees. The compiler checks signatures; only you can check the rest.

io.Writer's documentation says Write must return a non-nil error if it writes fewer bytes than given. This implementation compiles, but breaks every caller that trusts that rule:

go
type TruncatingWriter struct{ w io.Writer; limit int }

// Violates the io.Writer contract: silently drops bytes past the limit.
func (t *TruncatingWriter) Write(p []byte) (int, error) {
	if len(p) > t.limit {
		p = p[:t.limit]
	}
	return t.w.Write(p) // returns n < len(original p) with err == nil
}

io.Copy would consider the data fully written and you'd lose the tail of a file without an error anywhere. Other common violations:

  • An implementation that panics with "not supported" for some methods (a read-only repository whose Save panics).
  • A subtype that strengthens preconditions (accepts fewer inputs than the interface promises) or weakens postconditions (guarantees less in its results).
  • The classic Square extends Rectangle: setting a square's width also changes its height, so code that sets width then height gets the wrong area.

When an implementation can't honour the contract, the abstraction is wrong. Split the interface (a Reader and a ReadWriter) rather than faking support.

I — Interface segregation

Clients should not be forced to depend on methods they don't use.

Fat interfaces couple every client to every method:

ts
interface UserRepository {
  findById(id: string): Promise<User | null>
  findByEmail(email: string): Promise<User | null>
  save(user: User): Promise<void>
  delete(id: string): Promise<void>
  exportAllToCsv(): Promise<string>
  rebuildSearchIndex(): Promise<void>
}

A login handler that only needs findByEmail now depends on CSV exports, and its test fake must stub six methods. Give each client the interface it actually needs:

ts
interface UserByEmail {
  findByEmail(email: string): Promise<User | null>
}

async function login(users: UserByEmail, email: string, password: string) { /* ... */ }

Go makes this idiomatic. Interfaces are satisfied implicitly, so the consumer declares the small interface it needs, right next to the code that uses it, and any type with those methods fits — including your real repository and a three-line test fake. The standard library is the model: io.Reader and io.Writer have one method each, and are composed into io.ReadWriter only where both are needed. "The bigger the interface, the weaker the abstraction," as the Go proverb goes.

D — Dependency inversion

High-level modules should not depend on low-level modules. Both should depend on abstractions — and the abstractions belong to the high-level module.

Without inversion, business logic imports infrastructure directly:

go
package billing

import "example.com/shop/internal/postgres"

type Service struct{ db *postgres.InvoiceStore } // billing depends on Postgres

Billing now can't be compiled, tested or reasoned about without Postgres, and swapping storage means rewriting it. Invert it: billing declares what it needs, and the Postgres package implements that:

go
package billing

type InvoiceStore interface {
	Save(ctx context.Context, inv Invoice) error
	ByCustomer(ctx context.Context, customerID string) ([]Invoice, error)
}

type Service struct{ invoices InvoiceStore }

func NewService(invoices InvoiceStore) *Service {
	return &Service{invoices: invoices}
}
go
// main.go — the one place that knows about every concrete type
store := postgres.NewInvoiceStore(pool)
svc := billing.NewService(store)

The source-code dependency now points from postgres to billing, against the flow of control — that's the "inversion". Passing dependencies in through constructors is called dependency injection; in Go you rarely need a framework for it, just a main that wires things together. This principle is the foundation of the clean architecture lesson, and the dependency inversion note summarises it on one page.

Composition over inheritance

SOLID is often taught with class hierarchies, but deep inheritance is itself a coupling hazard: a subclass depends on its parent's implementation details, and a change to the base class ripples down unpredictably. Prefer composition — build behaviour by holding and delegating to small parts — which is the only option in Go, and good practice in TypeScript. Go's struct embedding is composition with automatic delegation, not inheritance: the embedded type doesn't know about the outer type, and there is no virtual dispatch back into it.

go
type Logger interface{ Info(msg string, args ...any) }

type Notifier struct {
	sender Sender // composition: holds a collaborator
	log    Logger
}

When SOLID is overkill

Every principle adds indirection, and indirection has a reading cost. A script, a prototype, or a module with one implementation and no tests needing fakes doesn't need an interface per dependency. Apply SOLID where change is likely and expensive: domain logic at the heart of the product, boundaries with external systems, and code many teams touch. Elsewhere, KISS and YAGNI from the previous lesson take priority.

Practice

This Go service violates at least three SOLID principles. Name them, then refactor so Register can be unit-tested without a database or SMTP server.

go
type UserService struct{}

func (s *UserService) Register(email, password string) error {
	db, _ := sql.Open("postgres", os.Getenv("DATABASE_URL"))
	hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	if _, err := db.Exec(`INSERT INTO users (email, password_hash) VALUES ($1, $2)`, email, hash); err != nil {
		return err
	}
	return smtp.SendMail("smtp.example.com:587", nil, "[email protected]",
		[]string{email}, []byte("Welcome!"))
}
Show solution

Violations:

  • SRP: one method owns persistence, password hashing policy and email delivery — three reasons to change.
  • DIP: high-level registration logic constructs and depends directly on concrete infrastructure (sql.Open, smtp.SendMail, an environment variable).
  • OCP: switching to a transactional email provider means editing Register.

It also ignores errors from sql.Open and bcrypt and opens a new connection pool on every call. Refactored:

go
type UserStore interface {
	Create(ctx context.Context, email string, passwordHash []byte) error
}

type Mailer interface {
	SendWelcome(ctx context.Context, email string) error
}

type UserService struct {
	users  UserStore
	mailer Mailer
	hash   func(password []byte) ([]byte, error)
}

func NewUserService(users UserStore, mailer Mailer) *UserService {
	return &UserService{
		users:  users,
		mailer: mailer,
		hash: func(p []byte) ([]byte, error) {
			return bcrypt.GenerateFromPassword(p, bcrypt.DefaultCost)
		},
	}
}

func (s *UserService) Register(ctx context.Context, email, password string) error {
	hash, err := s.hash([]byte(password))
	if err != nil {
		return fmt.Errorf("hash password: %w", err)
	}
	if err := s.users.Create(ctx, email, hash); err != nil {
		return fmt.Errorf("create user: %w", err)
	}
	if err := s.mailer.SendWelcome(ctx, email); err != nil {
		return fmt.Errorf("send welcome email: %w", err)
	}
	return nil
}

A test passes in-memory fakes for UserStore and Mailer and asserts that the user was stored and the email sent. The Postgres and SMTP implementations live in their own packages and are wired together in main. (Whether a failed welcome email should fail registration is a product decision; often you'd log it or enqueue a retry instead.)

Key takeaways

  • S: one reason to change — one actor — per module; split code that serves different groups.
  • O: add behaviour by adding code, using interfaces where you have evidence variation will continue.
  • L: implementations must honour the full behavioural contract, not just the method signatures.
  • I: keep interfaces small and client-specific; in Go, declare them where they're consumed.
  • D: business logic owns the interfaces it needs; infrastructure implements them; main wires them together.
  • Prefer composition to inheritance, and skip SOLID's indirection where change is unlikely or cheap.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.According to the 'one actor' framing of the single responsibility principle, why is a Payslip type with CalculatePay, RenderPDF and Save methods a problem?

  2. 2.A CachedStore implements a Store interface, but its Delete method does nothing and returns nil because the cache is read-only. Which principle does this break?

  3. 3.In Go, where should the small interface a billing service depends on (for example InvoiceStore) usually be declared?

  4. 4.Your codebase has had exactly two report formats for five years. A teammate wants to replace a two-case switch with an interface, a factory and a registry to satisfy open–closed. What is the best response?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.