Skip to content

SOLID with small examples

The five SOLID design principles, each shown on a small piece of Python code that you can run: one reason to change, adding without editing, safe substitution, small interfaces, and passing in what a class needs.

  • Lesson 106 of 125
  • 20 min read
  • Core
  • Free to read, no account
On this page
  1. Why interviewers ask this
  2. The idea in plain words
  3. Pause and predict
  4. Worked example
  5. Trade-offs
  6. Common misconceptions
  7. How to say it in the interview
  8. Check your understanding
  9. Answers
  10. Sources

What you need first

This lesson uses ideas from these earlier lessons. Read them first if any idea is new to you.

01Why interviewers ask this

A machine-coding round is a timed round in which you write a small working program. Then the interviewer adds a new requirement. In Tradeoff's round, part of the score is extensibility: how easily new rules fit in. Does the new rule fit as a small new piece, or does it break old code?

A weak answer puts everything in one big class. It works for the first version. Then every change means editing working code in many places. A strong answer splits the code so that a change touches one small place. It can also say why.

At SDE-1 level you should split the code into sensible classes. At SDE-2 level you should make the new requirement a small, tidy change. At SDE-3 level you should also say when a principle is not worth its cost.

02The idea in plain words

Think of the sockets in a house. Every appliance has the same plug, so you can add a new fan without opening the wall. Each appliance does one job. A plug that fits the socket works in it, and it has only the pins it needs. The wiring depends on the socket shape, not on one brand of fan.

SOLID is five rules for code that behaves like this. Each letter is one rule. The code below is Python because it is short. The ideas are the same in Java, JavaScript and C++. We use one food-ordering example. Here is the first version, which has problems:

python
class OrderService:
    def place(self, items, discount_type, phone):
        total = sum(price * qty for price, qty in items)
        if discount_type == "PERCENT":
            total = total * 90 // 100
        elif discount_type == "FLAT":
            if total >= 500:
                total = total - 50
        print(f"SMS to {phone}: order total {total}")
        with open("orders.txt", "a") as f:
            f.write(f"{phone},{total}\n")
        return total

An order has 2 biryani at Rs 250 and 1 lassi at Rs 60. The total is 2 x 250 + 60 = Rs 560. With "PERCENT" (10% off) the method returns 560 x 90 // 100 = 504 (// divides and drops the fraction). With "FLAT" (Rs 50 off above Rs 500) it returns 510.

S: single responsibility

A class should have one reason to change. A reason to change is one group of rules that someone may ask you to change. The method above has three: the price rules, the message to the customer, and the place where orders are saved. A change to one can break the other two. Give each job its own class:

python
class PriceCalculator:
    def subtotal(self, items):
        return sum(price * qty for price, qty in items)

class SmsNotifier:
    def send(self, phone, text):
        print(f"SMS to {phone}: {text}")

class FileOrderStore:
    def save(self, phone, total):
        with open("orders.txt", "a") as f:
            f.write(f"{phone},{total}\n")

A class can have many methods. What matters is that all of them change for the same reason.

O: open for extension, closed for modification

You should be able to add a new behaviour without editing code that already works. The if and elif chain for discounts breaks this: each new discount edits the method. The fix is an abstract class: a class that lists methods but does not fill them in. Other classes fill them in. Python's abc module makes the list a promise, because you cannot create an object from a class that has an unfilled method.

python
from abc import ABC, abstractmethod

class Discount(ABC):
    @abstractmethod
    def apply(self, total):
        """Return the total after the discount."""

class PercentOff(Discount):
    def __init__(self, percent):
        self.percent = percent
    def apply(self, total):
        return total * (100 - self.percent) // 100

class FlatOff(Discount):
    def __init__(self, amount, minimum):
        self.amount, self.minimum = amount, minimum
    def apply(self, total):
        return total - self.amount if total >= self.minimum else total

PercentOff(10).apply(560) gives 504. FlatOff(50, 500).apply(560) gives 510. A new discount is a new class. No old line changes.

L: Liskov substitution

Code that works with a parent class must also work with each child class, with no surprise. A child may not refuse a job that the parent promises. In everyday words, "a cash payment is a payment" is not enough. The child must also behave like the parent. Here a child breaks the promise:

python
class Payment:
    def pay(self, amount): ...
    def refund(self, amount): ...

class UpiPayment(Payment):
    def refund(self, amount):
        return f"Rs {amount} sent back to UPI"

