SOLID & design patterns (factory, strategy)
Refactor toward SOLID with pluggable strategies selected by a factory.
Why SOLID shows up in code review
Almost every service has a spot where behavior branches by a key: pick a discount by customer tier, a parser by file type, a shipping rate by region. It usually starts as a three-line if/elif. A year later it is forty branches, every teammate edits the same function, and every edit risks breaking a case that already worked. SOLID is five design principles for arranging code so that new behavior is additive instead of invasive. Two of them do most of the work here.
- Single responsibility: each function or class has one reason to change.
- Open/closed: code is open to extension but closed to modification. You add a case by adding code, not by editing code that already passed its tests.
The other three matter less here but come up in review, so it is worth being able to name all five:
| Principle | In one line | The smell it names |
|---|---|---|
| S: single responsibility | one reason to change | a class that both parses and saves, so two teams edit it |
| O: open/closed | extend by adding, not by editing | the forty-branch if/elif nobody dares touch |
| L: Liskov substitution | a subtype must work anywhere its base does | a subclass that raises NotImplementedError on an inherited method |
| I: interface segregation | many small interfaces beat one fat one | implementers stubbing methods they never needed |
| D: dependency inversion | depend on the abstraction, not the concrete type | a service that builds its own DB client, so tests cannot swap it |
A long if/elif chain violates open/closed: every new tier reopens price_for and puts a tested function back on the table. Two patterns remove the chain.
Strategy: behavior as a value
A strategy is one interchangeable unit of behavior. In Python, functions are first-class objects, so the simplest strategy is just a function you can store and pass around:
def regular(a):
return round(a, 2)
def member(a):
return round(a * 0.9, 2) # 10% off
Each strategy has the same shape (amount in, price out), so a caller can swap one for another without knowing which one it holds.
Factory: pick the strategy by key
A factory maps a key to a strategy so the caller never sees the choices:
STRATEGIES = {"regular": regular, "member": member}
def price_for(kind, amount):
strategy = STRATEGIES.get(kind, regular) # default to full price
return strategy(amount)
print(price_for("member", 100)) # 90.0
Adding a vip tier is one new function plus one dict entry. price_for itself never changes. That is open/closed in three lines, and it is exactly what you will build: first inline, then behind a pricing package.
Four versions of the same dispatch, all with price_for(kind, amount) calling strategy(amount). Sort each by what the customer sees.
Two traps interns hit
Store the function, not its result. {"member": member} stores the callable; {"member": member(100)} calls it immediately and stores the number 90.0, so a later strategy(amount) raises TypeError: 'float' object is not callable.
Handle the unknown key. STRATEGIES[kind] raises KeyError for a tier you have not registered. Use STRATEGIES.get(kind, regular) so an unknown kind falls back to full price, which is what the exercises require.
Interview nuance: an if/elif chain does up to k comparisons for k branches, while the dict dispatch is one average-case O(1) hash lookup no matter how many strategies exist. But interviewers care more about the design consequence than the constant factor: with the table, adding a strategy touches zero existing lines, so nothing that already passed can regress. Named dispatch tables like this are how real routers, command handlers, and plugin registries stay open for extension as they grow.
def regular(a):
return round(a, 2)
def member(a):
return round(a * 0.9, 2)
STRATEGIES = {"regular": regular, "member": member}
print(STRATEGIES.get("member", regular)(100)) # 90.0Apply
Your turn
The task this lesson builds to.
Warm-up (one file): implement price_for(kind, amount) that applies a discount by kind. regular
is full price, member is 10% off, vip is 20% off, and any unknown kind is full price. Round to
2 decimals.
price_for("member", 100) is 90; price_for("vip", 100) is 80.
3 hints and 4 automated checks are waiting in the workspace.
Practice
Make it stick
A second problem on the same idea, so it survives past today.
Implement price_for(kind, amount) in pricing/checkout.py: use the STRATEGIES factory dict
to pick the strategy for kind (defaulting to regular) and apply it to amount. Adding a
strategy must not require editing price_for. Some tests are hidden.
3 hints and 2 automated checks are waiting in the workspace.