Skip to main content

SOLID principles

Five design principles, collected by Robert Martin, aimed at making designs more understandable, flexible and maintainable. The letters spell a mnemonic:

Applied mindlessly, they cause more harm than good. Every one of them buys flexibility with indirection, and there is probably no successful product where all five are applied everywhere at once. Strive for them, stay pragmatic, and treat none of it as dogma - each section below ends with the cases where the principle is the wrong tool.

Single responsibility principle

A class should have just one reason to change.

Every class should own a single slice of the system's behavior, and that slice should be entirely hidden inside it. The wording matters: not "one method", not "one hundred lines" - one reason to change.

Why it exists

The goal is reducing complexity, and complexity is a problem you earn over time rather than one you start with. A 200-line program does not need architecture. Write a dozen decent methods and get on with your life.

The trouble arrives when the program keeps growing. Classes get big enough that you can no longer hold them in your head. Navigation slows to scanning. You start reading an entire file to find one thing, and the number of moving parts overflows whatever mental stack you were using to keep track. That is the moment the code stops being yours and starts being something you negotiate with.

And there is a mechanical cost on top of the cognitive one. A class that does several things has to be edited when any of them changes, and each edit risks breaking the parts you were not thinking about. Two responsibilities in one class means two teams filing changes against the same lines, which is a merge conflict factory with extra steps.

The example

An Employee class holds employee data and, somewhere near the bottom, prints the timesheet report. Both are perfectly reasonable pieces of code. Together they give the class two reasons to change: the way the company models employees, and the way finance wants that report formatted this quarter.

Move the printing into a TimeSheetReport class and the seam is obvious in hindsight. As a bonus, all the other report-shaped code scattered around the codebase now has somewhere to go.

// Employee knows two entirely unrelated things:
// what an employee is, and what a timesheet report looks like.
class Employee is
field name: string
field hourlyRate: number
field hoursWorked: list of entries
 
method getName() is
return name
 
method setName(name) is
this.name = name
 
// ...and then, for no structural reason, printing:
method printTimeSheetReport() is
print("TIMESHEET FOR " + name.toUpperCase())
print("--------------------------------")
foreach entry in hoursWorked
print(entry.date + " " + entry.hours + "h")
print("--------------------------------")
print("TOTAL: " + sum(hoursWorked) + "h")
 
// Two reasons to change, in one file:
// 1. the HR system changes how employees are modelled
// 2. someone in finance wants the report in CSV
Employee- name+ getName()+ printTimeSheetReport()
BEFORE: Employee also formats the timesheet report.
TimeSheetReport...+ print(employee)Employee- name+ getName()
uses
AFTER: reporting moves out, so each class has one reason to change.

Where it goes wrong

The failure mode is enthusiasm. "One reason to change" is not "one method per class", and taken literally it produces a codebase where every behavior is a class, every class is injected, and understanding one feature means opening eleven files.

Skip the split when:

  • The program is small enough to read in one sitting. Complexity you do not have does not need managing.
  • The two "responsibilities" always change together. If nobody has ever edited one without the other, they are one responsibility wearing a disguise.
  • The extraction would be a class with a single method, no state, and one caller. That is a function, and it was fine as a function.

The useful question is not "does this class do more than one thing?" - almost every class does. It is "have these things ever needed to change independently, and do I expect them to?" If the answer is no, leave it alone and revisit when reality disagrees.

Try it yourself: Split Employee into two classes, one that holds employee data and one that owns the timesheet report, then rewrite the caller that currently calls employee.printTimeSheetReport() so it goes through the new report class instead.

Open/closed principle

Classes should be open for extension but closed for modification.

The two halves sound like they cancel out. They do not, because they describe different activities. A class is open if you can build on it from the outside - subclass it, implement its interface, plug something new into it. A class is closed if it is complete: its interface is settled, other code relies on it, and nobody needs to reopen the file to add a feature.

The point of holding both at once is simple: new features should not be able to break old ones. If shipping a new requirement means editing a class that is already developed, tested, reviewed and depended upon, you are gambling with code that was working an hour ago.

The example

An e-commerce Order class calculates shipping costs, and every shipping method lives inside it as a branch of an if. Ground shipping has a free-delivery threshold, air shipping is a flat rate, each has its own delivery estimate. It works, right up until sales promises customers same-day delivery.

