SOLID design principles

Five principles for object-oriented code that stays cheap to change, each met at the moment one billing module starts to hurt without it.

A class that three teams keep editing

In March, finance asked for a change to how tax is rounded on invoices: four lines in InvoiceService. In April, customer success asked for the totals column on the invoice PDF to be narrower, and that change landed in InvoiceService too, in a formatMoney helper that both the PDF and the tax calculation happened to call. May's invoices went out with PDF totals that disagreed with the stored ones by a cent here and there. Nobody was careless. Two unrelated requests landed in the same class, and the class let one of them break the other.

The five SOLID principles are about this kind of cost: how much code that already works you have to open, and re-test, when a requirement changes. They are design principles for object-oriented code, heuristics rather than laws. Each one names a way classes and interfaces become expensive to change, and a way of arranging them so that it happens less. This article follows one billing module through all five, in the order it runs into them. The UML notation in the diagrams is covered in Inheritance, interfaces and abstract types and Association, aggregation, composition.

Before: one class, three sets of requests

Diagram components: Class, Note.

Every operation is reasonable. The trouble is who asks for each one to change.

The Single Responsibility Principle (SRP) is often quoted as "a class should do one thing". That is not what it says, and it would not have helped: InvoiceService does one thing, invoicing, and May happened anyway. Robert C. Martin's wording is that a class should have one reason to change, and he later made "reason" concrete: an actor, the person or group whose needs drive changes to that code. Finance owns tax and totals, customer success owns how the PDF looks, the platform team owns storage. Three actors, three reasons to change, one class.

So the class splits along those lines, and a request from one actor lands in code only that actor cares about. Totals and tax go to InvoiceCalculator, layout to InvoicePdfLayout, storage to InvoiceRepository. What is left of InvoiceService becomes BillingRun, a thin coordinator that calls the three when the month closes. formatMoney splits too: the calculator rounds by finance's rules and the layout formats for display. They shared a name, never a reason to change.

After: one class per actor

Diagram components: Class, Class, Class, Class.

BillingRun coordinates; each class it uses answers to one group of people.

Splitting can also go too far, and that mistake is just as common. Push "one thing" all the way and the calculator becomes SubtotalCalculator, TaxRateLookup, TaxLineRounder and TotalAssembler, one method each. Now a single request from finance, say a new tax on digital services, touches four files that always change together, and nobody can read the tax rules in one place. Martin's own summary has two halves: separate the things that change for different reasons, and gather the things that change for the same reason. The second half is what cohesion means. Split when two actors pull the same code in different directions, not before.

A third way to deliver an invoice

Invoices start out going by email. Then a large customer wants them posted to its accounting system through a webhook, and the delivery code grows a switch on customer.deliveryMethod. A year later the company starts selling in a country where business invoices must go through the government's e-invoicing system, which forwards them to the customer, and the same method gets a third branch. Each branch is small. The problem is where they live: every new channel means editing, re-testing and re-deploying a method that already works for every existing customer, and the new branch's first bug sits a few lines from the email path everyone depends on.

The Open/Closed Principle (OCP) says a module should be open for extension and closed for modification: you add behaviour by adding code, not by editing code that works. The way there is an abstraction at the point where variation is expected. Here that point is "how an invoice reaches the customer", so it becomes an interface, InvoiceChannel, with one operation, deliver(invoice). Email, webhook and portal each become a class that realizes it, and BillingRun depends on the interface alone.

Delivery behind one interface

Diagram components: Class, Interface, Class, Class, Class.

No line runs from BillingRun to a concrete channel. A new channel is a new box; BillingRun and the existing channels are not edited.

A fourth channel, a printed letter for customers who insist on paper, is now one new class plus one line where the channel for each customer is chosen. BillingRun and the three existing channels stay closed. The choice itself has to live somewhere, and it moves to the code that reads the customer's settings, which is the shape Strategy describes. Decorator and Template Method are two other standard ways to add behaviour without editing what already exists.