class CashOnDelivery(Payment):
    def refund(self, amount):
        raise NotImplementedError("cash cannot be refunded")

def cancel_order(payment, amount):
    return payment.refund(amount)

cancel_order(UpiPayment(), 460) works. cancel_order(CashOnDelivery(), 460) crashes. Three rules keep a child safe. It must not ask for more than the parent asks. It must not give less than the parent promises. It must not raise a new kind of error that the parent never raises. The fix here: do not promise what you cannot do. Make Payment promise only pay. Add a second class, RefundablePayment(Payment), that also promises refund. UPI extends RefundablePayment. Cash extends only Payment. Now cancel_order is meant only for RefundablePayment. A cash payment has no refund method at all, so the mistake is easy to see when you write the code.

I: interface segregation

A class should not be forced to write methods it does not use. An interface is the list of methods that a class promises. Suppose Notifier lists send_sms, send_email and send_push. A WhatsApp class needs only one of them, and must write two empty or crashing methods. Keep each interface small. The order code needs only one thing: tell the customer.

python
class Notifier(ABC):
    @abstractmethod
    def send(self, phone, text): ...

D: dependency inversion

Rules of the business, such as "price an order and tell the customer", are high-level code. Details, such as one SMS company or one file, are low-level code. The high-level code should depend on abstract classes, not on the details. Do not create the detail inside the class. Pass it in through the constructor. This is called dependency injection.

python
class OrderService:
    def __init__(self, discount, notifier, store):
        self.discount, self.notifier, self.store = discount, notifier, store

    def place(self, items, phone):
        subtotal = sum(price * qty for price, qty in items)
        total = self.discount.apply(subtotal)
        self.notifier.send(phone, f"order total {total}")
        self.store.save(phone, total)
        return total

Dependency inversion is the rule. Dependency injection is one way to follow it. The store is an abstract class too, like Notifier, with one method, save. This class does not know which discount, which message channel or which store it gets. A test can pass in a store that keeps rows in memory, with no file.

How to spot each one

RuleA smell you can seeA fix
SOne class changes for many different reasonsOne class for each job
OA new case means a new if branch in old codeAn abstract class and one new class per case
LA child class raises an error or ignores a promisePromise less in the parent
IA class has empty or crashing methodsSmaller interfaces
DA class makes its own SMS or file object insidePass it in through the constructor

Stuck on something? The AI tutor has read this lesson. Ask it with a free account

03Pause and predict

Pause. Guess first.

In the first version of OrderService.place, a teammate wants three changes. (a) A new discount. (b) WhatsApp instead of SMS. (c) A database instead of a file. How many of the three need an edit inside that one method? Which principle says what to do?

Write your guess before you read on. The answer is in the worked example.

04Worked example

The interviewer adds a new requirement: "20% off, but at most Rs 100 off. And send the message on WhatsApp, not SMS." The order is the same. It has 2 biryani at Rs 250 and 1 lassi at Rs 60, which is Rs 560.

Assumption: the first version is the method in the plain-words section. It has 11 lines. The new version is the classes shown above.

First version.

  • (a) needs a new elif branch: 2 new lines inside place.
  • (b) changes the print line.
  • (c) would change the open and write lines.

So all three changes need an edit inside the same method. That answers the predict box. S says to split the three jobs. Then O (for the discount) and D (for the message and the store) let each job change without an edit in the others. For the new requirement you add 2 lines and change 1 of the 11 lines, in a method that also saves files. Each edit can break the price, the message or the saving. To test it, you must write to a real file.

New version. We add two classes and change one line: the line that builds the service. Before, it passed PercentOff(10), an SMS notifier and a file store.

