SOLID design: depend on abstractions

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

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

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
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

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