Split responsibilities among injected collaborators, delegate through shared behavior, test subtype promises, and replace brittle inheritance with clearer composition.
You will learn: Separate responsibilities, inject and replace collaborators, use duck typing and delegation, override behavior safely, and accept inheritance only when every subtype preserves the base contract.
Practice in: Google Colab, JupyterLab, or a local Python interpreter
An Explorer object begins by storing a name and energy. Then it chooses routes, formats messages, rolls random weather, saves progress, and sends notifications. The class now changes for unrelated reasons and is hard to exercise without the outside world.
The solution is not automatically a family tree. First ask which responsibilities can collaborate through small, visible behaviors. Composition lets an object receive and use another object. Inheritance says something stronger: every specialized object can stand in for the base object without surprising its callers.
This lesson asks:
Which object should own each decision?
What is the difference between containing, depending on, and delegating?
How can behavior vary without a shared parent class?
What promise must an inheritance relationship preserve?
When is a function, dictionary, or dataclass still the simpler choice?
This class is not yet enormous, but it already owns three decisions:
explorer state and travel rules;
route-selection policy; and
presentation text.
Counting methods is not enough. A class with twelve cohesive methods can be healthier than a class with three unrelated ones. Ask what would cause the code to change. A new route algorithm should not risk the energy invariant. A new display language should not alter travel.
The constructor receives planner rather than constructing it. The explorer depends on an object with choose(routes) behavior. It stores that collaborator and delegates route choice to it.
Python commonly supports duck typing: code relies on available behavior rather than requiring an object’s class to appear in one hierarchy. If both planners can accept the route collection and return one route under the stated failure contract, the explorer can collaborate with either.
The behavior still needs a contract:
Planner operation
Promise
choose(routes)
accept a non-empty iterable of route records
success
return one of the supplied records
empty input
raise ValueError("routes cannot be empty")
side effects
do not mutate the supplied records or explorer
A matching method name alone is not enough. This object has a choose method but violates the return contract:
class BrokenPlanner:def choose(self, routes):return"surprise"
Explorer.choose_route can call it, but later code expecting route["distance"] will fail. Behavioral substitution includes inputs, outputs, failures, and side effects.
Distinguish ownership, reference, dependency, and delegation
Object diagrams often draw the same arrow for several relationships. Name the relationship in prose so readers know what lifecycle and mutation to expect:
Ownership: a Tour copies and controls its internal stop sequence. The sequence should not change because the caller appends to its original list.
Reference: a TurnResult may refer to an immutable position value without controlling a separate lifecycle.
Dependency: an Explorer requires planner behavior to complete route choice.
Delegation:Explorer.choose_route forwards the selection request to that planner and returns its result.
Composition does not automatically imply exclusive ownership. In the current constructor, the caller creates the planner and the explorer retains a reference to it. If two explorers receive the same stateful planner, they intentionally or accidentally share that collaborator:
That can be useful for shared metrics, but the constructor and documentation should make the choice visible. Copying an arbitrary collaborator is rarely a safe fix: a copy may duplicate file handles, caches, or configuration in surprising ways. Decide lifecycle at the design boundary instead.
The caller can also pass a simple fake object while investigating behavior:
class FirstRoute:def choose(self, routes):return routes[0]predictable = Explorer("Cy", FirstRoute(), energy=10)assert predictable.choose_route(routes) is routes[0]
No testing framework is required to benefit from a replaceable dependency. The small fake makes the explorer’s delegation observable.
The expedition owns the workflow; the collaborator owns condition selection. Unit 13 will use this same seam for test doubles. Here, it simply makes the design predictable.
Composition places the policy beside the object that uses it. The caller chooses which compatible policy to supply.
UrgentNotification(Notification) declares inheritance. super().__init__ continues to the base initializer so recipient validation and shared assignments remain in one place. Its render override extends the base result.
The subtype changes formatting while preserving the operation’s input and non-empty-string result. This is a small, honest hierarchy.
super() follows the class relationship
super() is not “call my parent by name.” It creates a proxy that continues method lookup after the current class according to Python’s method resolution order. In this shallow example, that reaches Notification. Using super() supports cooperative behavior better than hard-coding Notification.__init__(self, ...).
This unit stops at single, shallow inheritance. Multiple inheritance and deep method-resolution design need more context and belong in an advanced course.
4. Reject a subtype that breaks the caller’s promise
Suppose a base Storage promises that save(text) accepts any string and returns the stored character count:
class Storage:def__init__(self):self.items = []def save(self, text):self.items.append(text)returnlen(text)
This subtype is tempting but dishonest:
class NonEmptyStorage(Storage):def save(self, text):ifnot text:raiseValueError("empty text is forbidden")returnsuper().save(text)
The base accepts "", but the subtype rejects it. Code written for the base contract can fail when handed the subtype:
def archive_values(storage, values):return [storage.save(value) for value in values]assert archive_values(Storage(), ["map", ""]) == [3, 0]try: archive_values(NonEmptyStorage(), ["map", ""])exceptValueErroras error:print(error)
An “is-a storage” sentence is not enough. The subtype strengthened the precondition and broke substitutability. Better options include:
change the base contract if all storage should reject empty text;
validate before calling storage, where that application rule belongs; or
compose a filtering/validation policy with storage rather than pretending it is a subtype.
Use a substitution review:
Contract part
Subtype question
accepted inputs
Does it accept everything the base promises?
successful result
Does it return a result callers can use the same way?
failure behavior
Does it introduce surprising failure for valid base input?
state effects
Does it preserve the documented postconditions?
public attributes/methods
Are inherited promises still meaningful?
Inheritance is a public relationship. A subclass can technically access underscore-prefixed base state, but depending on every internal detail makes the base fragile: a harmless refactor can break subclasses. Design extension points intentionally or prefer composition.
5. Replace a brittle hierarchy with a collaborator
Suppose a game models movement this way:
class MovingActor:def move(self, position):raiseNotImplementedErrorclass WalkingActor(MovingActor):def move(self, position):return position +1class FlyingWalkingActor(WalkingActor):def move(self, position):return position +3
The hierarchy mixes what the actor is with a movement rule that might change during the game. A power-up could make a walking actor fly temporarily. Changing the object’s class at runtime is awkward.
Compose a movement policy instead:
class StepMovement:def__init__(self, distance=1):self.distance = distancedef move(self, position):return position +self.distanceclass Actor:def__init__(self, name, movement):self.name = nameself.movement = movementself.position =0def advance(self):self.position =self.movement.move(self.position)returnself.position
actor = Actor("Pip", StepMovement(1))assert actor.advance() ==1actor.movement = StepMovement(3)assert actor.advance() ==4
The actor owns identity and current position. The policy owns how a position advances. This design supports a temporary change without a class explosion.
Composition is not always better. If UrgentNotification is a stable semantic kind of notification and preserves the contract, inheritance communicates that relationship well. Prefer composition as the default for reuse and varying behavior; accept inheritance when substitutability makes the relationship true.
Inheritance fixes a subtype relationship. Composition attaches replaceable behavior to an object that keeps its own identity.
flowchart LR
sub[Urgent notification] -->|is substitutable for| base[Notification]
actor[Actor] -->|has movement policy| move[Step movement]
actor -->|policy can change| other[Other movement]
6. Use the smallest honest structure
Before creating a class, walk through this table:
Structure
Strong signal
Warning sign
function
input becomes output; no owned evolving state
hidden global mutation
dictionary/list
local, flexible data shape
many callers rely on magic keys and shared rules
dataclass
fields define value, representation, equality
unique entity is accidentally equal by fields
regular class
coherent state changes through guarded behavior
class only wraps one stateless function
composition
separate responsibilities collaborate or vary
dozens of tiny objects only forward calls
inheritance
stable subtype preserves one public contract
used only to borrow code or satisfy “is-a” wording
Watch for these smells:
every noun in a requirement becomes a class;
every field receives a getter and setter without a rule;
a Manager, Controller, or System owns every unrelated task;
a one-method class has no configuration, state, or replaceable behavior;
subclasses inspect and mutate several base underscore fields;
hierarchies grow a new subtype for every combination of features;
a class exists only to make two lines “object-oriented.”
A module function can be a public, testable abstraction. A dictionary can be the clearest record. The aim is coherent ownership, not ceremony.
Review a design with change scenarios
Static diagrams can make almost any decomposition look tidy. Test the design by asking where realistic changes land:
Add a third route strategy. Ideally add one collaborator and selection configuration, not edit every explorer method.
Change energy validation. Ideally edit the entity that owns energy, not each planner.
Display status in another language. Ideally change a presenter or formatter, not route algorithms.
Record planner calls. Ideally decorate or compose the planner behavior, not create subclasses for every explorer-policy combination.
Make a planner stateful. Decide whether it is shared or independently constructed; do not let that lifecycle be accidental.
When one small requirement crosses most classes, either the requirement is inherently cross-cutting or responsibilities have been split along the wrong lines. Use the change scenario as evidence, not as proof that more classes are always needed.
7. Design a museum tour with replaceable route policies
Build these types:
from dataclasses import dataclass@dataclass(frozen=True)class Stop: name: str floor: int popularity: intclass FloorOrder:def arrange(self, stops):"""Return stops ordered by floor, then name.""" ...class PopularFirst:def arrange(self, stops):"""Return stops by descending popularity, then name.""" ...class Tour:def__init__(self, title, stops, policy):"""Store a non-empty title, copied stops, and an arrangement policy.""" ...def itinerary(self):"""Delegate arrangement and return an independent list of stop names.""" ...
Contracts:
a stop name is non-blank, floor is non-negative, and popularity is 0–5;
Tour requires at least one stop and stores its own list copy;
each policy returns a new list without mutating the supplied collection;
itinerary() delegates to the current policy;
changing tour.policy changes arrangement, not tour identity or stop data; and
no project-defined base class is needed for the two policies.
Why is composition more useful than inheriting PopularTour(Tour) here?
Which part could remain a function without harming the design?
Hint 1
Validate Stop in __post_init__. In Tour.__init__, use self.stops = list(stops) so later mutation of the caller’s list does not alter the tour.
Hint 2
FloorOrder.arrange can use sorted(stops, key=lambda stop: (stop.floor, stop.name)). The popular policy can sort with (-stop.popularity, stop.name).
Hint 3
Tour.itinerary asks self.policy.arrange(self.stops) and converts the returned stops to names. It should not use isinstance to choose an algorithm; the collaborator owns that decision.
Compare a complete composed tour
from dataclasses import dataclass@dataclass(frozen=True)class Stop: name: str floor: int popularity: intdef __post_init__(self): cleaned =self.name.strip()ifnot cleaned:raiseValueError("name cannot be blank")ifself.floor <0:raiseValueError("floor cannot be negative")ifnot0<=self.popularity <=5:raiseValueError("popularity must be between 0 and 5")object.__setattr__(self, "name", cleaned)class FloorOrder:def arrange(self, stops):returnsorted(stops, key=lambda stop: (stop.floor, stop.name))class PopularFirst:def arrange(self, stops):returnsorted(stops, key=lambda stop: (-stop.popularity, stop.name))class Tour:def__init__(self, title, stops, policy): cleaned = title.strip() copied_stops =list(stops)ifnot cleaned:raiseValueError("title cannot be blank")ifnot copied_stops:raiseValueError("stops cannot be empty")self.title = cleanedself.stops = copied_stopsself.policy = policydef itinerary(self): arranged =self.policy.arrange(self.stops)return [stop.name for stop in arranged]stops = [ Stop("Moon Engine", 2, 5), Stop("Clockwork Birds", 1, 3), Stop("Amber Maps", 1, 4),]tour = Tour("Museum Sprint", stops, FloorOrder())assert tour.itinerary() == ["Amber Maps", "Clockwork Birds", "Moon Engine"]tour.policy = PopularFirst()assert tour.itinerary() == ["Moon Engine", "Amber Maps", "Clockwork Birds"]
The two policies happen to be stateless and could be functions passed into the tour. The small policy classes are justified here because they make a named, replaceable behavior visible; a callable-function design would also be honest.