SOLID design: depend on abstractions
Pass behavior into a class so it can be extended without being edited.
On this page
Classes and method calls.
- Pass behavior into a class so it can be extended without being edited.
- Predict the sample output and explain the state changes.
- Complete the practice task and check the boundary cases.
The concept
The SOLID principles are five guidelines for object-oriented design. Single responsibility: a class should have one reason to change. Open/closed: code should be open to extension but closed to modification. Liskov substitution: a subtype must be usable wherever its base type is expected. Interface segregation: prefer several small interfaces to one large one. Dependency inversion: high-level code should depend on abstractions, not on concrete details.
The example shows open/closed and dependency inversion together. Checkout calculates a total but does not decide how a discount works. It receives a pricing rule object and calls its apply method. New rules such as a percentage discount or a flat amount are new classes, and Checkout never has to be edited. Passing a dependency in, rather than creating it inside the class, is called dependency injection, and it also makes Checkout easy to test with a simple fake rule.
Python uses duck typing, so any object with an apply method works, and no shared base class is required. A typing.Protocol can document the expected shape for readers and type checkers. These are guidelines, not laws: applying them to code that will never vary adds indirection with no benefit.
Code example
class NoDiscount:
def apply(self, amount):
return amount
class PercentOff:
def __init__(self, percent):
self.percent = percent
def apply(self, amount):
return amount * (100 - self.percent) // 100
class FlatOff:
def __init__(self, cents):
self.cents = cents
def apply(self, amount):
return max(amount - self.cents, 0)
class Checkout:
def __init__(self, rule):
self.rule = rule
def total(self, prices):
return self.rule.apply(sum(prices))
cart = [1000, 2500]
for rule in (NoDiscount(), PercentOff(10), FlatOff(500)):
print(type(rule).__name__, Checkout(rule).total(cart))NoDiscount 3500 PercentOff 3150 FlatOff 3000
Follow the code
Open the walkthrough to inspect the example one step at a time.
Show guided walkthrough
class NoDiscount: def apply(self, amount): return amount class PercentOff: def __init__(self, percent): self.percent = percent def apply(self, amount): return amount * (100 - self.percent) // 100 class FlatOff: def __init__(self, cents): self.cents = cents def apply(self, amount): return max(amount - self.cents, 0) class Checkout: def __init__(self, rule): self.rule = rule def total(self, prices): return self.rule.apply(sum(prices)) cart = [1000, 2500]for rule in (NoDiscount(), PercentOff(10), FlatOff(500)): print(type(rule).__name__, Checkout(rule).total(cart))Follow the example
Start to inspect the source, values, and output. These prepared steps explain the published sample; grouped loop steps describe multiple iterations.
View the complete dry-run table
| Step / line | What happens | State after step | Output so far |
|---|---|---|---|
| Checkout receives its pricing rule from outside: dependency injection. | — | — | |
| Checkout calls apply on whatever rule it was given. It does not know which kind of rule it is. | — | — | |
| Three different rules work with the same unchanged Checkout class: open for extension, closed for modification. | — | — | |
| The cart sums to 3500. 10 percent off gives 3150 and 500 cents off gives 3000. | — | NoDiscount 3500 PercentOff 3150 FlatOff 3000 |
Behind the scenes
How Python processes this code
Python runs this source through its interpreter. It creates objects for values and resolves names as each statement runs.
Cost and common mistakes
Calling a rule through an object adds one method call, which is O(1) overhead per total.
- Do not create abstractions for variation that does not exist. Extra layers make simple code harder to read.
- A subclass that changes the meaning of an inherited method breaks the Liskov substitution principle, even if it compiles.
- Dependency injection only helps if callers can actually supply different implementations, for example in tests.
Try it yourself
Write a rule that charges a minimum of 1000 cents. What must change in Checkout?
Reveal answer and reasoning
Nothing. Add class MinimumCharge with apply returning max(amount, 1000) and pass an instance to Checkout.