python
class CappedPercentOff(Discount):
    def __init__(self, percent, cap):
        self.percent, self.cap = percent, cap
    def apply(self, total):
        return total - min(total * self.percent // 100, self.cap)

class WhatsAppNotifier(Notifier):
    def send(self, phone, text):
        print(f"WhatsApp to {phone}: {text}")

For the test we also need a store that keeps rows in memory, with no file. OrderStore is an abstract class with one method, save, like Notifier.

python
class OrderStore(ABC):
    @abstractmethod
    def save(self, phone, total): ...

class MemoryOrderStore(OrderStore):
    def __init__(self):
        self.rows = []
    def save(self, phone, total):
        self.rows.append((phone, total))

store = MemoryOrderStore()
service = OrderService(CappedPercentOff(20, 100), WhatsAppNotifier(), store)
print(service.place([(250, 2), (60, 1)], "98000"))

It prints WhatsApp to 98000: order total 460 and then 460. Now the arithmetic. 20% of 560 = 560 x 20 // 100 = 112. The cap is 100, and min(112, 100) = 100. So the total is 560 - 100 = Rs 460. Before the new requirement, PercentOff(10) gave 504.

OrderService.place has 0 changed lines. Its line count did not move. Only the line that builds the service changed. This is O and D together. The test uses MemoryOrderStore, so it needs no file. It can check that store.rows is [("98000", 460)].

The same order cancelled. The customer paid by UPI, so we refund Rs 460. With the fixed payment classes, cancel_order is written only for RefundablePayment. A cash order has no refund method, so the surprise crash from the L section is gone.

05Trade-offs

  • SOLID solves the cost of change: the new requirement took 2 new classes and 1 changed line, and old code stayed safe. It costs more classes, more files and a reader who must follow one more step. I would use it where a change is likely or already asked. I would change to simple code when there is one case and no change is expected.
  • An abstract class solves adding a case without editing. It costs a name, a file and an extra jump when you read. I would add one when there are two real cases, or the interviewer has said a change is coming.
  • Passing things in solves easy tests and easy swaps. It costs one place that builds all the objects. In a timed round, build that place in the first few minutes and keep it short.

06Common misconceptions

"Single responsibility means a class has only one method." It means one reason to change. PercentOff has __init__ and apply, and both change only when the percent rule changes.

"Open for extension means I may never edit old code." You still fix bugs and change rules that really change. The rule is about adding a new case. Adding a discount should not need an edit in the order flow.

"A cash payment is a payment, so inheritance is fine." In English it is true. In code, CashOnDelivery breaks cancel_order, because it refuses a job that the parent promises. A child class is correct only if every caller of the parent still works.

07How to say it in the interview

Say it like this

Each class has one reason to change, and I pass the discount and the notifier in through the constructor. So a new discount or WhatsApp instead of SMS is a new small class, not an edit inside working code.

08Check your understanding

  1. What do S, O, L, I and D stand for? Give one plain sentence for each.
  2. Using CappedPercentOff(20, 100), what total do you get for an order of (a) Rs 480 and (b) Rs 1,200?
  3. A teammate writes class ReadOnlyCart(Cart) where add_item raises an error. A function add_free_gift(cart) calls cart.add_item. What happens when it gets a ReadOnlyCart? Which principle is broken, and what do you change?

09Answers

Show the answers
  1. S: a class has one reason to change. O: add new behaviour without editing old code. L: a child class works wherever its parent works. I: a class is not forced to write methods it does not use, so keep interfaces small. D: high-level code depends on abstract classes, and the details are passed in.
  2. (a) 20% of 480 = 480 x 20 // 100 = 96. This is below the cap of 100, so the total is 480 - 96 = Rs 384. (b) 20% of 1,200 = 240. This is above the cap, so the discount is 100 and the total is 1,200 - 100 = Rs 1,100.
  3. add_free_gift crashes, because the child refuses a job that the parent promises. This breaks L. Do not make ReadOnlyCart a child of Cart. Make a small parent, such as CartView, with only total. Cart and ReadOnlyCart both extend it, and only Cart has add_item. Then add_free_gift accepts only Cart.

10Sources

  • The five principles were collected by Robert C. Martin in "Design Principles and Design Patterns" (2000). Michael Feathers coined the name SOLID, around 2004. Summary: https://en.wikipedia.org/wiki/SOLID (opens in a new tab) (checked 20 Oct 2026).
  • Abstract classes cannot be created until every abstract method is filled in: Python documentation, abc module, https://docs.python.org/3/library/abc.html (opens in a new tab) (checked 20 Oct 2026). The code in this lesson was run with Python 3.
  • The food-order example, the prices and the discount rules are made up for teaching. The idea that extensibility is part of the score describes Tradeoff's own machine-coding round, not the rules of any company.

Try it now, no sign-up

More practice, in the app (free account)

Ask the AI tutor about this lesson

The tutor has read this lesson. Ask in your own words, any time. Free account, 20 questions a day.

Reading never needs an account. The account is for the tutor, the quiz, your notes and your progress.

All lessons in Module 17: Low-level design and machine coding