Skip to content
Software Atlas

SOLID Principles

Five lenses for one question — why does changing this code hurt? — with worked Go and JavaScript examples, the honest trade-offs, and when to ignore them.

⏱ ~45 min read14 sections9 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceSOLID is five rules of thumb for one goal: when requirements change, you should have to touch as little code — and as few other people's code — as possible.

Picture an online shop. On day one, OrderService is 50 lines: check the order, save it, send a "thanks" email. A year later it's 900 lines. It also computes discounts, applies tax for twelve countries, talks SQL to the database and formats emails. Every change — from finance, from marketing, from the database team — lands in the same file.

Nothing in it is wrong. But every edit is scary, every test needs a database, and two people editing it in the same week get a merge conflict. The five SOLID principles are five different ways of asking "why does this hurt, and where should the code be split?"

The five letters

LetterPrincipleThe question it asks
SSingle responsibilityHow many groups of people can force this code to change? (Aim for one.)
OOpen–closedWhen this kind of change comes again, can I add code instead of editing working code?
LLiskov substitutionIf I hand the code a different implementation, does it still work — or does it break in a surprising way?
IInterface segregationDoes this code ask for only the methods it actually uses?
DDependency inversionDo my business rules depend on details like "Postgres" or "Stripe", or do the details plug in from outside?

They overlap a lot, and they are not laws. Robert C. Martin ("Uncle Bob") collected them around 2000 for object-oriented languages like Java and C++. This page translates each one into Go and JavaScript, shows the real problem it solves, and is honest about when applying it makes code worse.

Foundations§02

Primer: interfaces in Go and JavaScript

In one sentenceAn interface says what something can do, not what it is. Code that accepts an interface works with anything that can do those things.

Think of a wall socket. The socket doesn't care whether you plug in a lamp, a laptop charger or a kettle — only that the plug has the right shape. An Interface is that plug shape, written in code: a list of methods with no code inside.

In Go

Here is a Notifier interface and two types that satisfy it:

notify.go
package main

import "fmt"

// The "plug shape": anything with this method is a Notifier.
type Notifier interface {
    Notify(to, msg string) error
}

type EmailNotifier struct {
    From string
}

func (e EmailNotifier) Notify(to, msg string) error { // ← has the method, so it IS a Notifier
    fmt.Printf("email from %s to %s: %s\n", e.From, to, msg)
    return nil
}

type SMSNotifier struct{}

func (SMSNotifier) Notify(to, msg string) error {
    fmt.Printf("sms to %s: %s\n", to, msg)
    return nil
}

// confirm accepts ANY Notifier. It doesn't know or care which one.
func confirm(n Notifier, user string) error {
    return n.Notify(user, "order confirmed")
}

func main() {
    confirm(EmailNotifier{From: "shop@example.com"}, "ana@example.com")
    confirm(SMSNotifier{}, "+15550100")
}

The habit that makes SOLID possible

Look at confirm again. It receives its notifier as a parameter instead of creating one inside (new EmailNotifier(...)). That small choice is what lets you pass in SMS today, a fake notifier in a test tomorrow, and something new next year — without editing confirm.

Keep both ideas in mind: interfaces describe behavior, and code receives the things it depends on instead of building them. Every principle on this page is built from those two.

Foundations§03

Why change hurts: the OrderService story

Software is cheap to write once and expensive to change many times. Most of a codebase's cost comes after the first version ships: the hundredth tweak to the discount rules, the database migration, the new notification channel. SOLID is about making those later changes small and safe.

Here is a sketch of our 900-line OrderService — the shape, not every line:

order_service.go (sketch)
func (s *OrderService) PlaceOrder(o Order) error {
    // 1. validate the order                    (lines 1–80)
    // 2. price it: switch o.Tier { … }         (lines 81–260)   ← Marketing changes this
    // 3. tax: if o.Country == "DE" { … }       (lines 261–380)  ← Finance changes this
    // 4. save: s.db.Exec("INSERT INTO …")      (lines 381–600)  ← Platform team changes this
    // 5. email: smtp.SendMail(…)               (lines 601–820)  ← Support changes this
}

A type like this, which knows and does almost everything, is often called a god object. Two words describe what's wrong with it:

  • Coupling — how much one piece of code has to know about another. Tax code and email code sit in the same method and share variables, so they're tightly coupled: a mistake in one can break the other.
  • Cohesion — how well the things in one module belong together. Tax rules and SMTP settings have nothing in common, so this file has low cohesion.

The goal is the opposite: low coupling, high cohesion. Now try it. Each button is a real request from a different team; flip between the two designs and compare what each change costs.

Change ripple simulator
Pick one or more requirement changes (as if they all land in the same sprint). Orange = an existing file you must edit, green = a new file.
order_service.goone file, one type
validation1–80
pricing + discounts (switch)81–260
tax rules (if country == …)261–380
SQL queries (MySQL)381–600
email building + sending601–820
PlaceOrder glue code821–900
order_service_test.go · 140 tests · needs a fake DB + fake SMTP server
Cost of this sprintillustrative numbers
Existing files edited1
New files0
Lines to read before editing≈ 900
Tests to re-run140 (all)
Files edited by 2+ changes (merge-conflict risk)none
The tax rule lives on lines 261–380 of the same file. Finance's one-line change is reviewed by people who own email and SQL, and it runs all 140 tests — which need a fake database and a fake mail server just to start.

What to notice

  1. In the all-in-one design, every change edits the same file. Two changes in the same sprint means two people editing one file — merge conflicts, and reviews by people who don't own that part.
  2. Every change re-runs every test, and those tests need a fake database and a fake mail server just to start, because everything happens in one method.
  3. The SOLID design isn't free. It has ten files instead of one, and main.go (the file that connects the parts) is edited by three of the five changes. That's the honest trade: more pieces, but each change touches only the pieces it's about.

