Skip to content

SOLID in Python

TL;DR

  • SOLID is not five rules to recite; it is five smells to notice and five refactorings to perform: split the god class, replace the if/elif ladder, fix the broken subtype, shrink the fat interface, inject the abstraction.
  • In Python, four of the five collapse into one habit: depend on a small Protocol, pass it in, and let the composition root pick the implementation.
  • Name the principle only after you have named the change. "This is a new ShippingRule, registered here" beats "this violates OCP".

Concepts

The examples below are one runnable checkout module, code/fundamentals/solid.py, with a *Before class per principle and the shape that replaces it. code/fundamentals/tests/test_solid.py asserts the difference each refactor bought.

Single responsibility: one reason to change

A class should have one reason to change - one axis along which requirements move. The smell is a method you cannot summarise without the word "and", or a class whose imports span validation, HTTP, SQL and templating.

code/fundamentals/solid.py - five jobs in one method
class OrderServiceBefore:
    """One class, five reasons to change: what a valid order is, the shipping fees,
    the tax rate, where orders are stored and how the customer is told.
    """

    def __init__(self) -> None:
        self._orders: dict[str, Order] = {}
        self.outbox: list[str] = []

    def place(self, order: Order) -> Money:
        if not order.lines:  # reason 1: validation rules
            raise ValidationError("an order needs at least one line")
        if "@" not in order.customer_email:
            raise ValidationError("invalid email address")
        total = order.subtotal
        if order.shipping is ShippingMethod.EXPRESS:  # reason 2: shipping fees
            total += EXPRESS_FEE
        else:
            total += STANDARD_FEE
        total += total * DEFAULT_TAX_RATE  # reason 3: the tax rate
        self._orders[order.order_id] = order  # reason 4: the storage backend
        self.outbox.append(f"Order {order.order_id}: {total}")  # reason 5: the template
        return total

Count the reasons this class gets edited: the tax office changes the rate, the warehouse changes the fees, someone rewrites the email, someone moves storage to Postgres, someone adds a rule about gift cards. Five teams, one file, five chances to break the other four.

Before: one class with five reasons to change. After: collaborators with one each, and a service that only orchestrates.

classDiagram
    class OrderServiceBefore {
        +place(order: Order) Money
        +outbox: List~str~
    }
    class CheckoutService {
        +place(order: Order, token: str) Receipt
    }
    class OrderValidator {
        +check(order: Order)
    }
    class TaxPolicy {
        +rate: Decimal
        +on(amount: Money) Money
    }
    class ShippingRegistry {
        +cost_for(order: Order) Money
    }
    class OrderRepository {
        <<interface>>
        +save(order: Order)
    }
    CheckoutService ..> OrderValidator : validates with
    CheckoutService ..> TaxPolicy : taxes with
    CheckoutService ..> ShippingRegistry : prices with
    CheckoutService ..> OrderRepository : stores with
code/fundamentals/solid.py - one reason to change each
class OrderValidator:
    """One reason to change: what counts as a valid order."""

    def check(self, order: Order) -> None:
        if not order.lines:
            raise ValidationError("an order needs at least one line")
        if "@" not in order.customer_email:
            raise ValidationError(f"{order.customer_email!r} is not an email address")


@dataclass(frozen=True, slots=True)
class TaxPolicy:
    """One reason to change: the tax rate."""

    rate: Decimal = DEFAULT_TAX_RATE

    def on(self, amount: Money) -> Money:
        return amount * self.rate


class InMemoryOrderRepository:
    """One reason to change: where orders live."""

    def __init__(self) -> None:
        self._orders: dict[str, Order] = {}

    def save(self, order: Order) -> None:
        self._orders[order.order_id] = order

    def get(self, order_id: str) -> Order:
        try:
            return self._orders[order_id]
        except KeyError:
            raise NotFoundError(f"no order {order_id!r}") from None

