SOLID without dogma
In short: SOLID is five principles for code that survives change: one reason to change, extension without modification, substitutable subtypes, small interfaces and depending on abstractions.
Problem
The email change that broke accounting
The online shop emails the customer an invoice. Every evening it also gives accounting a file with a line for each order. One class does both:
class Order { constructor(number, customer, net) { this.number = number; this.customer = customer; this.net = net; }}class InvoiceService { vat(order) { return order.net * 0.2; }
// Accounting: a line for the file it imports into its software. exportLine(order) { const total = order.net + this.vat(order); return [order.number, this.#money(order.net), this.#money(this.vat(order)), this.#money(total)].join(";"); }
// The customer: the text of the email with the invoice. emailText(order) { const total = order.net + this.vat(order); return `Hi, ${order.customer}! Order ${order.number}: €${this.#money(total)}`; }
#money(amount) { return amount.toFixed(2); }}const invoices = new InvoiceService();const order = new Order("2026-0142", "Maria", 80);console.log(invoices.exportLine(order));console.log(invoices.emailText(order));2026-0142;80.00;16.00;96.00Hi, Maria! Order 2026-0142: €96.00Marketing says: "In the email, make the amount €96, not €96.00. It looks cleaner." Someone changes the helper method #money:
#money(amount) { if (Number.isInteger(amount)) { return String(amount); } return amount.toFixed(2);}2026-0142;80;16;96Hi, Maria! Order 2026-0142: €96The email is what they wanted. The next morning the accounting software rejects the file: it expects 80.00 and gets 80. Nobody touched the accounting line. What changed is the helper method the two lines share. The email test passes, and there is no test for the file, because "nobody changed it".
The idea
One reason to change
The class serves two parties who don't know each other. The accountant needs an exact format that her software reads. Marketing wants a pleasant text for the customer. They ask for changes at different times and for different reasons. As long as their code lives in one class, a change for one can break the other.
This is the first of the five SOLID principles (collected by Robert Martin; the name comes from their first letters). The Single Responsibility Principle (SRP): a class has one reason to change.
"Reason" means who asks for the change: a person, a department, a law. It does not mean "one method", and it does not mean "a small class". The question to ask yourself: "Who will come and ask for a change here?" If the answer is "accounting or marketing", that is two classes.
See it
Each one gets its own class
The VAT and the total are a rule of the tax law, and both parties need them. They go to the order, in one place:
class Order { constructor(number, customer, net) { this.number = number; this.customer = customer; this.net = net; }
vat() { return this.net * 0.2; }
total() { return this.net + this.vat(); }}The accounting line and the email text become separate classes. Each one formats amounts its own way:
class AccountingExport { line(order) { return [order.number, order.net.toFixed(2), order.vat().toFixed(2), order.total().toFixed(2)].join(";"); }}
class InvoiceEmail { text(order) { return `Hi, ${order.customer}! Order ${order.number}: €${this.#money(order.total())}`; }
#money(amount) { if (Number.isInteger(amount)) { return String(amount); } return amount.toFixed(2); }}const order = new Order("2026-0142", "Maria", 80);console.log(new AccountingExport().line(order));console.log(new InvoiceEmail().text(order));2026-0142;80.00;16.00;96.00Hi, Maria! Order 2026-0142: €96The email has whole amounts, and the file still has two decimals. Tomorrow accounting will change the format, and you will touch only AccountingExport. The email code is not even in front of you.
The two ways of writing an amount are no longer shared. That is not duplication: they look alike today, but they are two rules that change for different reasons.
When it hurts. The other extreme is a class for every action: VatCalculator, TotalCalculator, NumberFormatter, GreetingBuilder, three lines each. They always change together, because the same person asks for them, and you open four files to read one rule. A 200-line class that serves one person is better than five classes you always open together. What changes together stays together.
More precisely, if you are curious
- Robert Martin himself writes that "one responsibility" is often read as "does one thing". His later clarification is that a module answers to one actor: a group of people who ask for changes. "Does one thing" is a rule for functions, not for classes.
- "Don't repeat yourself" (DRY) is about knowledge, not text. Two pieces of code that look the same but change for different reasons are not duplication. If you merge them into one helper method, you tie two people together with one knot. That is exactly what happened in the problem.
Try it
Who asks for the change
See it
A new delivery without touching the total
The second principle is open/closed (Open/Closed Principle, OCP): code is open for extension and closed for modification. A new variant comes as new code, and the code that already works and is tested is not touched.
You saw it in the Strategy lesson: the chain of ifs for the payment method became a table name → strategy. Deliveries are the same. Each delivery knows its price:
function toAddress(amount) { return amount >= 100 ? 0 : 5.9;}
function toOffice(amount) { return 3.9;}The total does not know which deliveries exist:
function orderTotal(amount, delivery) { return amount + delivery(amount);}The shop adds delivery to a parcel locker. It is a new function:
function toLocker(amount) { return 2.9;}orderTotal, the delivery to an address and to an office are not touched. What remains is to put the new delivery in the table the customer chooses from. That is the next card.
When it hurts. You can't close the code against everything, and you shouldn't. Every extension point is a function and a row in a table that someone has to read. You close it where changes really come: new deliveries arrive every season. The rule of thumb: the first time, write an if. When the third variant arrives, make the table (Replace Conditional with Polymorphism from the lesson on refactoring).
Try it
A new row in the table
The table from the Strategy lesson lists the deliveries the customer chooses from. Add the locker.
What goes in the blank?
const deliveries = new Map([
["address", toAddress],
["office", toOffice],
["locker", ],
]);
for (const [choice, delivery] of deliveries) {
console.log(`${choice}: €${orderTotal(80, delivery).toFixed(2)}`);
}toLocker, without parentheses. The table holds the function itself. With parentheses it would hold its result (2.9), and the total would have nothing to call.address: €85.90office: €83.90locker: €82.90Only the table changed, and all it does is list. OCP does not mean "not a single changed line". It means the rules that already work and are tested are not touched for every new variant.
Problem
The cancellation that stopped halfway
A customer pays for an order three ways: €60 by card, a €20 voucher and €16 in points. Then she cancels it. Each kind of payment knows how to give its money back:
// Every payment has refund(): it gives the money back to the customer the same way they paid.class Payment { constructor(amount) { this.amount = amount; }}
class CardPayment extends Payment { refund() { console.log(`Card: refunded €${this.amount.toFixed(2)}`); }}
class VoucherPayment extends Payment { refund() { throw new Error("a voucher can't be refunded"); }}
class PointsPayment extends Payment { refund() { console.log(`Points: refunded €${this.amount.toFixed(2)}`); }}The order goes through its payments and calls refund() on each one:
class Order { constructor(payments) { this.payments = payments; this.status = "paid"; }
cancel() { for (const payment of this.payments) { payment.refund(); } this.status = "cancelled"; }}const order = new Order([ new CardPayment(60), new VoucherPayment(20), new PointsPayment(16),]);try { order.cancel();} catch (e) { console.log(`Error: ${e.message}`);}console.log(`Status: ${order.status}`);Card: refunded €60.00Error: a voucher can't be refundedStatus: paidThe card is refunded, the points are not, and the order is still "paid". The warehouse sees "paid" and ships the parcel. The code of cancel() is correct. The bug is in the voucher: it is a Payment, but it does not do what Payment promises.
See it
The subtype that breaks the promise
cancel() { for (const payment of this.payments) { payment.refund(); } this.status = "cancelled";}
const order = new Order([ new CardPayment(60), new VoucherPayment(20), new PointsPayment(16),]);try { order.cancel();} catch (e) { console.log(`Error: ${e.message}`);}console.log(`Status: ${order.status}`);Step by step, in words
- The order was paid three ways: €60.00 by card, a €20.00 voucher and €16.00 in points. All three are Payment.
- The customer cancels. cancel() goes through the payments and calls refund() on each one.
- The card gives back €60.00. It does exactly what Payment promises.
- The voucher is a Payment too, but its refund() throws Error. The promise is broken.
- The exception leaves the loop and cancel(). The points (€16.00) are never refunded, and the line that sets status to "cancelled" never runs.
- The catch in the program catches it and prints the error. The program goes on.
- The status is still "paid". The warehouse ships the order, and €60.00 is already back on the card.
This is the third principle, Barbara Liskov's substitution principle (Liskov Substitution Principle, LSP): wherever the code works with a type, any subtype can stand in, and the code won't notice. The subtype can do the job its own way, but it keeps the promise of the type.
Payment promises "I give the customer the money back". What does that mean for a voucher in a real shop? A new voucher for the same amount:
class VoucherPayment extends Payment { refund() { console.log(`Voucher: new voucher for €${this.amount.toFixed(2)}`); }}const order = new Order([ new CardPayment(60), new VoucherPayment(20), new PointsPayment(16),]);try { order.cancel();} catch (e) { console.log(`Error: ${e.message}`);}console.log(`Status: ${order.status}`);Card: refunded €60.00Voucher: new voucher for €20.00Points: refunded €16.00Status: cancelledcancel() did not change. The subtype now fits where the type is expected.
You recognize a violation by three signs:
- the subtype throws "not supported" (an error) for a method the type promises;
- the subtype returns something the type does not promise (
-1,null, an empty string), or asks more from the caller; - the code checks instanceof before calling the method. That is a detour around a subtype that doesn't fit.
When it hurts. Here the dogma goes the other way: forcing something into the hierarchy because "it is a payment too". If something really can't give money back (a gift from the shop, for example), it is not a Payment with refund. Give it its own type instead of writing a method that throws.
More precisely, if you are curious
Array (Array.isArray says true), but it does not take push:const items = Object.freeze(["coffee", "mug"]);try { items.push("tea");} catch (e) { console.log(`${e.name}: ${e.message}`);}TypeError: Cannot add property 2, object is not extensible"use strict") the error is a TypeError. Outside strict mode push still throws, but assigning an element silently does nothing.Formally, after Liskov: a subtype asks no more than the type (does not strengthen the conditions on input), gives no less (does not weaken the promise about the result), and keeps what is always true for the type.
Try it
The silent violation
Delivery to a locker returns -1 when the locker is full. The total adds it, as always. What does the program print?
function toLocker(full) { return (amount) => { if (full) { return -1; // -1 means "no space" } return 2.9; };}
console.log(`€${orderTotal(80, toLocker(true)).toFixed(2)}`);€79.00. The total is written for the promise "the price of the delivery, 0 or more". It adds 80 and -1, and the customer pays €1 less for a delivery that can't happen. Nothing throws, which makes this violation more dangerous than the exception. The fix: a full locker is not a price. The full locker doesn't go into the table of deliveries the customer can choose.
Problem
The SMS that never went out
The shop sends notifications to customers. Someone described everything that can be sent in one place:
// Notifications: everyone who sends notifications has email(to, text) and sms(phone, text).notifier.sms(...), and without the method the program stops with a TypeError. So someone wrote it.Try it
Find the bug
The customer gets an email that the parcel has shipped. The courier SMS never comes:
const notifier = new MailProvider();notifier.email("maria@example.com", "Order 2026-0142 has shipped.");notifier.sms("0888 123 456", "The courier comes today between 2 and 4 pm.");Email to maria@example.com: Order 2026-0142 has shipped.Which line has the bug?
Pick the line with the bug.
The SMS method is empty. It is a lie nobody hears: the code calls it, nothing goes out, and there is no error. The provider wrote it because the code calls sms on everyone who sends notifications. The empty method breaks LSP too: the promise "I send an SMS" is not kept. The real fix is not on this line but in the contract: the next card.
See it
Small interfaces
The fourth principle: interface segregation (Interface Segregation Principle, ISP). Nobody should depend on methods they don't use. The contract is written from the side of whoever uses it, and it is as big as they need.
JavaScript has no interfaces. The small contract is what the code calls: the courier notice calls onlysms, so any object with sms fits. Here the principle means: don't ask for more than you call, and don't make anyone write empty methods.Each provider does only what it can, and the courier notice asks only for SMS:
class MailProvider { email(to, text) { console.log(`Email to ${to}: ${text}`); }}
class SmsGateway { sms(phone, text) { console.log(`SMS to ${phone}: ${text}`); }}
class ShippingNotice { constructor(sender) { this.sender = sender; }
courierToday(phone) { this.sender.sms(phone, "The courier comes today between 2 and 4 pm."); }}const email = new MailProvider();email.email("maria@example.com", "Order 2026-0142 has shipped.");new ShippingNotice(new SmsGateway()).courierToday("0888 123 456");Email to maria@example.com: Order 2026-0142 has shipped.SMS to 0888 123 456: The courier comes today between 2 and 4 pm.If someone passes the email provider where an SMS is needed, the program stops on the first call instead of staying silent:
try { new ShippingNotice(new MailProvider()).courierToday("0888 123 456");} catch (e) { console.log(`${e.name}: ${e.message}`);}TypeError: this.sender.sms is not a functionSmall interfaces help tests too: the double for the notice implements one method, not everything the shop can send.
When it hurts. An interface for every method "because of ISP" is the other extreme: Readable, Writable, Deletable, Countable in front of a repository that is used in one place and always as a whole. If everyone who uses the contract uses all of it, it is one contract.
Problem
The test that sends a real email
The invoice goes out by email. The service makes its own mailer:
class SmtpMailer { send(to, text) { console.log(`SMTP to ${to}: ${text}`); }}
class InvoiceService { constructor() { this.mailer = new SmtpMailer(); }
issue(order, email) { const total = order.total().toFixed(2); const text = `Invoice ${order.number}: €${total}`; this.mailer.send(email, text); }}const order = new Order("2026-0142", "Maria", 80);new InvoiceService().issue(order, "maria@example.com");SMTP to maria@example.com: Invoice 2026-0142: €96.00It works, but:
- the invoice test sends a real email, and on a machine without an SMTP server it fails;
- the shop moves from SMTP to a service with an API, and you edit the class with the invoice rules to change the mailer.
The rule (what goes on the invoice) depends on the detail (how the email goes out).
See it
The abstraction belongs to the rule
The fifth principle: dependency inversion (Dependency Inversion Principle, DIP). The code with the rules does not depend on the details. Both depend on an abstraction, and it belongs to the rules: it says what they need ("send this text to this address"), not how SMTP does it.
class InvoiceService { // sender is any object with a send(to, text) method. constructor(sender) { this.sender = sender; }
issue(order, email) { const total = order.total().toFixed(2); const text = `Invoice ${order.number}: €${total}`; this.sender.send(email, text); }}The details are separate. The mailer and the test double have the method the service calls:
class SmtpSender { send(to, text) { console.log(`SMTP to ${to}: ${text}`); }}
class FakeSender { constructor() { this.sent = []; }
send(to, text) { this.sent.push(`${to} | ${text}`); }}const order = new Order("2026-0142", "Maria", 80);new InvoiceService(new SmtpSender()) .issue(order, "maria@example.com");
const fake = new FakeSender();const service = new InvoiceService(fake);service.issue(order, "maria@example.com");console.log(`The test saw: ${fake.sent[0]}`);SMTP to maria@example.com: Invoice 2026-0142: €96.00The test saw: maria@example.com | Invoice 2026-0142: €96.00class InvoiceService { // sender is any object with a send(to, text) method. constructor(sender) { this.sender = sender; }
issue(order, email) { const total = order.total().toFixed(2); const text = `Invoice ${order.number}: €${total}`; this.sender.send(email, text); }}
class SmtpSender { send(to, text) { console.log(`SMTP to ${to}: ${text}`); }}
const order = new Order("2026-0142", "Maria", 80);new InvoiceService(new SmtpSender()) .issue(order, "maria@example.com");
const fake = new FakeSender();const service = new InvoiceService(fake);service.issue(order, "maria@example.com");console.log(`The test saw: ${fake.sent[0]}`);Step by step, in words
- Before, the invoices pointed down, at SmtpMailer. Now the service writes down what it needs: an object with send(to, text).
- InvoiceService gets the sender in its constructor and knows only send(to, text). It knows nothing about SMTP.
- SmtpSender has send(to, text), the contract the service described. The arrow points up: the detail depends on the rule. That is the inversion.
- At run time the call still goes down: from the service to SMTP. The dependency in the code is inverted, not the direction of the call.
- In the test the same service gets FakeSender: a second detail that points at the same abstraction. No email goes out: the double writes down what it got in sent.
- The test sees exactly the text of the invoice, without a mail server.
The "inversion" is of the arrow in the code. Before, the invoices knew about SMTP. Now SMTP knows about the send(to, text) contract, which sits next to the invoices. At run time the call still goes from the invoices to SMTP.
DIP and DI. In the lesson Singleton and Dependency Injection you saw passing from outside: the object gets its dependency in the constructor. That is the mechanism. DIP says what to depend on: an abstraction that is yours. You can have one without the other:
-
passing without DIP: the service gets the mailer from outside, but its file still imports
import { SmtpMailer } from "./mail.js"and calls something only SMTP has; - DIP without a DI library: in the program,
InvoiceServicegetsSmtpSender, and that is all the wiring. There is one place where the program puts the objects together.
When it hurts. An interface in front of every class "because of DIP" is the most common dogma. An abstraction is worth it in front of a detail that is slow, external or going to change: mail, payment, database, clock, network. In front of Order or in front of formatting an amount it is code to read with no benefit.
More precisely, if you are curious
invoices.js does not import smtp.js. main.js imports both and passes new SmtpSender() to the service. There is no abstraction as a type: it is the method send(to, text), written in the comment and checked by the test with the double.Try it
Put the test in order
The test checks the invoice without a real mailer. The order already exists. Put the lines in order:
const service = new InvoiceService(fake);service.issue(order, "maria@example.com");console.log(`The test saw: ${fake.sent[0]}`);const fake = new FakeSender();
The double comes first, because the service wants it in its constructor. Then the invoice is issued, and only then is there something to check. The output: The test saw: maria@example.com | Invoice 2026-0142: €96.00
In short
When SOLID hurts
Here is the VAT, written "by all the rules":
class StandardVat { vat(net) { return net * 0.2; }}
class TaxPolicyFactory { static create() { return new StandardVat(); }}console.log(`VAT: €${TaxPolicyFactory.create().vat(80).toFixed(2)}`);VAT: €16.00And the same without dogma:
const VAT_RATE = 0.2;console.log(`VAT: €${(80 * VAT_RATE).toFixed(2)}`);VAT: €16.00The output is the same. The first is a class and a factory for a number that changes once every ten years, and in one place anyway. That is the smell called speculative generality: code for changes nobody asked for. When a second rate arrives (9% for food), that is the moment for a table.
The principles are questions, not rules. Ask them when the code hurts:
| The question | The dogma to avoid | |
|---|---|---|
| SRP | Who will ask for a change here? One party or two? | A class for every method |
| OCP | What really comes new every month? | Extension points everywhere |
| LSP | Can every subtype stand here without an if? |
A hierarchy because of the word "is" |
| ISP | Does this client use everything it asks for? | An interface for every method |
| DIP | Is the detail slow, external or replaceable? | An interface in front of every class |
Exercise
A gift card without touching the cancellation
Solve the task in your editor.
Gift card. The shop also takes gift cards. On cancellation the money goes back onto the card. Add a GiftCardPayment that keeps the promise of Payment, and cancel an order paid with €40 by card and €25 by gift card. cancel() and the other payments are not touched.
Card: refunded €40.00Gift card: €25.00 back on the cardStatus: cancelledMore tasks
- The monthly report. A class computes the month's revenue, writes a CSV for accounting and a text for the manager, and sends the email itself. Split it by who asks for the changes. What stays shared?
- Push notification. Add a notification in the mobile app when the parcel has shipped. Which small contract do you need, and which classes do you not touch?
- Remove one abstraction. Find in your own project a class or function wrapped "just in case" with no slow or external detail behind it. Remove the wrapper and run the tests.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Singleton and Dependency Injection · Functions: one job, few parameters, no hidden side effects · Code smells: long method, large class, duplication, primitives instead of types, long if chains · Refactoring in small steps
- Continues in: Exercise: a messy class, cleaned up step by step · Layers · Hexagonal and clean architecture · Monolith, modular monolith, services
- Tasks on this topic: Clean code and refactoring