The next five sections take one letter each and show exactly how to get from the left design to the right one — and where to stop.

Foundations§04

S — Single responsibility

In one sentenceA module should answer to one group of people. If two different teams can ask you to change it, it's two modules sharing a file.

The famous short version is "a class should have only one reason to change". Most people read that as "a class should do one thing" — and then split code into dozens of tiny classes. That's not what it means. Robert Martin later rewrote it more precisely (in Clean Architecture, 2017):

A module should be responsible to one, and only one, actor.— Robert C. Martin

An actor is a person or group who asks for changes: the finance team, the marketing team, the ops team. "Reason to change" means "someone who can ask for a change".

A worked example: the monthly sales report

A shop emails a sales report every month. Three groups care about it, and each asks for different things:

  • Finance decides what counts as a sale: "don't count refunded orders".
  • Marketing decides how it looks: "add our logo, bigger numbers".
  • Ops decides how it's delivered: "stop using our own mail server, use the mail API".

Here it is written as one type. Notice the small helper countedOrders — both the revenue and the "orders shipped" banner use it:

report.go — before
type SalesReport struct {
    db       *sql.DB
    smtpAddr string
}

// Shared helper: "the orders that count". Used for revenue AND for the banner.
func countedOrders(orders []Order) []Order {
    return orders
}

func (r *SalesReport) Send(month time.Month) error {
    orders, err := r.loadOrders(month)
    if err != nil {
        return err
    }
    revenue := 0.0
    for _, o := range countedOrders(orders) { // Finance: revenue
        revenue += o.Amount
    }
    shipped := len(countedOrders(orders)) // Marketing: "N orders shipped!"
    html := fmt.Sprintf("<h1>%d orders shipped!</h1><p>Revenue: $%.2f</p>", shipped, revenue)
    msg := "Subject: Monthly sales\r\nContent-Type: text/html\r\n\r\n" + html
    return smtp.SendMail(r.smtpAddr, nil, "reports@shop.example", // Ops: delivery
        []string{"ceo@shop.example"}, []byte(msg))
}

It's short and readable. So what's the problem? Say there were 42 orders this month, 3 of them refunded. Finance asks: "don't count refunds in revenue". The developer edits the shared helper to skip refunded orders. Revenue is now right — but the banner silently drops from "42 orders shipped!" to "39 orders shipped!", even though all 42 were shipped. Marketing didn't ask for anything, and their report changed.

That's the bug SRP prevents: code that serves two actors lets one actor's change break the other's. Press the Finance button below in both views:

Who asks for changes? Apply Finance's request and see what moves.
Finance“don't count refunds”Marketing“new layout, add a logo”Ops“send via our mail API”SalesReport.Send()revenue loop“N orders shipped!” bannersmtp.SendMail(…)countedOrders() — sharedRevenue()finance.goShippedCount() + RenderHTML()layout.goMailermail.go
The emailed report
42 orders shipped!
Revenue: $1,050
42 orders this month, 3 refunded, $25 each.
One class, three groups of people. The shared helper countedOrders() feeds both the revenue and the banner — so Finance's change leaks into Marketing's report.

After: split along the actors

report.go — after
// finance.go — Finance owns this: what counts as revenue.
func Revenue(orders []Order) float64 {
    total := 0.0
    for _, o := range orders {
        if o.Refunded {
            continue // ← Finance's rule, in Finance's code only
        }
        total += o.Amount
    }
    return total
}

// layout.go — Marketing owns this: what the report shows and how.
func ShippedCount(orders []Order) int {
    return len(orders) // refunded orders were still shipped
}

func RenderHTML(revenue float64, shipped int) string {
    return fmt.Sprintf("<h1>%d orders shipped!</h1><p>Revenue: $%.2f</p>", shipped, revenue)
}

// mail.go — Ops owns this: how it's delivered. (Any type with this method will do.)
type Mailer interface {
    Send(to []string, subject, html string) error
}

// A thin coordinator. It only changes if the *steps* change.
func SendMonthlyReport(orders []Order, m Mailer, to []string) error {
    html := RenderHTML(Revenue(orders), ShippedCount(orders))
    return m.Send(to, "Monthly sales", html)
}

Yes, Revenue and ShippedCount both loop over the same orders, and that's deliberate duplication. They look alike today, but they answer to different people and will drift apart. Sharing one helper between them is exactly what caused the bug.

Three things got better. Finance's rule exists in Finance's code only, so changing it can't touch the banner. Revenue and RenderHTML are plain functions — a Unit test just calls them with a list, no database or mail server needed. And the mail delivery is behind an interface, so Ops can switch providers without touching the report code.

The opposite mistake: splitting too far

SRP does not say "one function per class" or "one method per file". This is what over-applying it looks like:

✕ Split by “does one thing”
type OrderAmountExtractor struct{}
type RefundFilter struct{}
type RevenueAccumulator struct{}
type RevenueCalculatorFactory struct{}
type CurrencyRounder struct{}
type RevenueResult struct{}
// …6 types in 6 files, all changed
// together by the same team, Finance.
✓ Split by actor
// Finance's whole rule, one function.
func Revenue(orders []Order) float64 {
    total := 0.0
    for _, o := range orders {
        if !o.Refunded {
            total += o.Amount
        }
    }
    return total
}

If the same team always changes RefundFilter and RevenueAccumulator together, they belong together. Splitting them doesn't reduce coupling; it just spreads one idea over more files, so a reader has to jump between six of them to understand one rule.

Practitioner§05

O — Open–closed

In one sentenceCode should be open for extension, closed for modification: when the same kind of change keeps arriving, you should be able to add new code instead of editing code that already works.

The phrase comes from Bertrand Meyer (1988). "Closed" doesn't mean you may never edit the file again — you fix bugs and refactor as usual. It means: for the kind of change you keep getting, the existing, tested code doesn't need to be opened up.