No design is closed against every change. InvoiceChannel makes a new channel cheap and does nothing for a change of another shape: if finance decides each customer should get one batch per month instead of one delivery per invoice, deliver(invoice) itself is wrong and every channel changes. You choose which changes to be closed against, and the honest basis for that choice is the variation you have actually seen. This interface earned its place when the second and third channels arrived. A TaxPolicy interface with one implementation, for a rule nobody has asked to vary, is a layer every reader has to look through, and it may not match the variation that eventually comes. An interface with a single implementation is not automatically wrong, but "we might need it" is a weak reason for one.

The portal channel that throws

The portal brings a requirement the other channels do not have: the e-invoicing system rejects an invoice without the customer's tax ID. The first PortalChannel.deliver(invoice) checks for it and throws MissingTaxIdError when it is absent. It compiles, it realizes the interface, the type checker is satisfied. But BillingRun was written against InvoiceChannel, whose contract is to return a receipt or fail with DeliveryFailed, which BillingRun catches and schedules for retry. Halfway through month-end it meets an exception it has never heard of, the run stops, and the invoices after that customer are not sent.

This is the failure the Liskov Substitution Principle (LSP) names. Barbara Liskov stated the idea in a 1987 keynote, and she and Jeannette Wing gave it a precise form in 1994: code written against a base type must keep working when handed any of its subtypes. Working means behaviour, not matching signatures, and the conditions are about the contract the caller relies on:

  • Preconditions are not strengthened. A subtype may not demand more of its input than the base type accepts. InvoiceChannel accepts any finalized invoice for the customer the channel serves; this PortalChannel also demands that the invoice carry a tax ID. That is the violation above.
  • Postconditions are not weakened. A subtype delivers at least what the base type promises. A channel that returns a receipt before anything is sent, when the contract says a receipt means the customer's side accepted the invoice, breaks the caller that starts the 30-day payment clock from that receipt.
  • Invariants are kept. What the base type guarantees is always true of its state stays true in every subtype.

The fix is not to teach BillingRun about portals. An if (channel instanceof PortalChannel) in the caller is exactly what the interface was meant to remove. The fix moves the requirement to where it can be met: each PortalChannel is built for one customer and takes that customer's tax ID in its constructor, so it is never built without one, and the code that assigns channels refuses portal delivery to a customer with no tax ID on file. Every PortalChannel that BillingRun receives now accepts any finalized invoice for its customer, as the base type promised.

The requirement moves to construction

Diagram components: Interface, Note, Class, Note.

The contract is written where callers can read it, and the subtype keeps it.

The textbook case is a Square that extends Rectangle: if callers can set width and height independently, a caller that sets 5 by 4 on a square gets an area of 16, not 20. A type that cannot keep the base type's promises should not be its subtype, however natural "a square is a rectangle" sounds, and Inheritance, interfaces and abstract types discusses what to use instead of a hierarchy.

Seven methods, and the reminder job uses two

By now storage sits behind an interface, InvoiceRepository, implemented by SqlInvoiceRepository, and it has seven operations, because every feature that touched invoices added one: save, findById, markPaid, findOverdue, recordReminder, listForExport and markExported. Three clients use it. BillingRun saves, finds and marks invoices paid. The reminder job, which emails customers who are late paying, uses findOverdue and recordReminder. The accounting export uses listForExport and markExported.

Each client depends on all seven, and the reminder job's tests show what that costs. Its hand-written fake of InvoiceRepository gives real answers for findOverdue and recordReminder; the other five return null, because the job never calls them. Then support reports customers reminded about invoices they paid that morning, and the fix is for the job to re-read each invoice with findById just before emailing and skip it if it is paid. The fake's findById returns null, the new code treats a missing invoice like one with no paidAt, as unpaid, and every test stays green. The suite now passes without ever running the new check against a paid invoice, and nothing about the job's dependency said that one of the stubs had started to matter.

