Design Principles: SOLID

Imagine two workshops that both make wooden chairs. In the first, every tool is bolted to every other tool: move the lathe and the drill press swings with it, sharpen a chisel and somehow the glue pot tips over. In the second, each tool sits on its own bench, does one job well, and plugs into the others through a shared, standard fitting. Both workshops can build a chair today. But when the customer wants a stool next week, only the second workshop can rearrange itself without a fight.

Software has exactly this choice. Code is read and changed far more often than it is first written — a system may be edited for years by people who never met its author. A design that is pleasant to change survives; a design where every edit ripples into ten unrelated files gets quietly abandoned. Design principles are the accumulated wisdom of how to keep object-oriented code changeable. The most famous set is SOLID: five principles that, taken together, pull a codebase toward two deep goals — high cohesion and low coupling.

The two goals behind everything: cohesion and coupling

Before the five letters, meet the two ideas they all serve. Every principle below is really just a tactic for raising cohesion or lowering coupling.

Why do these two matter so much for maintainability? Because they decide the blast radius of a change. In a high-cohesion, low-coupling system, a new requirement lands in one obvious class, and the edit stays there. In a low-cohesion, high-coupling system, that same requirement is smeared across a dozen classes that all know too much about each other, and touching any one of them breaks three others you had forgotten existed. The picture below makes the contrast physical.

On the left, every module is wired to almost every other: a dense web where pulling one node drags the whole tangle. On the right, the same six modules talk through a single shared hub (an abstraction), so each depends on one thing, not five. Same modules, wildly different cost of change. SOLID is the set of habits that keeps you on the right.

The five principles

SOLID is an acronym coined by Robert C. Martin. Each letter is a small, memorable rule; each is a different angle on "keep cohesion high and coupling low". Here they are with the one-line idea and a tiny concrete example.

The five principles were gathered and popularised by Robert C. Martin ("Uncle Bob") around 2000; Michael Feathers coined the tidy SOLID ordering a little later. None of them is a theorem you can prove — they are heuristics, rules of thumb distilled from decades of watching object-oriented code rot or thrive. The "L" is the odd one out: it comes from a genuine 1987 result by Barbara Liskov about behavioural subtyping, which is why it sounds more formal than its four neighbours.

Worked example: from tightly coupled to Dependency Inversion

Principles click when you refactor real code. Here is an OrderService that must tell a customer their order shipped. The naive version reaches straight for a concrete EmailSender:

class EmailSender { send(to: string, text: string) { console.log(`EMAIL to ${to}: ${text}`); } } // TIGHTLY COUPLED: OrderService hard-wires the concrete EmailSender. class OrderService { private email = new EmailSender(); // ← baked in ship(customer: string) { this.email.send(customer, "Your order has shipped!"); } }

It works — until marketing wants SMS, or a test needs to check the message without really emailing anyone. Because OrderService creates and names the concrete EmailSender, every such change means editing (and re-testing) OrderService itself. That is high coupling.

Dependency Inversion fixes it: introduce a Messenger abstraction, make OrderService depend on that, and hand it a concrete messenger from outside (this handing-in is called dependency injection). Now the service depends only on an interface, and swapping channels — via polymorphism — needs no edit to the service at all. Run it:

// The abstraction the high-level code depends on. interface Messenger { send(to: string, text: string): void; } // Low-level details: each implements the same small interface. class EmailMessenger implements Messenger { send(to: string, text: string) { console.log(`EMAIL to ${to}: ${text}`); } } class SmsMessenger implements Messenger { send(to: string, text: string) { console.log(`SMS to ${to}: ${text}`); } } // A test double — no real sending, yet OrderService is unchanged. class FakeMessenger implements Messenger { public sent: string[] = []; send(to: string, text: string) { this.sent.push(`${to}|${text}`); } } // High-level policy: depends on Messenger, NOT on any concrete class. class OrderService { constructor(private readonly messenger: Messenger) {} // injected from outside ship(customer: string) { this.messenger.send(customer, "Your order has shipped!"); } } // The SAME OrderService, three different channels — zero edits to it. new OrderService(new EmailMessenger()).ship("ada@example.com"); new OrderService(new SmsMessenger()).ship("+44 7700 900123"); const fake = new FakeMessenger(); new OrderService(fake).ship("test@example.com"); console.log("Test captured:", fake.sent.length, "message(s):", fake.sent[0]);

Notice what changed and what didn't. OrderService no longer knows or cares how a message travels — its coupling to the outside world shrank to a single small interface. Adding WhatsApp tomorrow is a brand-new class and nothing else: that is the Open/Closed Principle falling out for free. One good abstraction served three principles at once.

Cohesion, coupling, and the cost of tomorrow

Step back and the whole of SOLID is one sentence: build systems out of small, cohesive pieces that depend on each other only through stable abstractions. Do that and change is cheap — a new feature is usually a new class, slotted in, with the old code left alone. Ignore it and you get the big ball of mud: a system so interconnected that no one dares touch it, so it is patched from the outside with ever-hackier workarounds until it is rewritten from scratch.

The principles are not about elegance for its own sake. They are an economic bet: pay a little discipline now — an interface here, a split class there — to make every future change cheaper. In code that lives for years, that bet pays off many times over.

A tempting misreading of SOLID is "more classes and more abstraction are always better — so split everything and put an interface in front of it all." This is how you get the opposite disaster: a "lasagne" of a hundred one-method classes and interfaces with a single implementation each, where following one simple action means opening fifteen files. That code has low cohesion (behaviour scattered everywhere) hiding behind a costume of good design. SOLID exists to make code easier to read and change — it is a means, not an end. Add an abstraction when a real, likely change would otherwise be painful (a second messenger, a second payment provider), not on the mere possibility of one. The right amount of structure is the least that keeps the next change cheap; a Square extends Rectangle that "obeys inheritance" but breaks callers is worse than two honest, separate classes.

Mostly it is phrased in OO terms — classes, interfaces, inheritance — because that is where it grew up. But the ideas travel. In functional programming, "depend on abstractions" becomes "take a function as a parameter"; "single responsibility" becomes "small, pure functions that do one thing"; "open/closed" becomes "compose new behaviour from existing functions". Different vocabulary, same two goals underneath: keep each piece cohesive, and keep the wires between pieces thin.