The problem: a switch that keeps growing

Marketing launches a new discount every month. Here's the pricing function after three months:

price.go — before
func Price(o Order) float64 {
    price := o.Subtotal
    switch o.Tier {
    case "gold":
        price *= 0.90 // 10% off
    case "silver":
        price *= 0.95
    }
    if o.Coupon == "WELCOME10" {
        price -= 10
    }
    // Black Friday next week? → edit this function again.
    return price
}

Every new discount means editing Price, re-testing every existing discount, and hoping you didn't break one. If several functions have the same switch o.Tier, you must find and edit all of them.

The fix: make each rule a value you can add

Give every rule the same shape — an interface with one method — and let the pricer run a list of them:

price.go — after
// Every discount has the same shape.
type Rule interface {
    Apply(o Order, price float64) float64
}

type TierDiscount struct {
    Tier string
    Pct  float64
}

func (t TierDiscount) Apply(o Order, price float64) float64 {
    if o.Tier == t.Tier {
        return price * (1 - t.Pct)
    }
    return price
}

type Coupon struct {
    Code   string
    Amount float64
}

func (c Coupon) Apply(o Order, price float64) float64 {
    if o.Coupon == c.Code {
        return price - c.Amount
    }
    return price
}

// The pricer never changes when a new rule arrives.
type Pricer struct {
    Rules []Rule
}

func (p Pricer) Price(o Order) float64 {
    price := o.Subtotal
    for _, r := range p.Rules { // ← works with rules that don't exist yet
        price = r.Apply(o, price)
    }
    return price
}

Adding Black Friday is now a new type in a new file:

pricing/black_friday.go — new file
type BlackFriday struct{}

func (BlackFriday) Apply(o Order, price float64) float64 {
    return price * 0.70 // 30% off everything
}

Pricer.Price and the other rules weren't touched, so their tests don't need to change. The only edit to existing code is one line where the pricer is built (usually in main):

pricer := Pricer{Rules: []Rule{
    TierDiscount{Tier: "gold", Pct: 0.10},
    Coupon{Code: "WELCOME10", Amount: 10},
    BlackFriday{}, // ← the one added line
}}

That's what "closed for modification" means in practice: not zero edits, but no edits to the logic that already works. Try it:

Pricing rules: extend by adding
An order with a $100 subtotal from a gold member using coupon WELCOME10. The Pricer just runs each rule in order.
Pricer.Rules (in order)price.go never changes
Gold member: −10%pricing/tier.go
Coupon WELCOME10: −$10pricing/coupon.go
Pricer.Price(order)
subtotal $100.00
→ Gold member: $90.00
→ Coupon WELCOME10: $80.00
total $80.00
Try ↑ to move the coupon above the gold discount, untick a rule, or add the Black Friday rule.

Worked numbers: order is part of the design

A $100 order from a gold member with coupon WELCOME10:

  • Discount first: $100 × 0.9 = $90, then − $10 = $80.
  • Coupon first: $100 − $10 = $90, then × 0.9 = $81.

Same rules, different totals. OCP makes adding rules cheap, but rules still interact, and somebody has to decide their order (and write a test for it). Don't let a neat plugin design hide that decision.

The honest part: don't build extension points in advance

OCP only pays off along an axis of change you have actually seen. Discounts changed three times in three months — a rule list is worth it. Tax rules changed once in two years? A plain switch is easier to read, and you can refactor it the day it starts changing often.

Adding interfaces, plugin hooks and config options "in case we need them" is a well-known Code smell called speculative generality. You pay for it every time someone reads the code, and you usually guess the wrong axis anyway.

Practitioner§06

L — Liskov substitution

In one sentenceIf code works with a type, it must keep working with any subtype you hand it — without knowing which one it got.

Named after Barbara Liskov, who stated the idea in a 1987 keynote. In plain words: a subtype is a promise that "you can use me wherever you used the original". In JS a subtype is usually a subclass (class Square extends Rectangle) or any object with the same methods. In Go it's any type that satisfies an interface.

The catch: having the right methods isn't enough. The compiler checks names and types. It can't check behavior — and behavior is where substitutes break.

The classic: is a square a rectangle?

In geometry, yes. In code, watch what happens:

shapes.js
class Rectangle {
  constructor(w, h) { this.w = w; this.h = h; }
  setWidth(w) { this.w = w; }
  setHeight(h) { this.h = h; }
  area() { return this.w * this.h; }
}

class Square extends Rectangle {
  constructor(side) { super(side, side); }
  setWidth(w) { this.w = w; this.h = w; }   // keep it square…
  setHeight(h) { this.w = h; this.h = h; }  // …by changing BOTH sides
}

// Client code, written and tested against Rectangle:
function stretch(r) {
  r.setWidth(5);
  r.setHeight(4);
  console.assert(r.area() === 20, `expected 20, got ${r.area()}`);
}

stretch(new Rectangle(1, 1)); // fine
stretch(new Square(1));       // Assertion failed: expected 20, got 16

stretch did nothing wrong. It relied on something every Rectangle promised: setting the height doesn't change the width. Square broke that promise, so it isn't a safe substitute — even though it has every method and nothing crashed.

Contracts: the promises behind the methods

Every method has a behavioral contract, written down or not. It has three parts:

PartMeaningRectangle example
PreconditionWhat the caller must make true before calling"width is a positive number"
PostconditionWhat the method promises afterwards"width is now w, height is unchanged"
InvariantWhat's always true"area = width × height"

LSP in one rule: a subtype may ask for less and promise more — never ask for more or promise less.

  • Asking for more (a stronger precondition): a FixedRect whose setWidth throws "not supported". Callers who passed any positive number now crash.
  • Promising less (a weaker postcondition): Square.setWidth changes the height too.
  • Doing more is fine: a LoggedRect that records every call and then behaves exactly like a Rectangle.

