SOLID Principles
Five heuristics for keeping the cost of change flat as a codebase grows — and the judgment to know when each one is the wrong tool.
Heuristics, Not Laws
SOLID is five design guidelines assembled by Robert C. Martin in the early 2000s from ideas that were already circulating — Barbara Liskov's work on subtyping, Bertrand Meyer's open-closed principle, decades of accumulated pain with rigid procedural codebases. Michael Feathers noticed the initials could be rearranged into a memorable acronym, and a mnemonic was born. That origin matters: SOLID is a curated collection of observations about what made object-oriented systems expensive to change, not a set of theorems. None of the principles can be mechanically verified, and all of them trade one cost for another.
The unifying idea is this: software spends most of its life being modified, not written. Every principle in SOLID is an answer to the question "when a requirement changes, how much existing, tested, deployed code do I have to touch, and how far does the blast radius extend?" If a design decision does not change that answer, SOLID has nothing to say about it. That is why applying the principles to a 200-line script, a throwaway prototype, or a module with one caller and no history of churn usually makes the code worse: you pay the indirection tax immediately and the payoff never arrives.
Cargo-culting SOLID — adding an interface in front of every class, splitting every object until each has one method, building plugin systems for code that has never once needed a second implementation — produces codebases that are harder to navigate than the "bad" code the principles were meant to prevent. A senior engineer treats each principle as a diagnostic lens: it tells you why a particular change hurt, and what structural move would make the next change of that kind cheap. The skill is matching the lens to a demonstrated pattern of change, not applying all five everywhere.
How to read this module
Each principle below follows the same arc: what it actually says (including the popular misreading), a realistic violation in code, the refactor, and the failure mode of over-applying it. The last section is purely about judgment — the signals that you have gone too far.
Single Responsibility Principle
SRP is the most quoted and most misread principle in the set. The popular version — "a class should do one thing" — is not what it says, and following the popular version leads directly to the one-method-class explosion we cover at the end of this page. The actual formulation is "a module should have one reason to change," and Martin later sharpened what "reason" means: a reason to change is a person, or more precisely an actor — a stakeholder group whose requirements can change independently.
Consider an invoice. Finance owns the rules for how totals, tax, and rounding are calculated. The data team owns the schema the invoice is persisted into. The product or design team owns how it is rendered for customers. Those are three different actors with three independent streams of change requests. If all three concerns live in one class, a change requested by any one of them forces a redeploy of — and a re-test of, and a merge conflict risk in — code owned by the other two.
The violation
class Invoice {
constructor(private lines: InvoiceLine[]) {}
// Finance owns these rules
calculateTotal(): number {
return this.lines.reduce(
(sum, line) => sum + line.unitPrice * line.quantity,
0
);
}
// The data team owns this schema
saveToDatabase(db: Connection): void {
db.execute(
"INSERT INTO invoices (total, line_count) VALUES (?, ?)",
[this.calculateTotal(), this.lines.length]
);
}
// Presentation owns this markup
renderHtml(): string {
return "<h1>Invoice</h1><p>Total: " +
this.calculateTotal().toFixed(2) + "</p>";
}
}The symptoms in practice: a tax-rounding change requested by finance shows up in the same pull request queue as a column rename and an HTML tweak; the class accumulates imports from the database driver, the templating layer, and the money library; and every test file that touches invoices needs a database fixture even when it only cares about arithmetic.
The refactor
// Owned by finance: pure calculation, trivially testable
class Invoice {
constructor(readonly lines: InvoiceLine[]) {}
total(): number {
return this.lines.reduce(
(sum, l) => sum + l.unitPrice * l.quantity,
0
);
}
}
// Owned by the data team: knows the schema, nothing else
class InvoiceRepository {
constructor(private db: Connection) {}
save(invoice: Invoice): void {
this.db.execute(
"INSERT INTO invoices (total, line_count) VALUES (?, ?)",
[invoice.total(), invoice.lines.length]
);
}
}
// Owned by presentation: markup only
class InvoiceHtmlView {
render(invoice: Invoice): string {
return "<h1>Invoice</h1><p>Total: " +
invoice.total().toFixed(2) + "</p>";
}
}Note what the refactor did not do: it did not split calculation into a TotalCalculator, a LineSummer, and a RoundingPolicy. All of the arithmetic belongs to one actor, so it stays together. Cohesion — keeping things that change together in one place — is the other half of SRP that the "do one thing" misreading throws away.
When splitting is the wrong call
The failure mode is real and common: a codebase where every class has one method, every operation is smeared across six files, and understanding a single request means opening a dozen tabs. Teams call this "lasagna code" — so many thin layers that no single one contains enough logic to reason about. If two pieces of logic always change in the same pull request, they have the same reason to change, and separating them adds navigation cost for zero change-isolation benefit. Split when actors demonstrably diverge, not preemptively.
Open-Closed Principle
"Open for extension, closed for modification" sounds like a paradox and gets misread as "never touch existing files." That reading is both impossible and undesirable — fixing bugs, renaming, and refactoring all modify existing code, and should. What OCP actually describes is a property you can give a specific axis of change: when the system needs a new variant of an existing behavior, you can add it by writing new code against a stable abstraction, instead of editing the tested, deployed code that dispatches between variants.
The canonical smell is the switch statement that grows forever. Every team has one: discount types, notification channels, export formats, payment providers. Each new variant means editing the same function — the same function every previous variant flows through — and re-verifying behavior that was already correct.
The violation
function applyDiscount(order: Order): number {
switch (order.discountType) {
case "none":
return order.subtotal;
case "percentage":
return order.subtotal * (1 - order.discountValue);
case "fixed":
return Math.max(0, order.subtotal - order.discountValue);
case "loyalty":
// Added last sprint: edits code every order flows through
return order.customer.tier === "gold"
? order.subtotal * 0.85
: order.subtotal * 0.95;
default:
throw new Error("Unknown discount: " + order.discountType);
}
}The refactor
Introduce an abstraction for the thing that varies — a discount rule — and a registry that maps type to rule. Adding a new discount now means adding a new file and one registry entry. The dispatch code, and every previously shipped rule, stays untouched.
interface DiscountRule {
apply(order: Order): number;
}
const rules: Record<string, DiscountRule> = {
none: { apply: (o) => o.subtotal },
percentage: {
apply: (o) => o.subtotal * (1 - o.discountValue),
},
fixed: {
apply: (o) => Math.max(0, o.subtotal - o.discountValue),
},
loyalty: {
apply: (o) =>
o.customer.tier === "gold" ? o.subtotal * 0.85 : o.subtotal * 0.95,
},
// New rules register here; nothing above ever changes
};
function applyDiscount(order: Order): number {
const rule = rules[order.discountType];
if (!rule) {
throw new Error("Unknown discount: " + order.discountType);
}
return rule.apply(order);
}Speculative extension points are debt
The strategy registry above is only a win because discount rules were proven to keep arriving. An abstraction with exactly one implementation, built because a second one "might" show up, costs real complexity today for a payoff that usually never comes — and when the second case finally arrives, it often varies along a different axis than the one you guessed. Write the switch first. Refactor to a registry the second or third time it grows. The refactor is cheap precisely because the change pattern is by then obvious.
Liskov Substitution Principle
LSP is the most technically precise principle in the set, and the one where the misreading is subtlest. It is not "subclasses should be is-a relationships" in the taxonomic sense. It is a statement about behavioral contracts: any code written against a base type must keep working, without knowing or checking, when handed any subtype. Concretely, a subtype may not:
- Strengthen preconditions — demand more of callers than the base type did (rejecting inputs the base type accepted).
- Weaken postconditions — deliver less than the base type promised (returning without actually doing the work, returning a narrower result).
- Break invariants — violate properties the base type guaranteed would always hold.
- Throw new exception types the base contract did not declare — an UnsupportedOperationException from a method the base type implements is a substitution failure, even though the code compiles.
The classroom example is Rectangle/Square: a Square that inherits from Rectangle must break either setWidth or the area invariant, because callers of Rectangle assume width and height vary independently. Mathematically a square is a rectangle; behaviorally, a mutable Square is not a substitutable Rectangle. That is the whole point: "is-a" in inheritance is a claim about substitutable behavior, not real-world taxonomy.
The production version
You will rarely ship a Square. You will absolutely ship one of these: a read-only repository subtype that throws on save, or a caching decorator that silently drops writes. Both compile, both pass a type-check, and both detonate in code that was written — correctly — against the base contract.
class OrderRepository {
async save(order: Order): Promise<void> {
// persists the order; callers rely on a normal return
}
}
class ReadOnlyOrderRepository extends OrderRepository {
// LSP violation: throws where the base contract promised success
async save(_order: Order): Promise<void> {
throw new Error("Repository is read-only");
}
}
// Written correctly against the base type, far from either subtype:
async function fulfil(repo: OrderRepository, order: Order) {
order.markFulfilled();
await repo.save(order); // explodes only with SOME subtypes
}The caching variant is nastier because it fails silently: a write-through cache subtype that drops writes above a size threshold weakens the postcondition ("after save returns, the data is durable") without ever throwing. Nothing crashes; data just quietly does not exist later. LSP violations that throw are found in staging; LSP violations that silently under-deliver are found in incident reviews.
The refactor
The fix is almost never a cleverer subclass — it is admitting the contract was wrong. If some implementations cannot write, then "writes" does not belong in the shared contract. Split the type so each contract only promises what every implementation can honor, and let call sites demand exactly the capability they need.
interface OrderReader {
findById(id: string): Promise<Order | null>;
}
interface OrderWriter {
save(order: Order): Promise<void>;
}
// Full repository honors both contracts
class SqlOrderRepository implements OrderReader, OrderWriter {
async findById(id: string): Promise<Order | null> {
return this.db.queryOne("SELECT * FROM orders WHERE id = ?", [id]);
}
async save(order: Order): Promise<void> {
await this.db.upsert("orders", order);
}
}
// The replica honestly implements only what it can do
class ReplicaOrderRepository implements OrderReader {
async findById(id: string): Promise<Order | null> {
return this.replica.queryOne("SELECT * FROM orders WHERE id = ?", [id]);
}
}
// Call sites now demand exactly the capability they use
async function fulfil(repo: OrderWriter, order: Order) {
order.markFulfilled();
await repo.save(order); // cannot be handed a read-only impl
}A cheap LSP test
Run the base type's test suite against every subtype. If any subtype needs its own weaker assertions, expects different exceptions, or must skip tests, the hierarchy is lying about its contract — fix the contract or break the inheritance.
Interface Segregation Principle
ISP says clients should not be forced to depend on methods they do not use. The mechanism of harm is false coupling: when one fat interface serves many different clients, every client is recompiled, re-tested, re-mocked, and potentially broken by changes to parts of the interface it never touches. Implementors, meanwhile, are forced to stub out methods that make no sense for them — and every stub that throws or silently no-ops is an LSP violation waiting for a caller. ISP and LSP are two views of the same disease: fat contracts nobody can fully honor.
A realistic case: a notification system grows a single channel interface. Email needs subject lines and HTML bodies. SMS has neither. Webhooks need retry configuration and signing secrets. Push needs device-token management. One interface accretes all of it, and every new channel starts life by stubbing out most of an API designed for the other channels.
| Fat interface (before) | Role interfaces (after) |
|---|---|
| NotificationChannel: send, sendBatch, renderHtml, renderPlainText, setSubject, validateDeviceToken, configureRetries, signPayload, previewTemplate, listBounces, trackOpens, unsubscribeUrl | MessageSender (send, sendBatch) · HtmlRenderable (renderHtml, previewTemplate) · DeviceTargeted (validateDeviceToken) · SignedDelivery (signPayload, configureRetries) · EngagementTracked (trackOpens, listBounces) |
| SmsChannel implements 12 methods, throws "not supported" from 8 of them | SmsChannel implements MessageSender. That is all. |
| A change to webhook signing forces recompiling and re-testing email, SMS, and push code | Signing changes touch only SignedDelivery and its two implementors |
| Every unit test mocks 12 methods to exercise the 1 it calls | Tests mock a 2-method role interface |
The test-pain row is the one to internalize, because it is the earliest signal. When setting up a mock takes more lines than the behavior under test, the interface is telling you it serves clients with different needs. Segregate by client need, not by implementation convenience: the question is never "what can this class do?" but "what does this call site actually require?" A class is welcome to implement five role interfaces; each caller should only ever see the one it uses.
The over-application failure here is interface confetti: fifteen single-method interfaces for a subsystem with exactly one implementation and one caller, where a single cohesive interface — or no interface at all — would do. Roles earn their existence when distinct client groups demonstrably need distinct slices, not before.
Dependency Inversion Principle
DIP is about the direction of source-code dependencies, and the misreading — "use interfaces everywhere" or "DIP means dependency injection" — misses that entirely. The principle says: high-level policy (business rules) should not depend on low-level detail (SMTP libraries, ORMs, SDKs); both should depend on abstractions, and critically, the high-level module owns the abstraction. The "inversion" is that where naive layering has business logic importing infrastructure, DIP flips the arrow: infrastructure imports an interface defined by, and shaped by the needs of, the business logic.
Ownership is the load-bearing word. An OrderService that depends on an IEmailClient interface defined in the email package has not inverted anything — the interface still speaks the detail's language (MIME types, subject lines) and still lives on the detail's side of the boundary, so email concerns still leak into ordering code. Invert it: the order module defines a NotificationPort in its own vocabulary ("an order shipped, tell the customer"), and the SMTP adapter — in the infrastructure layer — imports the order module to implement it. Swap SMTP for a queue or a push service and the order module does not even recompile.
// order/notification-port.ts -- OWNED by the order module,
// written in the order module's vocabulary
export interface NotificationPort {
orderShipped(orderId: string, email: string): Promise<void>;
}
// order/order-service.ts -- high-level policy
export class OrderService {
constructor(private readonly notifier: NotificationPort) {}
async ship(order: Order): Promise<void> {
order.markShipped();
await this.notifier.orderShipped(order.id, order.customerEmail);
}
}
// infra/smtp-notification-adapter.ts -- the detail depends
// on the policy's abstraction, not the other way around
import { NotificationPort } from "../order/notification-port";
export class SmtpNotificationAdapter implements NotificationPort {
constructor(private readonly smtp: SmtpClient) {}
async orderShipped(orderId: string, email: string): Promise<void> {
await this.smtp.send(
email,
"Your order shipped",
"Order " + orderId + " is on its way."
);
}
}This pattern generalized across a whole application boundary is ports-and-adapters (hexagonal architecture): the domain core defines ports — interfaces expressing what it needs from the outside world and what it offers to it — and everything volatile (databases, message brokers, third-party APIs, UI frameworks) lives in adapters that implement or call those ports. All source-code arrows point inward toward the domain; the domain points at nothing. That is DIP applied systematically rather than class by class.
Keep DIP and DI distinct in your head
DIP is a design principle about which module owns the abstraction and which way the source dependency points. Dependency injection is merely a construction mechanism — passing collaborators in rather than instantiating them — and a DI container is just tooling for that mechanism. You can inject dependencies all day while every interface lives in the infrastructure layer and violates DIP; and you can satisfy DIP with plain constructor arguments and no framework at all. In discussion and in interviews, conflating the two signals pattern-matching rather than understanding.
And the counterweight, as always: invert dependencies where the detail is volatile or the policy is worth isolating — payment providers, notification channels, anything you mock in tests. Putting a port in front of a stable standard library call, or defining an abstraction for a detail that will never have a second implementation and never needs test isolation, is ceremony.
When SOLID Hurts
Every principle above has the same shape: it trades local simplicity for change isolation. That trade only pays off when the change actually arrives. The senior-level skill is not knowing the principles — it is recognizing when you are paying the premium for insurance against a change that is not coming. Concrete signals that a codebase has over-applied SOLID:
- Interfaces with exactly one implementation, forever. An IUserService/UserServiceImpl pair that has existed for three years with no second implementation and no test double that a plain class could not have provided. The indirection is pure cost.
- Files-per-feature explosion. Adding a nullable column to one form touches nine files across four layers, each containing a few lines of pass-through. The layers are not isolating change; they are amplifying it.
- Abstractions with one caller. A strategy pattern, event bus, or plugin registry consumed from exactly one place. Flexibility no one uses is complexity everyone pays.
- Logic you can only understand in a debugger. When dispatch is so dynamic that "what runs when I call this?" requires runtime inspection, indirection has exceeded the team's ability to reason statically — the exact ability SOLID was supposed to protect.
- Stubs and no-ops everywhere. Many implementors throwing NotImplemented or silently returning is fat contracts (ISP) breeding substitution failures (LSP) — abstraction boundaries drawn where no real seam exists.
The working heuristic: wait for the second concrete need. Write the direct version first. When the second discount rule, second notification channel, or second storage backend actually shows up, refactor to the abstraction — at that point you have two real cases to shape it, so you will draw the seam where variation actually occurs instead of where you guessed it might. Duplicating once and abstracting on the third occurrence is almost always cheaper than abstracting wrongly on the first. The cost of adding an abstraction later is usually one honest refactor; the cost of a wrong abstraction is every future change fighting it.
Don't cite principles as arguments
"This violates SRP" is not a reason to change code — it is a claim that needs a mechanism behind it. The reason is always concrete: "finance and the data team keep colliding in this file," or "every new discount edits code all orders flow through." If you cannot name the actor, the recurring change, or the blast radius, the principle does not apply — and invoking it anyway is how teams end up with lasagna.
Interviewer's Perspective
When SOLID comes up in a design or behavioral interview, reciting the acronym is table stakes and earns nothing. What separates senior candidates: they define SRP in terms of actors rather than "doing one thing"; they reach for a real violation they have shipped or fixed, with the blast radius it caused; and — the strongest signal — they volunteer the limits unprompted: "I would not introduce the strategy pattern here until a second rule actually exists." Candidates who present SOLID as rules to maximize read as mid-level; candidates who present it as a cost-benefit lens, with examples of choosing NOT to apply it, read as people you can trust with architecture.
Knowledge Check
Test your understanding of SOLID before moving on.