Splitting is not free: you now have four names to keep straight. The test that justifies it is the one changing the tax rate without constructing a payment gateway. The other signal is cohesion - every method of TaxPolicy touches the rate, while OrderServiceBefore had methods that shared nothing but the word "order". Say in the room: "validation, pricing and persistence change for different reasons, so they are different objects; CheckoutService only sequences them."

Open/closed: extend by adding, not by editing

Software should be open to extension and closed to modification. The smell is an if/elif ladder over a type or an enum - especially the second one, because now two functions must be edited together and one of them will be forgotten.

code/fundamentals/solid.py - the ladder
class ShippingCalculatorBefore:
    """Adding a method edits this function, and every other one that switches on
    the same enum.
    """

    def cost(self, order: Order) -> Money:
        if order.shipping is ShippingMethod.STANDARD:
            return STANDARD_FEE
        if order.shipping is ShippingMethod.EXPRESS:
            return EXPRESS_FEE
        if order.shipping is ShippingMethod.LOCKER:
            return LOCKER_MINIMUM
        raise ValidationError(f"unknown shipping method {order.shipping}")

Before: every new method edits cost. After: a rule per method, looked up in a registry that never changes.

classDiagram
    class ShippingCalculatorBefore {
        +cost(order: Order) Money
    }
    class ShippingRule {
        <<interface>>
        +cost(order: Order) Money
    }
    class FlatShipping {
        +fee: Money
        +cost(order: Order) Money
    }
    class WeightedShipping {
        +per_item: Money
        +minimum: Money
        +cost(order: Order) Money
    }
    class FreeOverThreshold {
        +inner: ShippingRule
        +threshold: Money
        +cost(order: Order) Money
    }
    class ShippingRegistry {
        +register(method: ShippingMethod, rule: ShippingRule)
        +cost_for(order: Order) Money
    }
    ShippingRule <|.. FlatShipping
    ShippingRule <|.. WeightedShipping
    ShippingRule <|.. FreeOverThreshold
    FreeOverThreshold o-- ShippingRule : wraps
    ShippingRegistry o-- ShippingRule : looks up
code/fundamentals/solid.py - a rule per method, plus a registry
@runtime_checkable
class ShippingRule(Protocol):
    def cost(self, order: Order) -> Money: ...


@dataclass(frozen=True, slots=True)
class FlatShipping:
    fee: Money

    def cost(self, order: Order) -> Money:
        return self.fee


@dataclass(frozen=True, slots=True)
class WeightedShipping:
    """A rule the if/elif ladder could not express without growing a second switch."""

    per_item: Money
    minimum: Money

    def cost(self, order: Order) -> Money:
        return max(self.per_item * order.item_count, self.minimum)


@dataclass(frozen=True, slots=True)
class FreeOverThreshold:
    """Written months later, wrapping any rule, editing none of them."""

    inner: ShippingRule
    threshold: Money

    def cost(self, order: Order) -> Money:
        if order.subtotal >= self.threshold:
            return Money(0)
        return self.inner.cost(order)


class ShippingRegistry:
    """Open for extension through ``register``; ``cost_for`` never changes again."""

    def __init__(self, rules: Mapping[ShippingMethod, ShippingRule]) -> None:
        self._rules: dict[ShippingMethod, ShippingRule] = dict(rules)

    def register(self, method: ShippingMethod, rule: ShippingRule) -> None:
        self._rules[method] = rule

    def cost_for(self, order: Order) -> Money:
        try:
            rule = self._rules[order.shipping]
        except KeyError:
            raise ValidationError(f"no shipping rule for {order.shipping}") from None
        return rule.cost(order)


def default_shipping_registry() -> ShippingRegistry:
    return ShippingRegistry(
        {
            ShippingMethod.STANDARD: FlatShipping(STANDARD_FEE),
            ShippingMethod.EXPRESS: FlatShipping(EXPRESS_FEE),
            ShippingMethod.LOCKER: WeightedShipping(LOCKER_PER_ITEM, LOCKER_MINIMUM),
        }
    )

