SOLID in JavaScript: Five Principles, Broken and Fixed in Runnable Code
By Sergey Nosov
9 October 2026
Suppose a reviewer leaves a one-line comment on your pull request: “This violates the Liskov Substitution Principle.” You have two good answers. You can show that the code does not violate it, or you can explain why breaking it is the right call here. Both answers require knowing the principle well enough to argue about it, and that is the best reason I know to learn SOLID.
SOLID names five principles of object-oriented design: single responsibility, open-closed, Liskov substitution, interface segregation, and dependency inversion. Most of their classic statements were written for C++. There, an abstract base class declares a contract, and the compiler refuses to create an object of any class that leaves part of that contract unimplemented. It does not check what the methods do; that part was always up to the programmer. JavaScript checks neither. An object fits wherever it has the methods a caller uses, and a mismatch shows up only when the code runs. The principles were stated for classes, but they apply as well to the modules and functions that carry much of a JavaScript design.
This article takes the principles in S-O-L-I-D order. Each section states the principle from its original sources, then shows a JavaScript example that breaks it, a version that follows it, and what changes when interfaces are implicit. Every sample runs as is on Node.js 24. The article closes with when to break the rules and a checklist for code reviews.
Where SOLID Came From
Two of the five are older than the rest. Bertrand Meyer coined the open-closed principle in his 1988 book Object-Oriented Software Construction. Barbara Liskov described substitutability in a keynote at the OOPSLA conference in 1987. Liskov, who in 1968 became one of the first women in the United States to earn a PhD in computer science, received the 2008 ACM Turing Award.
Robert C. Martin wrote up four of the principles in 1996, in his column for The C++ Report. The single responsibility principle came out of the same period. In a 2014 post, Martin traces it to David Parnas and to the work on coupling and cohesion by Larry Constantine, Tom DeMarco, and Meilir Page-Jones. I wrote about Parnas and Page-Jones in an earlier article on foundational principles.
Martin’s Principles of OOD page lists the five together as “principles of class design.” The acronym came later. It is generally credited to Michael Feathers, around 2004.
Single Responsibility: One Reason to Change
On his Principles of OOD page, Martin states the principle in one line: “A class should have one, and only one, reason to change.” His book chapter on the principle offers a test: “If you can think of more than one motive for changing a class, then that class has more than one responsibility.” The 2014 post adds where those motives come from: “It is people who request changes.”
The principle, then, does not count what a class does. It counts the groups of people who might ask you to change it. Here is a class that answers to two such groups. It creates users and sends each new user a welcome email:
// One class, two reasons to change
class UserManager {
#users = new Map();
createUser(name, email) {
if (!name.trim()) throw new Error("Name is required");
if (!email.includes("@")) throw new Error("Email is invalid");
const user = { id: this.#users.size + 1, name, email };
this.#users.set(user.id, user);
this.sendWelcomeEmail(user);
return user;
}
sendWelcomeEmail(user) {
const text = `Hi ${user.name}, welcome aboard!`;
console.log(`To ${user.email}: ${text}`);
}
}
// Prints: To ada@example.com: Hi Ada, welcome aboard!
new UserManager().createUser("Ada", "ada@example.com");
Both jobs happen at sign-up, so they seem to belong together. They change for different reasons, though. When the team that owns accounts changes the rules for a valid one, someone edits this class. When marketing rewrites the welcome message or decides to send it a day later, someone edits it again, for a reason that has nothing to do with accounts. Each edit puts the other job at risk, and a test of the account rules cannot create a user without also sending an email.
Splitting the class along those lines gives each reason to change its own home:
// users.js: changes when the rules for accounts change
class UserRegistry {
#users = new Map();
create(name, email) {
if (!name.trim()) throw new Error("Name is required");
if (!email.includes("@")) throw new Error("Email is invalid");
const user = { id: this.#users.size + 1, name, email };
this.#users.set(user.id, user);
return user;
}
}
// welcome-email.js: changes when the message changes
class WelcomeEmail {
send(user) {
const text = `Hi ${user.name}, welcome aboard!`;
console.log(`To ${user.email}: ${text}`);
}
}
// sign-up.js: changes when the sign-up steps change
function signUp(registry, welcomeEmail, name, email) {
const user = registry.create(name, email);
welcomeEmail.send(user);
return user;
}
// Prints: To ada@example.com: Hi Ada, welcome aboard!
const registry = new UserRegistry();
signUp(registry, new WelcomeEmail(), "Ada", "ada@example.com");
Account rules now live in UserRegistry, the message in WelcomeEmail, and the order of the steps in signUp. Each part can change and be tested alone. The comments name the module each part would live in; the sample keeps them in one file so that it runs as is. Note that signUp receives its collaborators instead of creating them; that matters again under dependency inversion.
Where to draw the lines is a judgment call. Is validation part of creating a user, or a third responsibility? Reasonable developers disagree. I kept it in the registry, because the rules for a valid account change along with the rules for creating one. The principle can also be taken too far. Split every behavior into its own file, and you get a system in which each part is clean and the whole is impossible to follow.
In JavaScript, the unit to watch is often the module or the function rather than the class. A utils.js file that formats dates, calls an API, and validates forms can have three reasons to change and no class at all. So can a React component that fetches its data, reshapes it, and renders it: the API, the business rules, and the visual design can each force an edit. A custom hook can take over the fetching; the React documentation names fetching data as one of the jobs custom hooks are for.
Two habits help. First, listen to how you describe a module: “This does X and Y” is a warning. Second, read its history. This command prints the subject of every commit that touched a file:
git log --format=%s -- users.js
If the subjects fall into unrelated groups, so do the reasons to change. When you find a violation, split it gradually as you work in that code, not in one big refactoring.
Open-Closed: Extend Without Editing
In Martin’s 1996 paraphrase of Meyer, the principle reads: “Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.” The same article states the goal: “When requirements change, you extend the behavior of such modules by adding new code, not by changing old code that already works.”
The usual violation is a conditional that grows a branch for every new case. Here the discount depends on the customer type.
// Every new customer type means editing this function
function discountFor(order) {
switch (order.customer.type) {
case "regular":
return order.total * 0.01;
case "premium":
return order.total * 0.1;
case "vip":
return order.total * 0.2;
default:
return 0;
}
}
const order = { customer: { type: "premium" }, total: 100 };
// Prints: 10
console.log(discountFor(order));
Adding a gold customer type means editing discountFor, and the edit puts every existing branch at risk. A version that follows the principle closes the calculation and makes the rules pluggable:
// Closed: this class does not change for a new customer type
class DiscountCalculator {
#rules = new Map();
register(customerType, rule) {
this.#rules.set(customerType, rule);
return this;
}
discountFor(order) {
const rule = this.#rules.get(order.customer.type);
return rule ? rule(order) : 0;
}
}
// Open: each rule is a plain function
const calculator = new DiscountCalculator()
.register("regular", (order) => order.total * 0.01)
.register("premium", (order) => order.total * 0.1)
.register("vip", (order) => order.total * 0.2);
// A new customer type is new code, not an edit
calculator.register("gold", (order) => order.total * 0.15);
const order = { customer: { type: "gold" }, total: 100 };
// Prints: 15
console.log(calculator.discountFor(order));
The calculator never changes for a new customer type. The registrations live where the program is assembled. A new type adds a line there and a rule with its own test, but it does not reopen the calculator’s code. The classic object-oriented version of this fix defines an abstract strategy class and a subclass for each rule. In JavaScript, a function can play that role by itself, so a Map of functions does the same job with less ceremony. Plugin systems are the same idea at a larger scale. “ESLint plugins extend ESLint with additional functionality,” its documentation says, and installing one does not change ESLint itself.
No module is closed against every change, and Martin said so in the same article: “It should be clear that no significant program can be 100% closed.” Closure, he concluded, “must be strategic”: the designer picks the kinds of change to close against. The calculator above is closed against new customer types but not against a change in how discounts combine. That is the right bet only if new customer types are the change you actually see. Add an extension point where variation has already appeared, not everywhere it might appear someday.
Liskov Substitution: Keep the Parent’s Promises
Liskov’s 1987 definition is formal. Type S is a subtype of type T if objects of S can stand in for objects of T without changing what any program written for T does. In 1994, she and Jeannette Wing developed it into a behavioral notion of subtyping. Martin summarized the practical rule in 1996: overriding methods “must expect no more than the corresponding member functions of the base class; and should promise no less.”
Martin’s 2020 restatement suits JavaScript best: “A program that uses an interface must not be confused by an implementation of that interface.” The same post adds a line for duck-typed languages: “All duck-types are subtypes of an implied interface.” Every caller assumes something about the objects it receives, and those assumptions are the contract, whether anyone wrote them down or not.
The textbook example is a square that inherits from a rectangle. Martin’s 1996 article shows how code that sets a width and then a height breaks when it receives a square. A simpler one makes the same point:
class Bird {
fly() {
return `${this.constructor.name} takes off`;
}
}
class Sparrow extends Bird {}
class Penguin extends Bird {
fly() {
throw new Error("Penguins cannot fly");
}
}
// Written against Bird, and correct for every bird it has met
function releaseAll(birds) {
return birds.map((bird) => bird.fly());
}
// Prints: [ 'Sparrow takes off' ]
console.log(releaseAll([new Sparrow()]));
try {
releaseAll([new Sparrow(), new Penguin()]);
} catch (error) {
// Prints: Penguins cannot fly
console.log(error.message);
}
releaseAll was correct for every bird it had met. The penguin did not add a feature; it broke a caller that had not changed. Liskov and Wing’s 1994 paper has a rule for exactly this case. Its exception rule says that a subtype’s method may not signal exceptions its parent’s method does not. The reason, in the paper’s words: “a caller of a method on a supertype object should not expect to handle an unknown exception.” Martin’s 1996 article explains why such bugs are hard to find: “the runtime error occurs very far away from the actual logic flaw.”
The penguin also breaks a guideline I follow separately: exceptions should be exceptional. Not flying is not exceptional for a penguin. It is normal, and normal behavior should not travel as an exception.
Two tempting repairs make it worse. One is to have Penguin.fly() do nothing. That breaks the same promise more quietly: releaseAll returns undefined for the penguin, and nothing reports a problem. The other repair is a type check in the caller, such as if (bird instanceof Penguin), which makes every caller know every subclass. Martin’s 2020 post predicts the result: “If an implementation confuses the user of the base type, then if/switch statements will proliferate.”
The repair that works makes each class promise only what it can deliver:
// Bird promises only what every bird can do
class Bird {
eat() {
return `${this.constructor.name} eats`;
}
}
// Only flying birds promise to fly
class FlyingBird extends Bird {
fly() {
return `${this.constructor.name} takes off`;
}
}
class Sparrow extends FlyingBird {}
class Penguin extends Bird {
swim() {
return `${this.constructor.name} dives`;
}
}
// Asks for exactly the promise it uses
function releaseAll(flyers) {
return flyers.map((bird) => bird.fly());
}
// Prints: [ 'Sparrow takes off' ]
console.log(releaseAll([new Sparrow()]));
// Prints: [ 'Sparrow eats', 'Penguin eats' ]
console.log([new Sparrow(), new Penguin()].map((b) => b.eat()));
Bird now promises only what every bird can do, and only FlyingBird promises to fly. Plain JavaScript still lets someone pass a penguin to releaseAll, and the call fails with TypeError: bird.fly is not a function. The difference is where the mistake lives: in the code that built the wrong list, not in a subclass that broke its parent’s word. If releaseAll declares its parameter as FlyingBird[] in TypeScript, the compiler rejects the penguin before the code runs.
In plain JavaScript, the parent’s tests are the closest thing to a written contract. Write them once, as a function that takes a factory for the object under test, and call it for every subclass. A subclass that breaks a promise they cover then fails the first time they run. Tests cover only what they check, so write the rest of the contract down too: what each method accepts, what it promises, and which errors it may throw.
When capabilities multiply, with birds that fly, swim, and dive in every combination, a deeper hierarchy gets awkward. Composing small roles, the subject of the next section, scales better.
Interface Segregation: Ask Only for What You Use
Martin’s 1996 statement reads: “Clients should not be forced to depend upon interfaces that they do not use.” In C++, the cost was concrete. When a fat interface changed, everything that depended on it had to be recompiled and redeployed, whether it used the changed method or not.
Plain JavaScript has no compile step to force, and Martin’s 2020 post grants the difference: “Dynamically typed languages are affected much less; but are still not immune.” At run time, a JavaScript function needs only the members it touches on the objects it receives, so the language gives you half of the principle for free. The other half lives in the contracts you write down: base classes that emulate interfaces, JSDoc types, and TypeScript interfaces. Here is the classic case, an emulated interface for office machines:
// One contract for every device, whatever it can do
class Machine {
print(doc) {
throw new Error("print() is not implemented");
}
scan() {
throw new Error("scan() is not implemented");
}
fax(doc, number) {
throw new Error("fax() is not implemented");
}
}
// A simple printer inherits two methods it cannot honor
class BasicPrinter extends Machine {
print(doc) {
return `Printing ${doc}`;
}
}
// A client that only prints, but asks for a whole Machine
/** @param {Machine} machine */
function printReports(machine, reports) {
return reports.map((report) => machine.print(report));
}
const printer = new BasicPrinter();
// Prints: [ 'Printing Q1' ]
console.log(printReports(printer, ["Q1"]));
try {
printer.scan();
} catch (error) {
// Prints: scan() is not implemented
console.log(error.message);
}
The base class makes every device promise to print, scan, and fax. A basic printer cannot keep two of those promises, so it inherits methods that throw; it is the penguin again. The client side shows the problem the principle is named for. printReports only prints, but its JSDoc type says it needs a whole Machine. Run TypeScript’s checker over the file with --checkJs, and a test double that has only a print method is rejected for lacking scan and fax. In TypeScript itself, a class that implements a three-method Machine interface must have all three methods, written or inherited.
The fix splits the contract into roles, each sized to the clients that use it:
// Small roles, one capability each
const homePrinter = {
print: (doc) => `Printing ${doc}`,
};
const flatbedScanner = {
scan: () => "Scanned page",
};
// A device with two roles is composed from them
const officeDevice = { ...homePrinter, ...flatbedScanner };
// Each client asks for the one role it uses
function printReports(printer, reports) {
return reports.map((report) => printer.print(report));
}
// Prints: [ 'Printing Q1', 'Printing Q2' ]
console.log(printReports(homePrinter, ["Q1", "Q2"]));
// Prints: [ 'Printing Q3' ]
console.log(printReports(officeDevice, ["Q3"]));
// Prints: Scanned page
console.log(officeDevice.scan());
A device with several roles is composed from them, and printReports asks only for something that can print, so it accepts both the home printer and the office device. Here each role happens to be a single method; in general, a role is the set of methods that one kind of client uses together. In TypeScript, write Printer and Scanner as two small interfaces. No implements clause is needed, because, in the words of the TypeScript handbook, “Type compatibility in TypeScript is based on structural subtyping.” Any object with a matching print method is a Printer.
One trap: the spread in officeDevice works because the roles are plain objects whose methods are stateless arrow functions. Spread and Object.assign copy only an object’s own enumerable properties, and the methods a class declares live on its prototype. Spread two class instances into one object, and their methods silently disappear. With classes, compose by delegation instead: keep each role in a field and forward calls to it.
The principle also applies to parameters. A function that receives the whole application object to call one method on it hides what it needs. Its readers must read its body to learn what it uses, and if its parameter is typed as the whole object, every test double must fake the whole object too. Pass the one role it needs instead.
Dependency Inversion: The Policy Owns the Contract
Martin’s 1996 statement has two parts: “High level modules should not depend upon low level modules. Both should depend upon abstractions.” And: “Abstractions should not depend upon details. Details should depend upon abstractions.” He explained the word inversion in the same article. Structured analysis and design tended to produce programs in which high-level modules depend on low-level ones, and a well-designed object-oriented program inverts that structure. The high-level modules hold the policy decisions that give an application its identity. In his words, “It is the high level modules that ought to be forcing the low level modules to change.”
Here a notifier, which decides what to tell a customer when an order ships, builds its own email sender:
// A low-level detail
class EmailSender {
send(address, text) {
console.log(`Email to ${address}: ${text}`);
}
}
// High-level policy: what to say when an order ships
class ShippingNotifier {
// The policy picks its own detail
#email = new EmailSender();
orderShipped(order) {
const text = `Order ${order.id} has shipped`;
this.#email.send(order.email, text);
}
}
// Prints: Email to ada@example.com: Order 42 has shipped
const order = { id: 42, email: "ada@example.com" };
new ShippingNotifier().orderShipped(order);
The policy depends on the detail. In a real codebase, EmailSender sits in its own module, and the notifier’s import line points from policy to infrastructure. To send a text message instead, or to test the notifier without sending mail, you have to edit the notifier. Inverting the dependency means the notifier states what it needs and the details conform:
/**
* The contract the notifier needs, declared by the notifier:
* @typedef {{ send(to: string, text: string): void }} Channel
*/
// High-level policy: what to say when an order ships
class ShippingNotifier {
#channel;
/** @param {Channel} channel */
constructor(channel) {
this.#channel = channel;
}
orderShipped(order) {
const text = `Order ${order.id} has shipped`;
this.#channel.send(order.contact, text);
}
}
// Low-level details conform to the policy's contract
const email = {
send: (to, text) => console.log(`Email to ${to}: ${text}`),
};
const sms = {
send: (to, text) => console.log(`SMS to ${to}: ${text}`),
};
// The entry point wires policy to details, in one place
const byEmail = new ShippingNotifier(email);
const bySms = new ShippingNotifier(sms);
// Prints: Email to ada@example.com: Order 42 has shipped
byEmail.orderShipped({ id: 42, contact: "ada@example.com" });
// Prints: SMS to +1-555-0100: Order 43 has shipped
bySms.orderShipped({ id: 43, contact: "+1-555-0100" });
// A test needs no mail server, only a fake
const sent = [];
const fake = { send: (to, text) => sent.push(text) };
new ShippingNotifier(fake).orderShipped({ id: 44, contact: "x" });
// Prints: [ 'Order 44 has shipped' ]
console.log(sent);
The notifier now depends on a contract it defines itself, the Channel type at the top: something with send(to, text). The email and SMS senders conform to that contract. So both sides depend on the abstraction, as Martin’s first rule asks, and the abstraction names no detail, as the second asks. Even the order changed shape. It carries a contact instead of an email, because the policy no longer knows which channel it uses.
The JSDoc @typedef is how plain JavaScript writes such a contract down, and, as with printReports, TypeScript run with --checkJs rejects a channel that has no send method. In TypeScript itself, export an interface from the notifier’s module instead. Either way, the abstraction here belongs to the policy that uses it, not to the library that implements it. That is the arrangement I would choose between business rules and infrastructure, but the principle allows others. In Martin’s 1996 article, the abstractions declared in C’s stdio.h serve both the program and the device drivers.
The last lines of the sample show the payoff: the fake channel takes one line, and the test needs no mail server. The signUp function from the first section gets the same benefit from receiving its collaborators.
One caveat: these channels are synchronous. A real email or SMS client returns a promise, and a contract whose send returns void accepts one without complaint. The notifier then returns at once, and a failed send surfaces later as an unhandled rejection, which ends a Node.js process by default. For a real transport, make send return a promise and await it.
Dependency inversion is easy to confuse with two related ideas. Dependency injection is the technique the corrected notifier uses to receive its channel. Martin Fowler’s 2004 article explains where the name came from: after a lot of discussion with various advocates of inversion of control, “we settled on the name Dependency Injection.” Injection alone does not invert anything. A notifier that receives an EmailSender and calls email-specific methods on it is injected, and it is still shaped by its detail.
A dependency injection container, such as Awilix, InversifyJS, or tsyringe, automates the wiring: it builds the objects and passes each one its dependencies. It helps when the object graph is large, but it is not the principle. The example above follows the principle with no container at all, by wiring everything in one place at the entry point.
In React, context can do the same job without a library. It “lets the parent component make some information available to any component in the tree below it.” A provider near the top can hand components a real API client in the app and a fake one in tests.
Principles, Not Laws
Martin himself does not present the principles as laws. In a 2009 post on getting started with SOLID, he wrote: “The SOLID principles are not rules. They are not laws. They are not perfect truths.” He called them heuristics. He also warned about the opposite mistake: “If they are applied by rote it is just as bad as if they are not applied at all.”
That is why I value them most as a vocabulary. They let an author and a reviewer name a design worry precisely, like the comment that opens this article, and decide whether the trade is worth it. Sometimes it is. A short script does not need a strategy registry, and a split that scatters one feature across a dozen files costs more than the class it replaced.
The sharpest critique I know comes from Dan North. He once gave a tongue-in-cheek talk at PubConf titled “Why Every Single Element of SOLID Is Wrong.” Martin answered its slides in 2020, in a post called Solid Relevance. He agreed with North’s advice to write simple code and argued that the principles are how you get there. “The SRP is one of the ways we keep the code simple,” he wrote.
North’s full answer came in 2021 and 2022, as a back story and then an alternative called CUPID. His objection is to rules as such: “Principles are like rules: you are either compliant or you are not.” Of SOLID itself, he writes: “I see a mix of things that were once good advice, patterns that apply in a context, and advice that is easy to misapply.” CUPID offers five properties of code instead, each a direction to move in rather than a line to cross:
- Composable: “plays well with others”
- Unix philosophy: “does one thing well”
- Predictable: “does what you expect”
- Idiomatic: “feels natural”
- Domain-based: “the solution domain models the problem domain in language and structure”
North would push back on some of the splits in this article. Separating a UI component’s rendering from its business logic, he writes, “leads to an administrative chore of chaining identical fields together.” The warning is fair, and it is why I would let the change history decide: split where changes actually arrive separately, not by habit.
I read the two as complementary. CUPID describes what good code feels like to work in, and its emphasis on joy in programming is something we could all use more of. SOLID names specific ways code goes wrong, which makes it the sharper tool in a code review. My Software Development Principles series gives each of the five principles an entry, with signs of correct use and of violation.
A Review Checklist
- Single responsibility: Can you describe the module without “and”? Do the commit subjects in its history fall into one group?
- Open-closed: Did the last new variant require editing a
switchor anifchain? Is there an extension point where variation has already appeared, and none where it has not? - Liskov substitution: Does any subclass throw an error its parent does not, skip an effect its parent promises, or demand more than its parent? Does any caller check
instanceofto protect itself? Do the parent’s tests pass against every subclass? - Interface segregation: Does any class inherit or stub methods it cannot honor? Does any function take a whole object to use one method of it? Are your TypeScript interfaces sized to their callers?
- Dependency inversion: Does business logic construct or import its own infrastructure? Can you test it with a fake in a few lines? Does the wiring happen in one place?
- All five: When a review cites a principle, answer with one of two sentences. Either “This is not a violation, because…” or “This breaks it, and here is what we gain.”
Further Reading
- The Open-Closed Principle, The Liskov Substitution Principle, The Dependency Inversion Principle, and The Interface Segregation Principle (Robert C. Martin, The C++ Report, 1996)
- The Single Responsibility Principle (Robert C. Martin, 8 May 2014)
- Solid Relevance (Robert C. Martin, 18 October 2020)
- Data Abstraction and Hierarchy (Barbara Liskov, OOPSLA keynote, 1987; SIGPLAN Notices, May 1988)
- A Behavioral Notion of Subtyping (Barbara H. Liskov and Jeannette M. Wing, ACM Transactions on Programming Languages and Systems, November 1994)
- Inversion of Control Containers and the Dependency Injection Pattern (Martin Fowler, 23 January 2004)
- CUPID: For Joyful Coding (Dan North, 10 February 2022)