SOLID design: depend on abstractions
Pass behavior into a class so it can be extended without being edited.
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
Explanation
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.
Explore the dry run
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 |
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.