Two things the ladder could not do. WeightedShipping prices by item count, which the flat ladder had no place to put. FreeOverThreshold wraps any rule and was written months later without touching one line of the registry or the service - that is what "closed for modification" buys. This is Strategy with a dict for a factory.

Say in the room: "shipping is the axis they will ask me to extend, so it goes behind a ShippingRule and the ladder becomes a lookup."

Liskov substitution: a subtype must keep the promises

Anywhere the base type works, the subtype must work - same preconditions or weaker, same postconditions or stronger, same invariants. The smell is an override that raises NotImplementedError, an override that ignores an argument, or a subclass whose docstring starts "except that".

code/fundamentals/solid.py - the square that is not a rectangle
class RectangleBefore:
    def __init__(self, width: int, height: int) -> None:
        self.width = width
        self.height = height

    def set_width(self, width: int) -> None:
        self.width = width

    def set_height(self, height: int) -> None:
        self.height = height

    def area(self) -> int:
        return self.width * self.height


class SquareBefore(RectangleBefore):
    """A square *is* a rectangle in geometry and *is not* one in code: it breaks
    the postcondition that setting the width leaves the height alone.
    """

    def __init__(self, side: int) -> None:
        super().__init__(side, side)

    def set_width(self, width: int) -> None:
        self.width = self.height = width

    def set_height(self, height: int) -> None:
        self.width = self.height = height

RectangleBefore promises that set_width leaves the height alone. SquareBefore cannot keep that promise, so any code holding a RectangleBefore reference silently computes the wrong area. Write the promise down as a contract test and run it against every subclass; the violation stops being a debate:

def rectangle_contract(shape: RectangleBefore) -> None:
    shape.set_width(5)
    shape.set_height(4)
    assert shape.area() == 20

rectangle_contract(RectangleBefore(2, 3))   # passes
rectangle_contract(SquareBefore(4))         # AssertionError: area is 16

Before: inheritance chosen for the "is a" of geometry. After: one interface over the behaviour that is genuinely shared.

classDiagram
    class RectangleBefore {
        +width: int
        +height: int
        +set_width(width: int)
        +set_height(height: int)
        +area() int
    }
    class SquareBefore {
        +set_width(width: int)
        +set_height(height: int)
    }
    class Shape {
        <<interface>>
        +area() int
    }
    class Rectangle {
        +width: int
        +height: int
        +area() int
    }
    class Square {
        +side: int
        +area() int
    }
    RectangleBefore <|-- SquareBefore
    Shape <|.. Rectangle
    Shape <|.. Square
code/fundamentals/solid.py - immutable shapes behind one method
class Shape(Protocol):
    """What every shape shares is *area*, not independently mutable sides."""

    def area(self) -> int: ...


@dataclass(frozen=True, slots=True)
class Rectangle:
    width: int
    height: int

    def area(self) -> int:
        return self.width * self.height


@dataclass(frozen=True, slots=True)
class Square:
    side: int

    def area(self) -> int:
        return self.side * self.side


def total_area(shapes: Iterable[Shape]) -> int:
    """Works for every ``Shape`` ever written; that is what substitutable means."""
    return sum(shape.area() for shape in shapes)

Two fixes are hiding in there. Immutability removes the setter that caused the trouble, and the shared abstraction is narrowed to the behaviour that really is common. The contract a subtype must keep is bigger than the signature: it includes the exceptions the base class documents, the invariants it maintains and the arguments it accepts. Accepting fewer inputs, raising a new exception type, or returning a wider type than the base promised all break substitution even though the types still line up. Say in the room: "Square is not a subtype of a mutable Rectangle, so I will make both implement Shape instead, and I would write a contract test that every implementation must pass."

Interface segregation: many small Protocols beat one fat base class

