What the SOLID principles are
SOLID is a set of five design principles for object-oriented code: single responsibility, open/closed, Liskov substitution, interface segregation and dependency inversion. Each one targets a specific way code becomes hard to change, such as edits that ripple across files or subclasses that surprise their callers.
Robert C. Martin collected the principles in the early 2000s, and Michael Feathers arranged them into the acronym. Two are older: Bertrand Meyer described open/closed in 1988, and Barbara Liskov stated her substitution principle in 1987.
The ideas apply beyond classes, to modules, functions and services in any language that separates an interface from its implementation. They are heuristics for spotting design pressure, and the last section covers when following them to the letter makes code worse.
Single responsibility: one reason to change
A module should have one reason to change. Martin later restated it in terms of people: a module should be responsible to one actor, meaning one group whose requests drive its changes.
Picture an InvoiceService that calculates totals, renders the PDF and emails it to the customer. The finance team asks for a tax rule change, and the design team asks for a new PDF layout. Both edits land in the same class, so a layout change can break tax math and every change needs the whole class retested.
Splitting it into a calculator, a renderer and a sender gives each request one place to land. The principle addresses unrelated changes colliding in the same code.
A common misreading is that a class should do only one thing. That leads to splitting code by verb into dozens of one-method classes, which spreads a single change across many files instead of containing it.
Open/closed: add behavior without editing working code
Code should be open for extension but closed for modification. In practice, adding a new variant of something should mean adding new code rather than editing a tested function in several places.
Consider a checkout that handles card, bank transfer and voucher payments with a switch statement. The same switch appears in validation, fee calculation and refunds. Adding a wallet payment means finding and editing every copy, and missing one is a bug.
With an interface, each payment type becomes its own implementation. A new type is a new class registered in one place, and the existing ones stay untouched.
interface PaymentMethod {
fee(amountCents: number): number;
refund(paymentId: string): Promise<void>;
}
class CardPayment implements PaymentMethod {
fee(amountCents: number) {
return Math.round(amountCents * 0.03);
}
async refund(paymentId: string) {
// call the card processor
}
}
// Adding a wallet means one new class and one new entry here.
const paymentMethods: Record<string, PaymentMethod> = {
card: new CardPayment(),
};Liskov substitution: subtypes must keep the contract
Code that works with a base type must keep working when it receives any subtype. A subtype can add behavior, but it can't demand more from callers or promise less than the base type does.
The textbook case is a Square that extends Rectangle. A caller sets the width to 5 and the height to 4, then expects an area of 20. The Square keeps its sides equal, so the second setter changes both and the area comes out as 16.
A more everyday case is a read-only collection that extends a mutable one and throws an error on add. Every caller that accepted the base type now has to check which subtype it got, which defeats the point of the abstraction.
The principle addresses surprise. When it holds, you can pass in a new implementation without reading every caller. When a subtype can't honor the contract, model it as a separate type instead of forcing it into the hierarchy.
Interface segregation and dependency inversion
Both principles are about what a module depends on. Interface segregation says callers shouldn't have to depend on methods they don't use. Dependency inversion says high-level policy shouldn't depend on low-level details; both should depend on an abstraction, usually owned by the policy side.
A MultifunctionDevice interface with print, scan and fax forces a basic printer to implement fax by throwing an error. Splitting it into Printer, Scanner and Fax means each class implements only what it supports. The same idea applies to parameters: a function that only sends email should accept an object with an email field, not a whole User.
For dependency inversion, picture a ReportService that imports a Postgres client directly. Every test needs a database, and changing storage means editing business code. Instead, ReportService declares a ReportStore interface with the two methods it needs, and a Postgres adapter implements it.
The inversion is in the source code: the database module now imports the interface from the business module, instead of the other way round. The dependency injection article covers how the concrete adapter gets passed in.
When applying SOLID rigidly backfires
Each principle trades directness for flexibility. That trade pays off where change happens and costs you everywhere else.
Apply a principle when you feel the pain it addresses: a change rippling across files, a test that needs a real database, a subclass that needs special handling. Without that pain, the simpler code is usually the better design.
Typical ways each principle goes wrong when it is applied by rule:
- Single responsibility taken too far produces many tiny classes, and one feature change then touches ten files.
- Open/closed applied up front builds plug-in points for variations that never arrive. Wait until you have two or three real cases.
- Liskov substitution gets harder to honor with each level of a deep inheritance tree. Composition often avoids the problem.
- Interface segregation can split an API into so many small interfaces that nobody can find the right one.
- Dependency inversion applied to everything gives you an interface for every class with exactly one implementation, which adds a file to read and nothing to swap.