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:

from dataclasses import dataclass
@dataclass
class Order:
number: str
customer: str
net: float
class InvoiceService:
def vat(self, order):
return order.net * 0.20
# Accounting: a line for the file it imports into its software.
def export_line(self, order):
total = order.net + self.vat(order)
return f"{order.number};{self._money(order.net)};{self._money(self.vat(order))};{self._money(total)}"
# The customer: the text of the email with the invoice.
def email_text(self, order):
total = order.net + self.vat(order)
return f"Hi, {order.customer}! Order {order.number}: €{self._money(total)}"
def _money(self, amount):
return f"{amount:.2f}"
invoices = InvoiceService()
order = Order("2026-0142", "Maria", 80)
print(invoices.export_line(order))
print(invoices.email_text(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:

def _money(self, amount):
if amount == int(amount):
return str(int(amount))
return f"{amount:.2f}"
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:

from dataclasses import dataclass
@dataclass
class Order:
number: str
customer: str
net: float
def vat(self):
return self.net * 0.20
def total(self):
return self.net + self.vat()

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

class AccountingExport:
def line(self, order):
return f"{order.number};{order.net:.2f};{order.vat():.2f};{order.total():.2f}"
class InvoiceEmail:
def text(self, order):
return f"Hi, {order.customer}! Order {order.number}: €{self._money(order.total())}"
def _money(self, amount):
if amount == int(amount):
return str(int(amount))
return f"{amount:.2f}"
order = Order("2026-0142", "Maria", 80)
print(AccountingExport().line(order))
print(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:

def to_address(amount):
return 0 if amount >= 100 else 5.90
def to_office(amount):
return 3.90

The total does not know which deliveries exist:

def order_total(amount, delivery):
return amount + delivery(amount)

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

def to_locker(amount):
return 2.90

order_total, 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?

DELIVERIES = {
    "address": to_address,
    "office": to_office,
    "locker": ,
}

for choice, delivery in DELIVERIES.items():
    print(f"{choice}: €{order_total(80, delivery):.2f}")

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:

from abc import ABC, abstractmethod
from dataclasses import dataclass
class Payment(ABC):
@abstractmethod
def refund(self):
"""Gives the money back to the customer the same way they paid."""
@dataclass
class CardPayment(Payment):
amount: float
def refund(self):
print(f"Card: refunded €{self.amount:.2f}")
@dataclass
class VoucherPayment(Payment):
amount: float
def refund(self):
raise NotImplementedError("a voucher can't be refunded")
@dataclass
class PointsPayment(Payment):
amount: float
def refund(self):
print(f"Points: refunded €{self.amount:.2f}")

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

class Order:
def __init__(self, payments):
self.payments = payments
self.status = "paid"
def cancel(self):
for payment in self.payments:
payment.refund()
self.status = "cancelled"
order = Order([
CardPayment(60),
VoucherPayment(20),
PointsPayment(16),
])
try:
order.cancel()
except NotImplementedError as e:
print(f"Error: {e}")
print(f"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

def cancel(self):
for payment in self.payments:
payment.refund()
self.status = "cancelled"
order = Order([
CardPayment(60),
VoucherPayment(20),
PointsPayment(16),
])
try:
order.cancel()
except NotImplementedError as e:
print(f"Error: {e}")
print(f"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 NotImplementedError. 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 except 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:

@dataclass
class VoucherPayment(Payment):
amount: float
def refund(self):
print(f"Voucher: new voucher for €{self.amount:.2f}")
order = Order([
CardPayment(60),
VoucherPayment(20),
PointsPayment(16),
])
try:
order.cancel()
except NotImplementedError as e:
print(f"Error: {e}")
print(f"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" (NotImplementedError) for a method the type promises;
  • the subtype returns something the type does not promise (-1, None, an empty string), or asks more from the caller;
  • the code checks isinstance 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 Python 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
Python is careful here. Lists and tuples are not subtypes of each other, and the collections.abc module splits the contract in two: Sequence (reads) and MutableSequence (also changes). A tuple is only the first:
from collections.abc import MutableSequence, Sequence
items = ("coffee", "mug")
print(isinstance(items, Sequence), isinstance(items, MutableSequence))
Output
True False
So code that only needs to read asks for a Sequence, and a tuple fits without lying. That is also the next principle: small contracts.

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?

def to_locker(full):
def cost(amount):
if full:
return -1 # -1 means "no space"
return 2.90
return cost
print(f"€{order_total(80, to_locker(True)):.2f}")
Answer

Problem

The SMS that never went out

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

from abc import ABC, abstractmethod
class Notifier(ABC):
@abstractmethod
def email(self, to, text):
pass
@abstractmethod
def sms(self, phone, text):
pass
The email provider only sends emails, but the abstract class asks for sms too. Without it MailProvider() 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:

notifier = 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.

In Python a small contract is written with typing.Protocol: "something with an sms method". The providers inherit nothing. Any object that has the method fits:

from typing import Protocol
class EmailSender(Protocol):
def email(self, to: str, text: str) -> None: ...
class SmsSender(Protocol):
def sms(self, phone: str, text: str) -> None: ...

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

class MailProvider:
def email(self, to, text):
print(f"Email to {to}: {text}")
class SmsGateway:
def sms(self, phone, text):
print(f"SMS to {phone}: {text}")
class ShippingNotice:
def __init__(self, sender: SmsSender):
self.sender = sender
def courier_today(self, phone):
self.sender.sms(phone, "The courier comes today between 2 and 4 pm.")
email = MailProvider()
email.email("maria@example.com", "Order 2026-0142 has shipped.")
ShippingNotice(SmsGateway()).courier_today("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:
ShippingNotice(MailProvider()).courier_today("0888 123 456")
except AttributeError as e:
print(f"AttributeError: {e}")
Output
AttributeError: 'MailProvider' object has no attribute 'sms'
Python itself does not check a Protocol at run time. A type checker such as mypy sees the error before running: MailProvider has no sms, and the parameter wants an SmsSender.

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:
def send(self, to, text):
print(f"SMTP to {to}: {text}")
class InvoiceService:
def __init__(self):
self.mailer = SmtpMailer()
def issue(self, order, email):
total = order.total()
text = f"Invoice {order.number}: €{total:.2f}"
self.mailer.send(email, text)
order = Order("2026-0142", "Maria", 80)
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.

from abc import ABC, abstractmethod
class InvoiceSender(ABC):
@abstractmethod
def send(self, to, text):
pass
class InvoiceService:
def __init__(self, sender: InvoiceSender):
self.sender = sender
def issue(self, order, email):
total = order.total()
text = f"Invoice {order.number}: €{total:.2f}"
self.sender.send(email, text)

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

class SmtpSender(InvoiceSender):
def send(self, to, text):
print(f"SMTP to {to}: {text}")
class FakeSender(InvoiceSender):
def __init__(self):
self.sent = []
def send(self, to, text):
self.sent.append(f"{to} | {text}")
order = Order("2026-0142", "Maria", 80)
smtp = InvoiceService(SmtpSender())
smtp.issue(order, "maria@example.com")
fake = FakeSender()
service = InvoiceService(fake)
service.issue(order, "maria@example.com")
print(f"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 InvoiceSender(ABC):
@abstractmethod
def send(self, to, text):
pass
class InvoiceService:
def __init__(self, sender: InvoiceSender):
self.sender = sender
def issue(self, order, email):
total = order.total()
text = f"Invoice {order.number}: €{total:.2f}"
self.sender.send(email, text)
class SmtpSender(InvoiceSender):
def send(self, to, text):
print(f"SMTP to {to}: {text}")
order = Order("2026-0142", "Maria", 80)
smtp = InvoiceService(SmtpSender())
smtp.issue(order, "maria@example.com")
fake = FakeSender()
service = InvoiceService(fake)
service.issue(order, "maria@example.com")
print(f"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: 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: the service gets the mailer from outside, but its file still imports from mail import SmtpMailer 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
With ABC the mailer inherits InvoiceSender and imports it: the arrow towards the abstraction is visible. With typing.Protocol, SmtpSender doesn't know about the contract at all, and the dependency from the detail to the abstraction disappears. The type checker (mypy) checks the shape before running.

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. print(f"The test saw: {fake.sent[0]}")
  3. fake = FakeSender()
  4. service = InvoiceService(fake)

In short

When SOLID hurts

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

from abc import ABC, abstractmethod
class TaxPolicy(ABC):
@abstractmethod
def vat(self, net):
pass
class StandardVat(TaxPolicy):
def vat(self, net):
return net * 0.20
def create_tax_policy():
return StandardVat()
print(f"VAT: €{create_tax_policy().vat(80):.2f}")
Output
VAT: €16.00

And the same without dogma:

VAT_RATE = 0.20
print(f"VAT: €{80 * VAT_RATE:.2f}")
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 ·