Python · Course navigation

SOLID design: depend on abstractions

Pass behavior into a class so it can be extended without being edited.

On this page
Before you start

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

Python
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))
Output
NoDiscount 3500
PercentOff 3150
FlatOff 3000

Follow the code

Open the walkthrough to inspect the example one step at a time.

Show guided walkthrough
Guided walkthroughReady
1class NoDiscount:
2 def apply(self, amount):
3 return amount
4
5
6class PercentOff:
7 def __init__(self, percent):
8 self.percent = percent
9
10 def apply(self, amount):
11 return amount * (100 - self.percent) // 100
12
13
14class FlatOff:
15 def __init__(self, cents):
16 self.cents = cents
17
18 def apply(self, amount):
19 return max(amount - self.cents, 0)
20
21
22class Checkout:
23 def __init__(self, rule):
24 self.rule = rule
25
26 def total(self, prices):
27 return self.rule.apply(sum(prices))
28
29
30cart = [1000, 2500]
31for rule in (NoDiscount(), PercentOff(10), FlatOff(500)):
32 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
Prepared trace of the published program. Some rows group repeated iterations.
Step / lineWhat happensState after stepOutput 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.

Further reading