LSP in Go: the io.Reader contract

Go has no subclasses, so LSP shows up as "does this type really keep the interface's promises?" The best example is io.Reader, implemented by files, network connections, strings and HTTP bodies:

type Reader interface {
    Read(p []byte) (n int, err error)
}

The signature says almost nothing. The contract lives in the documentation, and it's subtle:

  1. Read fills up to len(p) bytes and returns how many in n, with 0 ≤ n ≤ len(p). Fewer than len(p) is fine — a short read.
  2. At the end of the data it returns the special error value io.EOF ("end of file") — a Sentinel error that callers compare against.
  3. It may return data and io.EOF in the same call — so callers must use the n bytes before checking err.
  4. Implementations are discouraged from returning 0, nil (no data, no error) except when len(p) == 0. Callers must treat 0, nil as "nothing happened, try again".

One 0, nil is merely discouraged. A reader that returns it every time breaks the deeper promise behind rule 4 — that reading eventually makes progress:

type StuckReader struct{}

func (StuckReader) Read(p []byte) (int, error) {
    return 0, nil // "nothing yet"… forever
}

func main() {
    data, err := io.ReadAll(StuckReader{}) // never returns: spins at 100% CPU
    fmt.Println(len(data), err)            // never reached
}

io.ReadAll loops until it sees an error. StuckReader never returns one, so the loop never ends. Nothing in the type system can catch this — it's purely a broken promise.

Try it: run the client's tests against each subtype

Predict each result before you press Run. Two of the readers look suspicious but are legal — they test whether you know the contract.

LSP contract tester
Pick a subtype, then run the client's tests against it. The code really runs: shapes as JavaScript classes, readers as a simulation of Go's io.Reader rules.
The subtype
1class Square extends Rectangle {
2 // keep it square: one side sets both
3 setWidth(w) { this.w = w; this.h = w; }
4 setHeight(h) { this.w = h; this.h = h; }
5}
The client
1// The client was written against Rectangle:
2function stretch(r) {
3 r.setWidth(5);
4 r.setHeight(4);
5 assert(r.area() === 20);
6}
Client's test suiteSquare extends Rectangle
·Accepts any positive sizeprecondition
·setWidth leaves the height alonepostcondition
·setHeight leaves the width alonepostcondition
·stretch(): area is 5 × 4 = 20client's real use
Predict first: will this subtype pass every check? Then press Run.

How to fix an LSP violation

  • Don't force the subtype relationship. Square and Rectangle can both offer area() without one extending the other. Making shapes immutable (no setters; "resizing" returns a new shape) also makes the problem disappear.
  • If a method would have to throw "not supported", the type wants a smaller interface. A read-only file shouldn't pretend to be a Writer and panic on Write — it should only be a Reader. That's the next principle.
  • Write contract tests. One shared test suite that every implementation must pass — the same idea as the widget above. Go's testing/iotest package even ships helpers for testing readers.
Practitioner§07

I — Interface segregation

In one sentenceNo code should be forced to depend on methods it doesn't use. Ask for exactly what you call.

Imagine applying for a library card and being handed a 40-page form because the same form is used for mortgages. Most of it doesn't apply to you, but you still have to read it, and any change to the mortgage section means everyone gets a new form. A fat interface — one with many methods, used by code that needs only a few — does the same thing to your code.

The problem: one interface for everyone

users.go — a fat interface
type UserStore interface {
    GetUser(id string) (User, error)
    ListUsers() ([]User, error)
    CreateUser(u User) error
    UpdateUser(u User) error
    DeleteUser(id string) error
    SetPassword(id, hash string) error
    ExportCSV(w io.Writer) error
    Ping() error
}

func ShowProfile(users UserStore, id string) (string, error) {
    u, err := users.GetUser(id) // ← the only method this function uses
    if err != nil {
        return "", err
    }
    return "Hello, " + u.Name, nil
}

Three costs hide in that signature:

  1. Tests get heavy. A test fake for ShowProfile must implement all eight methods, seven of them with panic("unused").
  2. Readers can't tell what the function does. Looking at the signature, ShowProfile might delete users.
  3. Changes ripple. Add a method to UserStore and every fake in the codebase stops compiling — including ones for code that will never call it.

Click through the consumers and see each cost:

Interface slicer
Four pieces of code use the same user store. Click one to see which methods it really calls — and what its test fake has to implement.
UserStore methods1 of 8 used
GetUser
ListUsers
CreateUser
UpdateUser
DeleteUser
SetPassword
ExportCSV
Ping
Fat interface + its test fake
1func ShowProfile(s UserStore, …) { … }
2
3// Its test fake must still implement all 8:
4func (f *fake) GetUser(…) … { /* used */ }
5func (f *fake) ListUsers(…) … { panic("unused") }
6func (f *fake) CreateUser(…) … { panic("unused") }
7func (f *fake) UpdateUser(…) … { panic("unused") }
8func (f *fake) DeleteUser(…) … { panic("unused") }
9func (f *fake) SetPassword(…) … { panic("unused") }
10func (f *fake) ExportCSV(…) … { panic("unused") }
11func (f *fake) Ping(…) … { panic("unused") }
ShowProfile uses 1 method, but its signature demands all 8. A reader can't tell what it needs, and a test fake is mostly panic("unused").

The Go way: declare the interface at the consumer

In Go, a type satisfies an interface automatically (§02). That means the code that uses a dependency can declare its own small interface, and the real store fits it without any change:

profile.go
// Declared right here, next to the only code that needs it.
type userGetter interface {
    GetUser(id string) (User, error)
}

func ShowProfile(users userGetter, id string) (string, error) {
    u, err := users.GetUser(id)
    if err != nil {
        return "", err
    }
    return "Hello, " + u.Name, nil
}