No client should depend on methods it does not call. The smell is an implementation full of stubs that raise, or a test double that has to fake eight methods to exercise one.

code/fundamentals/solid.py - five methods nobody wants all of
class PaymentGatewayBefore(ABC):
    """Five abstract methods, so every implementation must supply five, and the cash
    drawer grows ``refund``, ``subscribe``, ``tokenize`` and ``settlement_report``
    stubs that raise.
    """

    @abstractmethod
    def charge(self, amount: Money, token: str) -> str: ...

    @abstractmethod
    def refund(self, charge_id: str) -> None: ...

    @abstractmethod
    def subscribe(self, plan_id: str, token: str) -> str: ...

    @abstractmethod
    def tokenize(self, card_number: str) -> str: ...

    @abstractmethod
    def settlement_report(self, day: str) -> list[str]: ...

Before: one abstract base with five methods. After: two Protocols, and a class implements only what it can honestly do.

classDiagram
    class PaymentGatewayBefore {
        <<abstract>>
        +charge(amount: Money, token: str)* str
        +refund(charge_id: str)*
        +subscribe(plan_id: str, token: str)* str
        +tokenize(card_number: str)* str
        +settlement_report(day: str)* List~str~
    }
    class Charges {
        <<interface>>
        +charge(amount: Money, token: str) str
    }
    class Refunds {
        <<interface>>
        +refund(charge_id: str)
    }
    class CashDrawer {
        +charge(amount: Money, token: str) str
    }
    class CardGateway {
        +charge(amount: Money, token: str) str
        +refund(charge_id: str)
    }
    Charges <|.. CashDrawer
    Charges <|.. CardGateway
    Refunds <|.. CardGateway
code/fundamentals/solid.py - one method per role
@runtime_checkable
class Charges(Protocol):
    """The whole interface the checkout needs: one method."""

    def charge(self, amount: Money, token: str) -> str: ...


@runtime_checkable
class Refunds(Protocol):
    def refund(self, charge_id: str) -> None: ...


class CashDrawer:
    """Implements exactly what it can do. No stubs that raise."""

    def __init__(self) -> None:
        self.taken: list[Money] = []

    def charge(self, amount: Money, token: str) -> str:
        self.taken.append(amount)
        return f"cash-{len(self.taken)}"


class CardGateway:
    """Implements both small protocols; a client that only charges still depends on
    ``Charges`` alone.
    """

    def __init__(self, approve: bool = True) -> None:
        self.approve = approve
        self.charges: dict[str, Money] = {}

    def charge(self, amount: Money, token: str) -> str:
        if not self.approve:
            raise ConflictError(f"card {token} declined")
        charge_id = f"ch-{len(self.charges) + 1}"
        self.charges[charge_id] = amount
        return charge_id

    def refund(self, charge_id: str) -> None:
        if charge_id not in self.charges:
            raise NotFoundError(f"no charge {charge_id!r}")
        del self.charges[charge_id]

A cash drawer cannot subscribe anyone to anything, and under the fat base class it had to say so in four stub methods. With small Protocols it implements charge and stops. The client shrinks too: CheckoutService declares payments: Charges, so nothing it depends on can be broken by a change to refunds. Python gives you this almost for free - duck typing means no implementation has to declare anything - but only if you name the narrow type on the parameter. Annotate it CardGateway and you have imported the whole fat surface anyway.

Say in the room: "the checkout only ever charges, so the interface it depends on has one method."

Dependency inversion: depend on the abstraction, own the wiring

High-level policy should not depend on low-level detail; both depend on an abstraction. The smell is self._gateway = StripeGateway() inside a constructor: the class has chosen its own collaborator, and no test can substitute anything.

code/fundamentals/solid.py - inject every collaborator
class OrderRepository(Protocol):
    def save(self, order: Order) -> None: ...

    def get(self, order_id: str) -> Order: ...


class Notifier(Protocol):
    def send(self, to: str, message: str) -> None: ...