Now adding a shipping method means editing Order, which is the class that also computes totals, and which every checkout path in the application runs through. The risk is entirely disproportionate to the feature.

Extract shipping methods into their own classes behind a common Shipping interface, and the new method arrives as a new class. Order is not touched. Client code links the order to whichever shipping object the user picked in the UI, and that is the only place that knows concrete types.

// Every shipping method the company will ever offer,
// hardcoded into the class that also handles orders.
class Order is
field lineItems: list
field shipping: string
 
method getTotal() is
total = 0
foreach item in lineItems
total += item.price * item.quantity
return total
 
method getShippingCost() is
if (shipping == "ground")
// free ground shipping on large orders
if (getTotal() > 100)
return 0
return max(10, getTotal() * 0.03)
else if (shipping == "air")
return 100
 
method getShippingDate() is
if (shipping == "ground")
return today() + 5 days
else if (shipping == "air")
return today() + 1 day
 
// Adding same-day delivery means editing Order.
// Order is tested, reviewed, and used by half the app.
Order- lineItems- shipping+ getTotal()+ getTotalWeight()+ setShippingType(st)+ getShippingCost()+ getShippingDate()if (shipping == "ground") { // free ground delivery on big orders if (getTotal() > 100) return 0 // $1.5 per kilogram, $10 minimum return max(10, getTotalWeight() * 1.5)}if (shipping == "air") { // $3 per kilogram, $20 minimum return max(20, getTotalWeight() * 3)}
BEFORE: every new shipping method edits Order.
Order- lineItems- shipping: Shipping+ getTotal()+ getTotalWeight()+ setShippingType(sh)+ getShippingCost()+ getShippingDate()«interface»Shipping+ getCost(order)+ getDate(order)Ground...+ getCost(order)+ getDate(order)Air...+ getCost(order)+ getDate(order)return shipping.getCost(this)
implementsaggregation
AFTER: a new shipping method is a new class, and Order never moves.

Two things worth noticing. First, this is the Strategy pattern, again arrived at by following a principle rather than reaching for a catalog. Second, moving the delivery-time calculation next to the cost calculation also fixed a single responsibility problem for free - the principles tend to pull in the same direction.

Where it goes wrong

The principle is not a ban on editing classes.

  • Bugs get fixed in place. If a class is wrong, correct it. Creating a subclass to route around a defect makes the child responsible for the parent's mistakes, and now you have two problems and a confusing hierarchy.
  • Speculative extension points are a tax. Every seam you carve out costs indirection: one more interface to name, one more file to open, one more hop when reading a stack trace. Pay it where variation is plausible, not everywhere.
  • Two variants is not always a pattern. Ground and air, with no third on the roadmap and no team asking, is a perfectly respectable if. Extract on the second or third genuine change request, when the direction of variation is known rather than guessed.

The tell that you actually need this: a conditional that has grown one arm per variant, and a history of commits that all touch it.

Try it yourself: Extract Order's getShippingCost()/getShippingDate() if/else chain into a Shipping interface with one implementing class per shipping method, then rewrite Order so adding same-day delivery never requires touching Order again.

Liskov substitution principle

When extending a class, you should be able to pass objects of the subclass wherever the parent was expected, without breaking the client code.

Most design principles are open to interpretation and taste. This one comes with a checklist, which makes it the easiest of the five to actually verify - and the one whose violations hurt most, because they only show up at runtime, in somebody else's code.

That "somebody else" is the point. The principle matters most when you are writing libraries and frameworks, where your subclasses end up inside programs you cannot see, let alone fix.

The checklist

A subclass stays substitutable when its overrides obey all of these:

  • Parameter types match or get more abstract. The base declares feed(Cat c). Overriding it as feed(Animal c) is fine, because callers still pass cats and cats are animals. Overriding it as feed(BengalCat c) is not, because the client's ordinary cat no longer fits.
  • Return types match or get more specific. The inverse rule. buyCat(): Cat may be narrowed to buyCat(): BengalCat, because the caller wanted a cat and got one. Widening it to buyCat(): Animal breaks code that was built around cat-shaped results. In dynamically typed languages the equivalent sin is returning a number where the base returned a string.
  • No new exception types. Client code catches what the base method is documented to throw. An override that raises something unrelated slips straight past the try/catch and takes the process with it.
  • No stronger pre-conditions. If the base accepts any int and your override rejects negatives, code that has been passing negative values for two years starts throwing.
  • No weaker post-conditions. If the base method always closed its database connections, your connection-reusing override leaves callers - who terminate right after the call because they trusted the contract - leaking connections into the void.
  • Invariants are preserved. The conditions under which an object makes sense at all. Cats have four legs and a tail; your subclass does not get to remove one. This is the rule most often broken by accident, because invariants live partly in interface contracts, partly in assertions, and partly in unit tests nobody reread.
  • Private state stays private. Reflection, and languages with no real access control, make it possible to reach into a superclass's private fields. Possible is not permission.

Statically typed languages such as Java and C# enforce the first three at compile time, which means the interesting failures are almost always in the last four.

The example

A Document class can be opened and saved. Somebody adds ReadOnlyDocument, and since saving makes no sense there, the override throws. Reasonable-looking, and wrong: the client code now has to check the concrete type before saving anything, which drags it into knowing about document subclasses and quietly breaks the open/closed principle too. Add another document type and the client changes again.

The fix is not a smarter override, it is a redesigned hierarchy. A subclass should add to the base behavior, so make the read-only document the base class and let a writable document extend it with the ability to save. Now the type system says exactly what is true, and the type check disappears.

// The hierarchy says every document can be saved.
// One subclass disagrees, at runtime, by exploding.
class Document is
field data: string
field filename: string
 
method open() is
// load data from disk
 
method save() is
// write data back to disk
 
class ReadOnlyDocument extends Document is
method save() is
throw new Error("Unable to save a read-only file.")
 
// Client code now has to know the concrete type,
// which is the coupling we were trying to avoid.
class Project is
field documents: list of Document
 
method openAll() is
foreach doc in documents
doc.open()
 
method saveAll() is
foreach doc in documents
// a type check, in code that should not care about types
if (doc is not ReadOnlyDocument)
doc.save()
Document- data- filename+ open()+ save()ReadOnlyDocument...+ save()Project- documents+ openAll()+ saveAll()foreach (doc in documents) doc.open()foreach (doc in documents) if (!(doc is ReadOnlyDocument)) doc.save()throw new Exception("cannot save a read-only document")
extendscomposition
BEFORE: the subclass cancels a promise the base class made.
Document- data- filename+ open()WritableDocument...+ save()Project- allDocs- writableDocs+ openAll()+ saveAll()foreach (doc in allDocs) doc.open()foreach (doc in writableDocs) doc.save()
extendscomposition
AFTER: the base class promises less, so nobody has to take it back.

Where it goes wrong

There is no "overkill" version of this one in the way there is for the other principles - substitutability is not an optional flourish. What does go wrong is over-application:

  • Not every awkward subclass needs the hierarchy inverted. Sometimes the honest answer is that these two things were never in an "is a" relationship, and composition dissolves the problem entirely.
  • Do not contort a design to preserve an invariant nobody depends on. The safest way to extend a class is to add fields and methods and touch nothing existing, but "safest" is not always "possible", and a heroic hierarchy built to satisfy a rule literally can be worse than the violation.
  • Beware the rectangle-and-square rabbit hole. Long arguments about whether a square is a rectangle are usually a sign that the model, not the principle, needs the attention.

The practical test costs nothing: take every place the superclass is used, imagine your subclass there instead, and ask whether anything gets surprised.

Try it yourself: Rework the Document/ReadOnlyDocument hierarchy so ReadOnlyDocument no longer overrides save() with a thrown exception, then rewrite Project.saveAll() so it no longer needs a type check against ReadOnlyDocument before calling it.

Interface segregation principle

Clients should not be forced to depend on methods they do not use.

Keep interfaces narrow enough that the classes implementing them never have to invent behavior they do not have. A "fat" interface is one that bundles several unrelated capabilities, and its damage is contagious: change it, and even clients that never touched the changed methods have to be recompiled, retested, and possibly rewritten.

The structural argument is short. A class may extend exactly one superclass, but it can implement as many interfaces as it likes. There is no scarcity to ration, so there is no excuse for cramming unrelated methods into a single declaration. Break it into refined pieces and let the classes that genuinely do everything implement all of them.

The example

You build a library that makes integrating with cloud providers painless. Version one supports Amazon, and since Amazon offers essentially every cloud service in existence, your CloudProvider interface covers storage, servers and CDN addresses. It fits perfectly, because it was traced around exactly one shape.

Then you add a second provider. It does storage and nothing else. Suddenly most of the interface is too wide, and the only way to satisfy the compiler is to implement methods that cannot work: throw here, return an empty list there, return null and look away. Every one of those is a promise the type system is making on your behalf and your object cannot keep.

Split the interface into CloudHostingProvider, CDNProvider and CloudStorageProvider. Amazon implements all three. The storage-only provider implements one, honestly. Client code that only backs up files asks for a CloudStorageProvider, and the mismatch that used to be a 3am exception becomes a compile error.

// One interface modelled on the single provider that
// happened to support everything.
interface CloudProvider is
method storeFile(name)
method getFile(name)
method createServer(region)
method listServers(region)
method getCDNAddress()
 
class Amazon implements CloudProvider is
method storeFile(name) is
// real implementation
method getFile(name) is
// real implementation
method createServer(region) is
// real implementation
method listServers(region) is
// real implementation
method getCDNAddress() is
// real implementation
 
// The second provider has no CDN and no servers,
// so it fills the gaps with lies.
class Dropbox implements CloudProvider is
method storeFile(name) is
// real implementation
method getFile(name) is
// real implementation
method createServer(region) is
throw new NotSupportedError()
method listServers(region) is
return [] // shrug
method getCDNAddress() is
return null // hope nobody calls this
«interface»CloudProvider+ storeFile(name)+ getFile(name)+ createServer(region)+ listServers(region)+ getCDNAddress()Amazon...+ storeFile(name)+ getFile(name)+ createServer(region)+ listServers(region)+ getCDNAddress()Dropbox...+ storeFile(name)+ getFile(name)+ createServer(region)+ listServers(region)+ getCDNAddress()not implemented
implements
BEFORE: one bloated interface, and a client that cannot honestly satisfy it.
«interface»CloudHostingProvider+ createServer(region)+ listServers(region)«interface»CDNProvider+ getCDNAddress()«interface»CloudStorageProvider+ storeFile(name)+ getFile(name)Amazon...+ storeFile(name)+ getFile(name)+ createServer(region)+ listServers(region)+ getCDNAddress()Dropbox...+ storeFile(name)+ getFile(name)
implements
AFTER: three narrow interfaces, and each provider claims only what it does.

Where it goes wrong

Like every principle here, this one can be taken past the point of usefulness.

  • Do not split an interface that is already specific. The failure this principle prevents is an implementer forced to fake behavior. If nobody is faking anything, there is nothing to fix.
  • Interface count is a real cost. Each one is a name to invent, a file to open, and an indirection to follow. Ten single-method interfaces where two would do makes the code more granular and less comprehensible at the same time.
  • Do not split by aesthetics. Split along the lines that actual implementers and actual clients care about. If every implementer implements all the pieces and every client depends on all the pieces, you have distributed one interface across several files and gained nothing.

The trigger to watch for is a stub. The first throw new NotSupportedError() written to satisfy an interface is the principle knocking.

Try it yourself: Break CloudProvider into narrower interfaces so Dropbox never has to implement createServer(), listServers(), or getCDNAddress(), then rewrite Dropbox to implement only the interface it can honestly support.

Dependency inversion principle

High-level classes should not depend on low-level classes. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.

Two levels of class show up in almost any system. Low-level classes do the basic mechanical work: reading a disk, pushing bytes over a network, talking to a database. High-level classes hold the business logic that directs the low-level ones.

The trouble is the order people build them in. Prototyping a new system, you write the low-level parts first, because until they exist you are not sure what the higher level can even ask for. Perfectly sensible, and it leaves your business logic shaped around the plumbing. Change the plumbing and the business logic changes, which is precisely backwards: a new database server version has no business affecting a budget report.

Inverting it

Three steps:

  1. Describe what the high level actually needs, in business language. The reporting code should be able to call openReport(file), not openFile(x), readBytes(n), closeFile(x). That vocabulary choice is the load-bearing part - an interface phrased in low-level terms is the low-level class in disguise, and it will leak the moment the implementation changes.
  2. Make the high-level classes depend on those interfaces instead of on concrete low-level classes. The coupling is still there, but it is now to something you control and that changes when your business changes.
  3. Have the low-level classes implement them. Once they do, they depend on the business layer's abstraction, and the original arrow has been turned around.

Note who owns the interface. It is declared by and for the high level, which is what distinguishes this from simply putting an interface in front of everything. This principle also pairs naturally with open/closed: new low-level implementations plug into existing business logic without touching it.

The example

A budget reporting class reads and persists its data through a MySQL database class. Any change down there - a new server version, a schema convention, a migration to something else entirely - propagates up into a class whose job description says nothing about storage.

Declare a Database interface describing the read and write operations the report needs, in the report's own terms, and let the report depend on that. Then have the MySQL class implement it, and add a MongoDB class beside it whenever someone wants one. The reporting logic never learns which one it got.

// A low-level class, written first, speaking in
// storage terms because that is all it knows.
class MySQLDatabase is
method insert(record)
method update(record)
method delete(record)
 
// The business logic reaches straight down into it.
class BudgetReport is
field database: MySQLDatabase
 
constructor BudgetReport(db: MySQLDatabase) is
this.database = db
 
method open(date) is
// hand-assembled query, in the reporting class
rows = database.select("SELECT * FROM budget WHERE d = " + date)
return buildReport(rows)
 
method save() is
database.insert(this.toRecord())
 
// The arrow points the wrong way: a new database version,
// or a switch to Mongo, edits the business logic.
High levelLow levelBudgetReport- database+ open(date)+ save()MySQLDatabase...+ insert()+ update()+ delete()
BEFORE: business logic reaches down into a storage class.
High levelAbstractionLow levelBudgetReport- database+ open(date)+ save()«interface»Database+ insert()+ update()+ delete()MySQL...+ insert()+ update()+ delete()MongoDB...+ insert()+ update()+ delete()
implementsassociation
AFTER: the arrow is inverted - storage implements the business logic's contract.

Where it goes wrong

This is the SOLID principle most likely to metastasize into architecture astronautics.

  • One implementation, one interface, no plausible second. An interface that exists only to be implemented once is a file tax. If the only reason for it is stubbing in tests, ask whether a purpose-built fake or an in-memory implementation would say more about your intent.
  • Interfaces that mirror their implementation method for method. If Database has exactly the methods MySQL happens to expose, including the ones named after MySQL concepts, nothing was inverted. You renamed the coupling.
  • Inverting inside a single layer. The principle is about boundaries between levels - business logic against infrastructure. Two collaborating business classes at the same altitude usually just need to know each other.
  • Frameworks that inject everything. A container wiring three hundred single-use interfaces makes the dependency graph invisible rather than flexible.

Invert at the seams that genuinely move: storage, network, third-party services, the clock. Everywhere else, a direct call is a feature, because you can read it.

Try it yourself: Introduce a Database interface that BudgetReport depends on instead of MySQLDatabase directly, then rewrite MySQLDatabase to implement it so BudgetReport no longer names a concrete database class anywhere in its own code.

Spot the smell

Five snippets, each lifted straight from the "where it goes wrong" examples above. Pick which principle each one breaks before scrolling back up to check your work.

Snippet 1 of 5
// Employee knows two entirely unrelated things:
// what an employee is, and what a timesheet report looks like.
class Employee is
field name: string
field hourlyRate: number
field hoursWorked: list of entries
 
method getName() is
return name
 
method setName(name) is
this.name = name
 
// ...and then, for no structural reason, printing:
method printTimeSheetReport() is
print("TIMESHEET FOR " + name.toUpperCase())
print("--------------------------------")
foreach entry in hoursWorked
print(entry.date + " " + entry.hours + "h")
print("--------------------------------")
print("TOTAL: " + sum(hoursWorked) + "h")
 
// Two reasons to change, in one file:
// 1. the HR system changes how employees are modelled
// 2. someone in finance wants the report in CSV

Check yourself

Question 1 of 15

What exactly does "one responsibility" mean in practice?