// In the test: a map is enough.
type fakeUsers map[string]User

func (f fakeUsers) GetUser(id string) (User, error) {
    u, ok := f[id]
    if !ok {
        return User{}, fmt.Errorf("user %q not found", id)
    }
    return u, nil
}

The real *PostgresUserStore (the production type that implements all eight UserStore methods) still works when passed to ShowProfile: it has GetUser, so it's a userGetter. Go programmers summarize this as "accept interfaces, return structs": functions take the small interface they need, and constructors return the concrete type so each consumer can pick the subset of methods it needs.

The standard library is built this way

Go's most-used interfaces have one method each. Bigger ones are made by combining small ones with interface embedding:

package io (simplified)
type Reader interface {
    Read(p []byte) (n int, err error)
}
type Writer interface {
    Write(p []byte) (n int, err error)
}
type Closer interface {
    Close() error
}

type ReadWriter interface {
    Reader // ← embedding: ReadWriter has every method of Reader…
    Writer // ← …and of Writer
}

Because io.Copy(dst Writer, src Reader) asks for so little, it works with files, network connections, HTTP bodies, compressors, hashes and in-memory buffers — hundreds of types, most written long after io.Copy. A small interface is easy to satisfy, so lots of things can plug into it.

The bigger the interface, the weaker the abstraction.— Rob Pike, Go Proverbs

In JavaScript

JavaScript has no interface types, so a fat interface shows up as a function that takes a whole object but uses one method of it. The fix is the same: depend on less.

// Instead of showProfile(userStore, id) with a 20-method object…
async function showProfile(users, id) {
  const u = await users.getUser(id); // only getUser is used
  return `Hello, ${u.name}`;
}

// …the test passes just what's used:
await showProfile({ getUser: async (id) => ({ id, name: "Ana" }) }, "42");

ISP and LSP work together. In §06, a read-only file that had to implement Write could only panic. With small interfaces, it simply isn't a Writer — the compiler stops you from passing it where writing is needed, instead of a crash at runtime.

Practitioner§08

D — Dependency inversion

In one sentenceYour business rules shouldn't depend on details like a specific database or vendor. Instead, the business code declares the interface it needs, and the details implement it.

First, the two words this principle is about — policy and detail:

  • Policy (or "high-level" code) is why the software exists: "an order needs at least one item, is saved, and the customer gets a confirmation". It changes when the business changes.
  • Detail (or "low-level" code) is how it's done today: Postgres, SMTP, Stripe, the HTTP framework. It changes when technology or vendors change.

The trouble is that the natural way to write code makes policy depend on detail. Robert Martin's principle has two halves:

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions (interfaces).
  2. Abstractions should not depend on details. Details should depend on abstractions.

Before: the business rule imports the database

orders/service.go — before
package orders

import (
    "context"

    "shop/postgres" // ← the business rules now know about Postgres
)

type Service struct {
    db *postgres.DB
}

func (s *Service) PlaceOrder(ctx context.Context, o Order) error {
    if len(o.Items) == 0 {
        return ErrEmpty
    }
    return s.db.InsertOrder(ctx, o.ID, o.Email, o.Total)
}

The Dependency arrow points from policy to detail: orders → postgres. If postgres renames InsertOrder or adds a parameter, orders has to change. You can't test PlaceOrder without a database. And switching to another database means editing the business rules.

After: the business rule owns the interface

orders/service.go — after
package orders

import (
    "context"
    "errors"
)

type Order struct {
    ID, Email string
    Items     []Item
    Total     float64
}

type Item struct {
    SKU   string
    Price float64
}

var ErrEmpty = errors.New("order is empty")

// Store says what the business needs, in business words.
// It lives HERE, in package orders — not in package postgres.
type Store interface {
    Save(ctx context.Context, o Order) error
}

type Service struct {
    store Store
}

func NewService(s Store) *Service {
    return &Service{store: s}
}

func (s *Service) PlaceOrder(ctx context.Context, o Order) error {
    if len(o.Items) == 0 {
        return ErrEmpty
    }
    return s.store.Save(ctx, o)
}

Watch the arrows. This is the "inversion": the import arrow between the packages turns around, while the runtime call still goes from the service to Postgres.

Dependency inversion, animated
Solid arrows = “imports / knows about” (source code). Dashed arrow = “calls at runtime”. Watch which one flips.
package orders · policypackage postgres · detailOrderServicePlaceOrder: the business ruleStore interfaceSave(ctx, Order) errorusespostgres.DBSQL, connections, retriesimportsimports ordersimplements Storecalls at runtimecalls at runtimeservice_test.goFakeStore (a map)implements
orders/service.go
import "shop/postgres"
postgres/db.go
// imports nothing from shop
Before. orders (the business rules) imports postgres (a detail). The solid arrow is a source-code dependency: if postgres changes its types, orders must change and recompile. Testing PlaceOrder needs a real database.

What "inversion" really means

Two different things point in two directions, and the principle is about separating them:

  • Control flow — who calls whom at runtime. PlaceOrder still calls Postgres's Save. That doesn't change.
  • Source-code dependency — who imports whom, and therefore who has to change when the other changes. That's what flips: postgres imports orders, never the reverse.

The interface is named and shaped for the business ("Save an Order"), not for the database ("ExecContext with SQL"). If it mirrored Postgres's API, you'd have an interface but the dependency in spirit would still point at Postgres.

Who builds the real thing?

Somebody still has to create a postgres.DB and pass it to orders.NewService. That happens once, at the outer edge of the program — usually main:

main.go
import _ "github.com/jackc/pgx/v5/stdlib" // registers the "pgx" driver with database/sql

func main() {
    db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
    if err != nil {
        log.Fatal(err)
    }
    svc := orders.NewService(postgres.New(db)) // ← the one place that knows both
    log.Fatal(http.ListenAndServe(":8080", api.Routes(svc))) // api = your HTTP layer
}