class CheckoutServiceBefore:
    """Constructs its own collaborators, so nothing can be substituted: a test charges
    a real gateway, and a second provider means editing this class.
    """

    def __init__(self) -> None:
        self._payments = CardGateway()
        self._orders = InMemoryOrderRepository()

    def place(self, order: Order, token: str) -> str:
        charge_id = self._payments.charge(order.subtotal, token)
        self._orders.save(order)
        return charge_id


@dataclass(frozen=True, slots=True)
class Receipt:
    order_id: str
    subtotal: Money
    shipping: Money
    tax: Money
    total: Money
    charge_id: str


class CheckoutService:
    """Depends on five abstractions and constructs none of them: swapping the card
    gateway for a cash drawer is a change in ``main``, not in here.
    """

    def __init__(
        self,
        validator: OrderValidator,
        shipping: ShippingRegistry,
        tax: TaxPolicy,
        payments: Charges,
        orders: OrderRepository,
        notifier: Notifier,
    ) -> None:
        self._validator = validator
        self._shipping = shipping
        self._tax = tax
        self._payments = payments
        self._orders = orders
        self._notifier = notifier

    def place(self, order: Order, token: str) -> Receipt:
        self._validator.check(order)
        shipping = self._shipping.cost_for(order)
        taxable = order.subtotal + shipping
        tax = self._tax.on(taxable)
        total = taxable + tax
        charge_id = self._payments.charge(total, token)
        self._orders.save(order)
        self._notifier.send(order.customer_email, f"Order {order.order_id} total {total}")
        return Receipt(order.order_id, order.subtotal, shipping, tax, total, charge_id)

Before: the service constructs a concrete gateway. After: it names Protocols, and one composition root chooses the implementations.

classDiagram
    class CheckoutServiceBefore {
        +place(order: Order, token: str) str
    }
    class CheckoutService {
        +place(order: Order, token: str) Receipt
    }
    class Charges {
        <<interface>>
        +charge(amount: Money, token: str) str
    }
    class OrderRepository {
        <<interface>>
        +save(order: Order)
    }
    class Notifier {
        <<interface>>
        +send(to: str, message: str)
    }
    class CardGateway {
        +charge(amount: Money, token: str) str
    }
    class CashDrawer {
        +charge(amount: Money, token: str) str
    }
    CheckoutServiceBefore ..> CardGateway : constructs
    CheckoutService ..> Charges : charges through
    CheckoutService ..> OrderRepository : saves through
    CheckoutService ..> Notifier : notifies through
    Charges <|.. CardGateway
    Charges <|.. CashDrawer

The ordering inside place is the other thing to point at: charge first, commit afterwards. A declined card raises before save runs, so no order is persisted and no email is sent. build_checkout is the composition root - the one function allowed to name concrete classes. Everything else takes what it needs as an argument, which is exactly Dependency Injection; no framework is involved.

Running python -m fundamentals.solid shows each pair:

--- order ORD-1: subtotal 44.98 USD, 3 items ---
SRP before: one class returns 65.96 USD and owns the outbox too
SRP after:  44.98 USD + 9.99 USD + 10.99 USD = 65.96 USD
OCP before: locker is 2.49 USD whatever the 6-item basket holds
OCP after:  the same basket is 4.50 USD under a per-item rule
OCP after:  a wrapping rule registered later drops express to 0.00 USD
LSP before: set_width(5) on a 4x4 square gives area 25, not 20
LSP after:  total_area of a rectangle and a square = 36
ISP after:  the cash drawer charges (cash-1) and owes no refund stub
ISP after:  isinstance(drawer, Charges) -> True, Refunds -> False
DIP:        the same service on a cash drawer -> cash-2
DIP:        the declined path is a two-line fake, not a sandbox: card tok-declined declined
notifier recorded 2 messages, the last to ana@example.com

Applying it in the interview