The Interface Segregation Principle (ISP) says no client should be forced to depend on methods it does not use. The fat interface splits by what each client needs: OverdueInvoices for the reminder job, InvoiceExport for the export, InvoiceStore for billing. One class, SqlInvoiceRepository, can still realize all three. The split is in what each client sees, not necessarily in the implementation.

One interface per client

Diagram components: Class, Class, Class, Interface, Interface, Interface, Class.

Each client names only what it uses. One SQL class can still implement all three.

Now the reminder job's dependency says what it uses, and its fake has two methods, both real. When the job needs findById, that need has to be added to what it depends on, in plain sight, and a hand-written fake that lacks it fails loudly instead of quietly answering null: at compile time where the interface is checked statically, or on the first call in a dynamic language. A fake that stubs the new method to return null would still hide the change, so the fix also needs a test with a paid invoice. A change to the export's interface also stops at the export instead of reaching the reminder job's code and tests. The split follows clients, not methods: one interface per method, FindOverdue, RecordReminder, MarkPaid and so on, turns every constructor into a list of single-method dependencies that always arrive together. Methods one client always uses together belong in one interface.

Which way the import points

One question is left: where do InvoiceStore and its siblings live? The obvious place is next to their implementation, in a persistence package with SqlInvoiceRepository. Then BillingRun, the code that knows when a month closes and what an invoice must contain, imports from persistence. The rules the business cares about most depend on the SQL package: billing's tests drag in the database code, and a change in persistence can force billing to be rebuilt. Having interfaces did not help, because the dependency still points from policy to detail.

Before: policy imports detail

Diagram components: Package, Package.

The interface exists, but it lives with the SQL code, so billing still depends on persistence.

The Dependency Inversion Principle (DIP) says two things. High-level modules, the policy, should not depend on low-level modules, the details; both should depend on abstractions. And abstractions should not depend on details; details should depend on abstractions. The part that is easy to miss is ownership. The interface belongs to the side that uses it: InvoiceStore moves into billing, written in billing's terms, and persistence imports it from there in order to implement it.

After: detail imports policy

Diagram components: Package, Package, Package, Note.

persistence and main both point at billing. Only main also knows persistence, because it wires the two together at startup.

At runtime nothing changed: BillingRun still calls into the SQL code to save an invoice. What changed is the source code. The arrow between the two packages now points from persistence to billing, against the direction of the call, and that reversal is the inversion in the name. billing can be compiled and tested with an in-memory InvoiceStore and no database code at all, and persistence can be replaced by anything that implements billing's interface. In a classic Layered architecture the domain depends on the infrastructure below it; Hexagonal (Ports & Adapters) is the same move as here, made for a whole application. Packages, notes and dependency direction shows how to state the rule on a diagram.

Something still has to create the SqlInvoiceRepository and hand it to BillingRun; in the diagram that is main. Handing it in is dependency injection, a mechanism that Dependency Injection explains, while DIP is the principle about which way the source dependencies point.

Inversion has a price as well: every inverted dependency is one more interface to name, keep in step and look through when you read the code. It pays off at boundaries where the detail is volatile or slow to test, such as databases, payment providers, email and the clock. Inverting the dependency on a stable value type like Money usually buys nothing.

The question behind all five

All five ask the same question from different sides: when a requirement changes, how much code that already works do you have to open? Each answer adds structure, and structure has a reading cost, so apply them where change has happened or is plainly coming. A module that has not changed in two years does not need five new interfaces.

Practise on Notification Channels

Notification Channels is this article's delivery problem with a different payload: a Notifier that must gain a new channel without any existing type being edited. Declare the channel contract as an interface, realize it with at least two channels labelled «realize», model the Message they are given, and draw the Notifier's one «use» toward the contract, never toward a channel. Then name a change, an SMS channel, and count the existing boxes you would have to edit: none, with one box added and one line added where a channel is picked. For LSP, write the contract in a note and check that every channel you drew could keep it.

In Layered Dependencies, put the repository contract on the inner side of the boundary and use a note to name the inversion. In Chess Pieces, check that nothing outside the Piece hierarchy asks which piece it is holding.

All Learn articles