Handing a component its dependencies from outside like this is called dependency injection. DIP is the design rule (which way the arrows point); injection is the mechanism (how the parts get connected). The mechanics — constructors vs functional options, wiring by hand vs tools, avoiding Global state — are covered in Dependency Injection.

Practitioner§09

All five on one service

Each principle so far had its own example. Real code doesn't arrive sorted by letter, so let's take the PlaceOrder method from §03 — condensed to 25 lines so it fits on screen; the real one has the same five parts, just longer — and apply the five lenses in the order you'd actually use them. Each step is a small, safe Refactoring — the code does the same thing after every step.

Watch the two panels on the right. "Who can make this file change" is SRP's question. "To unit-test PlaceOrder you need" is the practical payoff of D, I and L.

Refactoring OrderService, one principle at a time
Each step applies one letter. Watch who can make this file change, and what a unit test needs.
PlaceOrder, condensed to 25 lines
Code · 25 lines
1func (s *OrderService) PlaceOrder(o Order) error {
2 if len(o.Items) == 0 {
3 return errors.New("empty order")
4 }
5 price := o.Subtotal
6 switch o.Tier { // discounts
7 case "gold":
8 price *= 0.90
9 case "silver":
10 price *= 0.95
11 }
12 if o.Country == "DE" { // tax
13 price *= 1.19
14 } else if o.Country == "FR" {
15 price *= 1.20
16 }
17 _, err := s.db.Exec( // storage (MySQL)
18 "INSERT INTO orders (id, total) VALUES (?, ?)", o.ID, price)
19 if err != nil {
20 return err
21 }
22 msg := fmt.Sprintf("Subject: Thanks!\r\n\r\nYou paid %.2f", price)
23 return smtp.SendMail(s.smtpAddr, nil, "shop@example.com", // email
24 []string{o.Email}, []byte(msg))
25}
Who can make this file change
Marketing: discountsFinance: taxPlatform: databaseSupport: email textProduct: new channels
To unit-test PlaceOrder you need
a MySQL serveran SMTP server
This is §03's 820-line PlaceOrder, condensed to 25 lines — the real one has the same five parts, each just much longer. It's the shape of many services: correct, readable top to bottom — and answering to five groups of people. To unit-test the discount math you need a database and a mail server, because everything happens in one method.
1 / 7

What the walk shows

  1. The order matters. S comes first because it tells you where to cut. D comes next because it decides which way the new pieces depend on each other. I and L are mostly checks on the interfaces you just created.
  2. The code didn't get shorter. It went from one 25-line method to about 30 lines plus several small files. What improved is that each change now touches one place, and the business rule can be tested in milliseconds without a database.
  3. Every step was justified by a real change. Discounts changed monthly, the database migration was planned, tests were slow. Without those pressures, step 0 would have been fine to keep.
Staff-level§10

SOLID outside Java: Go and JavaScript

SOLID grew up in the late 1990s and early 2000s among C++ and Java programmers. Meyer's open–closed principle dates from 1988 and Liskov's keynote from 1987; Robert Martin gathered the five in a 2000 paper, and Michael Feathers later noticed the initials spelled SOLID. The examples of that era assume classes, Inheritance, and interfaces you must declare with implements.

Go and JavaScript have none of those in the same form. The problems SOLID addresses are the same; the mechanics shift, and some principles become nearly automatic while others become more important.

Principle by principle

Java-era formIn GoIn JavaScript
SOne class per responsibilityMostly applied at the package level: tax/, notify/, orders/. A package is Go's main unit of design.Applied at the module (file) level; functions are often enough — no class required.
OAbstract base classes, subclass to extendSmall interfaces and function types (http.HandlerFunc); Composition instead of subclassing.Arrays/maps of functions, objects with the same methods, middleware chains.
LSubclasses must honor the parent classNo subclasses, so it's about interface contracts (io.Reader, error) — mostly documented, not compiler-checked.Subclasses and duck-typed objects; no compiler check at all without TypeScript, so contract tests matter more.
ISplit big interfaces into role interfacesNearly free: implicit interfaces let the consumer declare a one-method interface. The standard library is the model.Take a function or a small object instead of a whole service; TypeScript's Pick<T, …> for types.
DInterfaces in a separate "API" package; frameworks (like Spring) that build and connect objects for youInterface lives in the consumer's package; wire by hand in main. DI containers are rare.Pass dependencies as parameters or to a factory function; modules are easy to swap in tests.

Go: implicit interfaces change everything about I and D

In Java, a class must say implements Store, so the interface must exist before the class and be importable by it. That pushed designs toward big, shared interface packages defined by the implementer.

In Go, postgres.DB doesn't mention any interface. Any package can later declare an interface that postgres.DB happens to satisfy. So the natural place for an interface is next to the code that uses it — which is exactly what ISP (small, consumer-specific) and DIP (owned by the policy) ask for. The common Go advice "don't define interfaces before you have a consumer" is SOLID, translated.

Go: no inheritance means fewer LSP traps

Go has no extends, so the Rectangle/Square trap — inheriting behavior you then have to fight — can't happen the same way. Go does have Struct embedding: writing a type inside a struct without a field name, like type Fancy struct{ Base }. Base's methods then become callable directly on a Fancy (Method promotion), which looks like inheritance.

The difference shows up when the inner code calls another method. Fancy can declare its own Name() that hides Base's, but Base's own code never calls Fancy's version — there is no "the parent calls my override". Step through both:

Inheritance vs embedding: where does the inner call go?
Same shape of code in both languages. Step through and watch which name() runs.
JavaScript · class extends
1class Base {
2 name() { return "base"; }
3 describe() { return "I am " + this.name(); }
4}
5class Fancy extends Base {
6 name() { return "fancy"; } // override
7}
8new Fancy().describe(); // ?
output: …
Press Run to call describe().
Go · struct embedding
1type Base struct{}
2func (Base) Name() string { return "base" }
3func (b Base) Describe() string {
4 return "I am " + b.Name()
5}
6
7type Fancy struct{ Base } // embedded
8func (Fancy) Name() string { return "fancy" }
9
10Fancy{}.Describe() // ?
output: …
Press Run to call Describe().

That's why embedding is just Composition with shortcuts: Fancy{}.Describe() is shorthand for Fancy{}.Base.Describe(), and the inner Base doesn't know it's wrapped. Go's LSP risks are almost all about interface contracts: a Read that never returns io.EOF; a function documented to return ErrNotFound that starts returning fmt.Errorf("load user: %w", ErrNotFound) (Error wrapping), which breaks every caller that checked err == ErrNotFound instead of errors.Is(err, ErrNotFound); a Close that isn't safe to call twice when the docs say it is.

JavaScript: duck typing and modules

With duck typing, every object is implicitly an "interface" of whatever methods you call on it. That makes ISP and DIP cheap (pass in any object with the right methods) but makes L risky: nothing checks that a substitute really behaves the same. Practical consequences:

  • The smallest interface is a function. A strategy, a rule, a notifier with one method — all can just be functions, thanks to first-class functions.
  • A module is the natural unit for S. One file per actor's concern, with only the needed functions exported.
  • TypeScript's structural types behave like Go interfaces: an object fits a type if it has the right shape, no implements needed.
Staff-level§11

When SOLID becomes over-engineering

In one sentenceEvery SOLID move adds indirection — another file, another interface, another hop to follow. Indirection is a cost. Pay it only when it buys you something you need now.

Each principle on this page has a failure mode, and they all look the same: code that's "flexible" in ways nobody uses, and slower to read for everyone. The guiding rule is YAGNI — "You Aren't Gonna Need It".

What each principle looks like when overdone

PrincipleHealthyOverdone
SSplit where different teams ask for changesEvery function its own type and file — "ravioli code": dozens of tiny pieces, no visible structure
OA rule list for discounts that change monthlyPlugin systems, config files and hooks for things that changed once in three years
LContract tests for an interface with several real implementationsDeep inheritance trees built to "reuse" code, then patched to keep them substitutable
IOne-method interfaces where consumers need one methodAn interface for every struct, "just in case"
DInterfaces at the boundaries: database, network, clock, payment providerInterfaces between every pair of internal types, with a framework to wire them

The interface with one implementation

This pattern is common in codebases written by people coming from Java:

type UserService interface {         // declared next to its only implementation
    GetUser(id string) (User, error)
}

type userService struct{ db *sql.DB }

func NewUserService(db *sql.DB) UserService { return &userService{db: db} }

It has one implementation, no test that substitutes it, and every "go to definition" in your editor now lands on the interface instead of the code. It adds a hop and buys nothing.

An interface with one production implementation is still worth it when:

  • it marks a boundary you need to fake in tests — a database, an external API, the clock, randomness;
  • it breaks an import cycle (how: the staff note in §08);
  • it's a published extension point that other teams or packages will implement.

Otherwise, return the concrete type. In Go, a consumer that needs to substitute it later can declare its own small interface without touching your code (§07). In JS, nothing needs to change at all.

Duplication is far cheaper than the wrong abstraction.— Sandi Metz, “The Wrong Abstraction” (2016)

The cognitive cost is real

A junior developer opens a SOLID-heavy codebase to fix a bug in checkout. PlaceOrder calls s.store.Save. Go to definition: an interface. Find implementations: three (Postgres, an in-memory cache wrapper, a test fake). Which one runs in production? That's decided in main.go, two packages away. Five hops to find one line of SQL.

Reading the code: how many hops to the SQL?
A new teammate wants to see what PlaceOrder's save actually does. Count the go-to-definition jumps.
Press “Follow the call”.
hops: 0

That cost is paid on every read, by every reader, forever. The benefit — easy swaps, isolated tests — is paid back only when a swap or test actually happens. When the benefit never comes, the design is a net loss.

SOLID's critics

Not everyone agrees SOLID is the right frame. Dan North's talk "Why Every Single Element of SOLID Is Wrong" argued for "write simple code" over the five rules, later proposing an alternative set of properties called CUPID. A common criticism is that SOLID optimizes for changeability in the abstract, while most code benefits more from being small, obvious and deletable.

The useful takeaway isn't "SOLID good" or "SOLID bad". It's to treat each principle as a diagnosis tool: when change hurts, the five letters help you name why. When nothing hurts, leave the code alone. For the broader catalog of design mistakes — including the god object from §03 — see Anti-patterns.

Practitioner§12

Pitfalls gallery

#1

Reading SRP as “a function should do one thing”

That's a good guideline for functions, but it isn't SRP. SRP is about who asks for changes (§04). Splitting a cohesive module into ten classes that always change together makes code harder to follow without reducing any risk.

#2

Declaring the interface next to the implementation (in Go)

Java habit: type UserService interface in the same package as type userService struct, often with an I prefix or Interface suffix. In Go, let the consumer declare the small interface it needs (§07), and return concrete types from constructors.

✕ Don't
// users/service.go
package users

type UserServiceInterface interface {
    Get(id string) (User, error)
    // …11 more
}
func New() UserServiceInterface { … }
✓ Do
// users/service.go
package users
func New(db *sql.DB) *Service { … } // concrete

// profile/profile.go — a different file
package profile
type userGetter interface {          // only what profile uses
    Get(id string) (users.User, error)
}
#3

Extension points nobody extends

A PricingStrategyFactory with one strategy, a plugin loader with one plugin, a config flag no one ever flips. Each is speculative generality: code that costs reading time today for a change that may never come. Wait until the second or third real change (§05).

#4