SOLID is a vocabulary for the last ten minutes, when the interviewer starts asking "what if we also need X?". The move is always the same: name the axis of change, name the seam, name the test. "Discounts are the next thing to vary, so they go behind a DiscountRule Protocol; the registry maps a coupon type to a rule; the test I would add asserts that a stacked coupon never takes the total below zero."

Two calibrations matter at SDE2. First, apply the principles to the one or two axes the requirements actually name, not to every class - a Protocol with a single implementation and no test double is speculative work you will be asked to defend. Second, say the cost out loud: splitting a class adds names and indirection, and the return is that one team can change one thing. An interviewer who hears you weigh both sides stops testing whether you memorised the acronym.

Interview tip

Never say "that violates the open/closed principle" on its own. Say the refactor, then the label: "I would put each shipping rule behind one cost method and look it up by method - that is open/closed, and it means adding same-day delivery touches no existing code." The refactor is what gets graded; the label is what makes it memorable.

Pitfalls

  • Pattern-itis. Five interfaces for a program with one implementation of each is not SOLID, it is ceremony. YAGNI wins until a second implementation, a test double, or a named requirement appears.
  • Splitting by layer instead of by reason to change. OrderHelper, OrderManager and OrderUtils are one class in three files. If two of them always change together, they are one responsibility.
  • Inheriting to reuse code. Most "is a" relationships in a domain are really "has a" or "plays the role of". Reach for a Protocol plus composition and keep inheritance for genuine shared behaviour.
  • Injecting the concrete type. Passing gateway: CardGateway as a constructor argument is not dependency inversion; you moved the coupling, you did not remove it. The parameter must be typed as the abstraction.
  • A "Protocol" with ten methods. Interface segregation is about the client's needs. If two clients use disjoint halves, that is two Protocols.

Common mistake

Treating SRP as "one method per class". Cutting a class into single-method fragments produces the same god object spread across ten files, plus a wiring problem, and reviewers see it immediately. The unit is a reason to change - a stakeholder, a policy, an external system - not a line count. If you cannot name who would ask for the change, you have not found a responsibility.

Exercises

  1. The registry still has an if. ShippingRegistry.cost_for raises when a method has no rule. Is that an open/closed violation?

    Solution

    No. Selection has to happen somewhere; the principle is that adding a rule does not edit existing code, and register satisfies that. The missing-rule branch is error handling, not a type switch - it does not grow when a method is added. A Null Object (FreeShipping) as the registry default would remove even that branch, at the cost of turning a configuration mistake into silent free delivery.

  2. Apply LSP to a repository. OrderRepository.get raises NotFoundError for a missing id. A caching implementation returns None instead. What breaks, and what is the fix?

    Solution

    Every caller written against the Protocol handles NotFoundError; the cache weakens the postcondition, so those callers now dereference None and fail somewhere unrelated. The fix is a contract test that every implementation must pass - "get on a missing id raises NotFoundError" - run as a parametrized test over all implementations. Contract tests are how you enforce LSP for interfaces you did not write.

  3. Add a loyalty discount without editing CheckoutService.

    Solution

    Define DiscountRule with discount(order) -> Money, add it as a constructor argument typed as that Protocol, and subtract it from taxable before tax. Adding it to the constructor is a change to CheckoutService, so the truly closed version instead composes: wrap the injected ShippingRegistry or introduce a PricingPipeline of rules the service iterates. Choose the second only if the interviewer asks for more than one discount - otherwise the extra parameter is simpler and honest.

  4. Which principle does FreeOverThreshold illustrate, and which pattern is it?

    Solution

    Open/closed, achieved through composition: it satisfies ShippingRule, holds another ShippingRule, and adds one rule on top. Structurally that is a Decorator - same interface in and out - which is why it can wrap FlatShipping, WeightedShipping or another FreeOverThreshold without any of them knowing. It also demonstrates DIP: it depends on the Protocol, never on a concrete rule.