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:

record Order(String number, String customer, double net) {}
class InvoiceService {
public double vat(Order order) {
return order.net() * 0.20;
}
// Accounting: a line for the file it imports into its software.
public String exportLine(Order order) {
double total = order.net() + vat(order);
return String.join(";", order.number(), money(order.net()), money(vat(order)), money(total));
}
// The customer: the text of the email with the invoice.
public String emailText(Order order) {
double total = order.net() + vat(order);
return String.format("Hi, %s! Order %s: €%s", order.customer(), order.number(), money(total));
}
private String money(double amount) {
return String.format("%.2f", amount);
}
}
InvoiceService invoices = new InvoiceService();
Order order = new Order("2026-0142", "Maria", 80);
System.out.println(invoices.exportLine(order));
System.out.println(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:

private String money(double amount) {
if (amount == Math.floor(amount)) {
return String.valueOf((long) amount);
}
return String.format("%.2f", amount);
}
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:

record Order(String number, String customer, double net) {
public double vat() {
return net * 0.20;
}
public double total() {
return net + vat();
}
}

The accounting line and the email text become separate classes. Each one formats amounts its own way:

class AccountingExport {
public String line(Order order) {
return String.format("%s;%.2f;%.2f;%.2f", order.number(), order.net(), order.vat(), order.total());
}
}
class InvoiceEmail {
public String text(Order order) {
return String.format("Hi, %s! Order %s: €%s", order.customer(), order.number(), money(order.total()));
}
private String money(double amount) {
if (amount == Math.floor(amount)) {
return String.valueOf((long) amount);
}
return String.format("%.2f", amount);
}
}
Order order = new Order("2026-0142", "Maria", 80);
System.out.println(new AccountingExport().line(order));
System.out.println(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:

interface Delivery {
double cost(double amount);
}
class ToAddress implements Delivery {
@Override
public double cost(double amount) {
return amount >= 100 ? 0 : 5.90;
}
}
class ToOffice implements Delivery {
@Override
public double cost(double amount) {
return 3.90;
}
}

The total does not know which deliveries exist:

static double orderTotal(double amount, Delivery delivery) {
return amount + delivery.cost(amount);
}

The shop adds delivery to a parcel locker. It is a new class:

class ToLocker implements Delivery {
@Override
public double cost(double amount) {
return 2.90;
}
}

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 an interface, a class 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?

Map<String, Delivery> deliveries = Map.of(
        "address", new ToAddress(),
        "office", new ToOffice(),
        "locker", );

for (String choice : List.of("address", "office", "locker")) {
    System.out.printf("%s: €%.2f%n", choice, orderTotal(80, deliveries.get(choice)));
}

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:

interface Payment {
// Gives the money back to the customer the same way they paid.
void refund();
}
record CardPayment(double amount) implements Payment {
@Override
public void refund() {
System.out.printf("Card: refunded €%.2f%n", amount);
}
}
record VoucherPayment(double amount) implements Payment {
@Override
public void refund() {
throw new UnsupportedOperationException("a voucher can't be refunded");
}
}
record PointsPayment(double amount) implements Payment {
@Override
public void refund() {
System.out.printf("Points: refunded €%.2f%n", amount);
}
}

The order goes through its payments and calls refund() on each one:

class Order {
private final List<Payment> payments;
String status = "paid";
Order(List<Payment> payments) {
this.payments = payments;
}
void cancel() {
for (Payment payment : payments) {
payment.refund();
}
status = "cancelled";
}
}
Order order = new Order(List.of(
new CardPayment(60),
new VoucherPayment(20),
new PointsPayment(16)));
try {
order.cancel();
} catch (UnsupportedOperationException e) {
System.out.println(
"Error: " + e.getMessage());
}
System.out.println("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

void cancel() {
for (Payment payment : payments) {
payment.refund();
}
status = "cancelled";
}
Order order = new Order(List.of(
new CardPayment(60),
new VoucherPayment(20),
new PointsPayment(16)));
try {
order.cancel();
} catch (UnsupportedOperationException e) {
System.out.println(
"Error: " + e.getMessage());
}
System.out.println("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 UnsupportedOperationException. 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 main 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:

record VoucherPayment(double amount) implements Payment {
@Override
public void refund() {
System.out.printf("Voucher: new voucher for €%.2f%n", amount);
}
}
Order order = new Order(List.of(
new CardPayment(60),
new VoucherPayment(20),
new PointsPayment(16)));
try {
order.cancel();
} catch (UnsupportedOperationException e) {
System.out.println(
"Error: " + e.getMessage());
}
System.out.println("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" (UnsupportedOperationException) 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 compiler sees none of the three. It checks that the method is written, not that it does what it promises.

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 standard library has such a subtype too. List.of gives you a List that does not take add:
List<String> items = List.of("coffee", "mug");
try {
items.add("tea");
} catch (UnsupportedOperationException e) {
System.out.println("List.of does not take add: " + e.getClass().getSimpleName());
}
Output
List.of does not take add: UnsupportedOperationException
That is why the library says so in its documentation: the method is an "optional operation". If your type has a method that sometimes doesn't work, say it in the name of the type or split it in two.

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?

class ToLocker implements Delivery {
private final boolean full;
public ToLocker(boolean full) {
this.full = full;
}
@Override
public double cost(double amount) {
if (full) {
return -1; // -1 means "no space"
}
return 2.90;
}
}
System.out.printf("€%.2f%n", orderTotal(80, new ToLocker(true)));
Answer

Problem

The SMS that never went out

The shop sends notifications to customers. Someone described everything that can be sent in one place:

interface Notifier {
void email(String to, String text);
void sms(String phone, String text);
}
The email provider only sends emails, but the interface asks for SMS too. Without the method the class does not compile, so someone wrote it.

Try it

Find the bug

The customer gets an email that the parcel has shipped. The courier SMS never comes:

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

Emails and SMS become two interfaces:

interface EmailSender {
void email(String to, String text);
}
interface SmsSender {
void sms(String phone, String text);
}

Each provider does only what it can, and the courier notice asks only for SMS:

class MailProvider implements EmailSender {
@Override
public void email(String to, String text) {
System.out.println("Email to " + to + ": " + text);
}
}
class SmsGateway implements SmsSender {
@Override
public void sms(String phone, String text) {
System.out.println("SMS to " + phone + ": " + text);
}
}
class ShippingNotice {
private final SmsSender sender;
public ShippingNotice(SmsSender sender) {
this.sender = sender;
}
public void courierToday(String phone) {
sender.sms(phone, "The courier comes today between 2 and 4 pm.");
}
}
EmailSender 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 does not compile:

new ShippingNotice(new MailProvider()).courierToday("0888 123 456");
Error
error: incompatible types: MailProvider cannot be converted to SmsSender

The bug from the previous card is impossible: nobody asks for an empty method.

Small interfaces help tests too: the double for the notice implements one method, not everything the shop can send. One class can implement both if the provider really sends emails and SMS: class MailAndSms implements EmailSender, SmsSender.

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 {
public void send(String to, String text) {
System.out.println("SMTP to " + to + ": " + text);
}
}
class InvoiceService {
private final SmtpMailer mailer = new SmtpMailer();
public void issue(Order order, String email) {
mailer.send(email, "Invoice %s: €%.2f".formatted(
order.number(), order.total()));
}
}
Order 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.

interface InvoiceSender {
void send(String to, String text);
}
class InvoiceService {
private final InvoiceSender sender;
public InvoiceService(InvoiceSender sender) {
this.sender = sender;
}
public void issue(Order order, String email) {
sender.send(email, "Invoice %s: €%.2f".formatted(
order.number(), order.total()));
}
}

The details are separate. The mailer and the test double implement the service's abstraction:

class SmtpSender implements InvoiceSender {
@Override
public void send(String to, String text) {
System.out.println("SMTP to " + to + ": " + text);
}
}
class FakeSender implements InvoiceSender {
final List<String> sent = new ArrayList<>();
@Override
public void send(String to, String text) {
sent.add(to + " | " + text);
}
}
Order order = new Order("2026-0142", "Maria", 80);
new InvoiceService(new SmtpSender())
.issue(order, "maria@example.com");
FakeSender fake = new FakeSender();
InvoiceService service = new InvoiceService(fake);
service.issue(order, "maria@example.com");
System.out.println("The test saw: " + fake.sent.get(0));
Output
SMTP to maria@example.com: Invoice 2026-0142: €96.00
The test saw: maria@example.com | Invoice 2026-0142: €96.00

interface InvoiceSender {
void send(String to, String text);
}
class InvoiceService {
private final InvoiceSender sender;
public InvoiceService(InvoiceSender sender) {
this.sender = sender;
}
public void issue(Order order, String email) {
sender.send(email, "Invoice %s: €%.2f".formatted(
order.number(), order.total()));
}
}
class SmtpSender implements InvoiceSender {
@Override
public void send(String to, String text) {
System.out.println("SMTP to " + to + ": " + text);
}
}
Order order = new Order("2026-0142", "Maria", 80);
new InvoiceService(new SmtpSender())
.issue(order, "maria@example.com");
FakeSender fake = new FakeSender();
InvoiceService service = new InvoiceService(fake);
service.issue(order, "maria@example.com");
System.out.println("The test saw: " + fake.sent.get(0));
Step by step, in words
  1. Before, the invoices pointed down, at SmtpMailer. Now the service writes down what it needs: InvoiceSender, next to itself.
  2. InvoiceService gets the sender in its constructor and knows only InvoiceSender. It knows nothing about SMTP.
  3. SmtpSender implements the service's InvoiceSender. The arrow in the code 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 InvoiceSender, 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: InvoiceService(SmtpMailer mailer). The mailer comes from outside, but the service still knows the SMTP class, and a test without SMTP is not possible;
  • DIP without a DI library: in main, 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 InvoiceSender lives in the invoices package (shop.invoices), and SmtpSender lives in shop.mail and imports shop.invoices.InvoiceSender. The invoices package imports nothing from the mail. That is the inverted arrow, visible in the imports.

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. service.issue(order, "maria@example.com");
  2. FakeSender fake = new FakeSender();
  3. System.out.println("The test saw: " + fake.sent.get(0));
  4. InvoiceService service = new InvoiceService(fake);

In short

When SOLID hurts

Here is the VAT, written "by all the rules":

interface TaxPolicy {
double vat(double net);
}
class StandardVat implements TaxPolicy {
@Override
public double vat(double net) {
return net * 0.20;
}
}
class TaxPolicyFactory {
public static TaxPolicy create() {
return new StandardVat();
}
}
System.out.printf("VAT: €%.2f%n", TaxPolicyFactory.create().vat(80));
Output
VAT: €16.00

And the same without dogma:

static final double VAT_RATE = 0.20;
System.out.printf("VAT: €%.2f%n", 80 * VAT_RATE);
Output
VAT: €16.00

The output is the same. The first is an interface, 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 an interface with one implementation 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 implements Payment, but refund throws "not supported". What happens to code that calls refund on every payment?
4. The email provider has to write an empty sms method to implement Notifier. What does that tell you?
5. InvoiceService(SmtpMailer mailer) gets the mailer in its constructor. Does it follow DIP?
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 ·