Subtypes that throw “not supported”

class ReadOnlyList extends List {
  add() { throw new Error("not supported"); } // LSP violation in one line
}

Every caller of List.add now has a hidden crash. If a type can't do everything its parent promises, it shouldn't be that parent's subtype — give it a smaller interface instead (§06, §07).

#5

Test fakes that promise more than the real thing

A fake store whose Save always succeeds lets tests pass for code that breaks in production on a duplicate ID or a timeout. A Test double is a subtype too: it must follow the same contract, errors included (the L step of §09).

#6

An interface, but the dependency is still built inside

✕ Don't
type Service struct{ store Store }

func NewService(db *sql.DB) *Service {
    // the interface is useless: we still
    // hard-wire Postgres right here
    return &Service{store: postgres.New(db)}
}
✓ Do
type Service struct{ store Store }

func NewService(s Store) *Service {
    return &Service{store: s} // caller decides
}

The point of DIP is that the business code doesn't choose the detail. If the constructor still imports and builds it, the import arrow hasn't flipped (§08).

#7

An interface shaped like the database instead of the business

type Store interface { Exec(query string, args ...any) error } technically inverts the dependency, but every caller still writes SQL. The interface should speak the business's language — Save(ctx, Order) — so that replacing the database doesn't change any caller.

#8

Pulling a method off a JS object and losing this

const { getUser } = userStore;   // looks like "taking only what I need"…
await getUser(42);               // TypeError: Cannot read properties of undefined

Destructuring a method detaches it from its object, so this is undefined inside. Pass (id) => userStore.getUser(id) or userStore.getUser.bind(userStore) (§07).

#9

SOLID-ing a script

A 200-line command-line tool, a prototype you'll throw away next week, a one-off data migration: none of them need interfaces, factories or injected clocks. Apply SOLID where code lives long and changes often (§11).

Practitioner§13

Check yourself: which principle is violated?

Read each snippet and name the principle it strains before you click. Q1 is covered in §04, Q2 in §05, Q3 in §06, Q4 in §07, Q5 in §08 and Q6 in §11. Some have more than one defensible answer — the explanation says which one is the main problem.

QUESTION 1 / 6
Which principle does this break?
class InvoiceService {
  calculateTotal(invoice) { /* tax + discounts (Finance) */ }
  renderPdf(invoice)      { /* fonts, logo, layout (Design) */ }
  saveToDb(invoice)       { /* SQL (Platform team) */ }
}
QUESTION 2 / 6
A new shape means editing this function. Which principle?
func Area(s Shape) float64 {
    switch s.Kind {
    case "circle":
        return math.Pi * s.R * s.R
    case "square":
        return s.Side * s.Side
    }
    return 0 // triangle? edit this function again
}
QUESTION 3 / 6
What's wrong here?
type Cache interface {
    Get(key string) (string, bool)
    Set(key, value string)
}

type ReadOnlyCache struct{ data map[string]string }

func (c ReadOnlyCache) Get(k string) (string, bool) { v, ok := c.data[k]; return v, ok }
func (c ReadOnlyCache) Set(k, v string) { panic("read-only cache") }
QUESTION 4 / 6
Which principle is strained?
type Repository interface {
    // … 14 methods: Get, List, Create, Update, Delete,
    // Search, Count, Export, Import, Migrate, Backup, …
}

func PrintName(r Repository, id string) error {
    u, err := r.Get(id) // the only call
    if err != nil {
        return err
    }
    fmt.Println(u.Name)
    return nil
}
QUESTION 5 / 6
Which principle?
package billing

import "shop/stripe"

type Checkout struct{}

func (c Checkout) Pay(amount int) error {
    client := stripe.NewClient(os.Getenv("STRIPE_KEY")) // ← built right here
    return client.Charge(amount)
}
QUESTION 6 / 6
A teammate wrote this for a struct with exactly one implementation, never mocked. Verdict?
type UserServiceInterface interface {
    GetUser(id string) (User, error)
}

type UserService struct{ /* … */ }

func NewUserService() UserServiceInterface { return &UserService{} }
Answered 0 / 6
Foundations§14

Cheat sheet

🎯
S — Single responsibilityOne module answers to one actor (one group of people who ask for changes). Not "does one thing".
🧩
O — Open–closedFor a change that keeps coming back, make it an addition: a new rule, a new type. Only along axes you've actually seen change.
🔁
L — Liskov substitutionA subtype keeps every promise of its parent: ask for no more, promise no less. Fakes in tests included.
✂️
I — Interface segregationAsk for exactly the methods you call. In Go, declare small interfaces where they're used.
🔌
D — Dependency inversionBusiness rules declare the interfaces they need; databases and vendors implement them. The import arrow points at the policy.
⚖️
Indirection is a costAdd an abstraction when it pays for a change or test you have today. Otherwise: YAGNI.
You notice…PrincipleTypical fix
Two teams keep editing the same file, and break each other's featuresSSplit along the actors: one module per group that requests changes
The same switch grows a new case every monthOA list of rules or an interface; each new case is a new type or function
A subclass throws "not supported", or a substitute makes callers hang or crashLDon't make it a subtype; use a smaller interface; add contract tests
A test fake needs 12 methods to test code that calls 1IDeclare a 1-method interface at the consumer (Go) or take a function (JS)
Business code imports the database driver or a vendor SDKDInterface in the business package, implementation in the detail package, wire in main
An interface with one implementation, no fakes, no boundarynone — over-engineeringReturn the concrete type; add the interface when a second use appears

Go one-liners: accept interfaces, return structs · the bigger the interface, the weaker the abstraction · define interfaces where they're consumed.

Where to go next: Dependency Injection for wiring the pieces together, Go Idioms for how Go code is usually shaped, and Anti-patterns for what happens when design goes wrong in either direction.

Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Design PatternsCreational Patterns
Read →