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/elifladder, 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.
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
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.
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
@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".
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
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.
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
@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.
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,OrderManagerandOrderUtilsare 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: CardGatewayas 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¶
-
The registry still has an
if.ShippingRegistry.cost_forraises 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
registersatisfies 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. -
Apply LSP to a repository.
OrderRepository.getraisesNotFoundErrorfor a missing id. A caching implementation returnsNoneinstead. 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 dereferenceNoneand fail somewhere unrelated. The fix is a contract test that every implementation must pass - "get on a missing id raisesNotFoundError" - run as a parametrized test over all implementations. Contract tests are how you enforce LSP for interfaces you did not write. -
Add a loyalty discount without editing
CheckoutService.Solution
Define
DiscountRulewithdiscount(order) -> Money, add it as a constructor argument typed as that Protocol, and subtract it fromtaxablebefore tax. Adding it to the constructor is a change toCheckoutService, so the truly closed version instead composes: wrap the injectedShippingRegistryor introduce aPricingPipelineof 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. -
Which principle does
FreeOverThresholdillustrate, and which pattern is it?Solution
Open/closed, achieved through composition: it satisfies
ShippingRule, holds anotherShippingRule, and adds one rule on top. Structurally that is a Decorator - same interface in and out - which is why it can wrapFlatShipping,WeightedShippingor anotherFreeOverThresholdwithout any of them knowing. It also demonstrates DIP: it depends on the Protocol, never on a concrete rule.
Related¶
- Object-oriented Python for interviews - the dataclasses, Protocols and enums these refactorings use
- DRY, KISS, YAGNI, Demeter, GRASP and cohesion - the principles that tell you when not to apply these
- Strategy - the pattern behind the open/closed refactor
- Dependency Injection - composition roots and how the abstraction reaches the service
- Robert C. Martin, "Design Principles and Design Patterns" (2000)
- Barbara Liskov and Jeannette Wing, "A Behavioral Notion of Subtyping" (1994)
- PEP 544 - Protocols: structural subtyping