Skip to content
wannacode

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));
Output
2026-0142;80.00;16.00;96.00
Hi, Maria! Order 2026-0142: €96.00

Marketing 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);
}
Output
2026-0142;80;16;96
Hi, Maria! Order 2026-0142: €96

The 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));
Output
2026-0142;80.00;16.00;96.00
Hi, Maria! Order 2026-0142: €96

The 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

1. Which question tells you whether a class has one reason to change?
2. ShippingLabel computes the delivery price (sales asks for it), draws the label (the format belongs to the courier company) and writes the warehouse line (the warehouse asks for it). How do you arrange it?

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)}`);
}

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}`);
Output
Card: refunded €60.00
Error: a voucher can't be refunded
Status: paid

The 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
  1. The order was paid three ways: €60.00 by card, a €20.00 voucher and €16.00 in points. All three are Payment.
  2. The customer cancels. cancel() goes through the payments and calls refund() on each one.
  3. The card gives back €60.00. It does exactly what Payment promises.
  4. The voucher is a Payment too, but its refund() throws Error. The promise is broken.
  5. The exception leaves the loop and cancel(). The points (€16.00) are never refunded, and the line that sets status to "cancelled" never runs.
  6. The catch in the program catches it and prints the error. The program goes on.
  7. 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}`);
Output
Card: refunded €60.00
Voucher: new voucher for €20.00
Points: refunded €16.00
Status: cancelled

cancel() 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.
The language sees none of the three, and without an interface in JavaScript the promise lives only in the method name and the comment. So it has to be written down and checked with a test.

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
The language has such a case too. A frozen array is still an 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}`);
}
Output
TypeError: Cannot add property 2, object is not extensible
In a module (and with "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)}`);
Answer

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).
The email provider only sends emails. The shipping code calls 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.");
Output
Email to maria@example.com: Order 2026-0142 has shipped.

Which line has the bug?

Pick the line with the bug.

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 only sms, 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");
Output
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}`);
}
Output
TypeError: this.sender.sms is not a function

Small 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");
Output
SMTP to maria@example.com: Invoice 2026-0142: €96.00

It 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]}`);
Output
SMTP to maria@example.com: Invoice 2026-0142: €96.00
The test saw: maria@example.com | Invoice 2026-0142: €96.00

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);
}
}
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
  1. Before, the invoices pointed down, at SmtpMailer. Now the service writes down what it needs: an object with send(to, text).
  2. InvoiceService gets the sender in its constructor and knows only send(to, text). It knows nothing about SMTP.
  3. SmtpSender has send(to, text), the contract the service described. The arrow points up: the detail depends on the rule. That is the inversion.
  4. 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.
  5. 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.
  6. 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, InvoiceService gets SmtpSender, 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
In a real project 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:

  1. const service = new InvoiceService(fake);
  2. service.issue(order, "maria@example.com");
  3. console.log(`The test saw: ${fake.sent[0]}`);
  4. const fake = new FakeSender();

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)}`);
Output
VAT: €16.00

And the same without dogma:

const VAT_RATE = 0.2;
console.log(`VAT: €${(80 * VAT_RATE).toFixed(2)}`);
Output
VAT: €16.00

The 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.

Output
Card: refunded €40.00
Gift card: €25.00 back on the card
Status: cancelled

More 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

1. Marketing asks for a change in the email text, and after it the accounting file breaks. Which principle is broken?
2. The shop adds delivery to a parcel locker. What does "closed for modification" mean?
3. VoucherPayment is a Payment, but refund throws a "can't be refunded" error. What happens to code that calls refund on every payment?
4. The email provider has an empty sms method, because the code calls sms on everyone who sends notifications. What does that tell you?
5. InvoiceService does self.mailer = SmtpMailer() (this.mailer = new SmtpMailer()) in its constructor. What does it break?
6. When is an interface with one implementation worth it?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·