Skip to main content

Python Composition Practice Problems & Exercises

Practice: Composition vs Inheritance — When to Use Each at Engineering Depth

11 problems4 Easy4 Medium3 Hard40-55 min
← Back to lesson

Easy

#1Identify Is-A vs Has-AEasy
compositioninheritancedesign

Classify each pair as either an "is-a" (inheritance) or "has-a" (composition) relationship, then verify your answers by running the code.

Python
pairs = [
    ("Dog", "Animal"),
    ("Car", "Engine"),
    ("Manager", "Employee"),
    ("Rectangle", "Shape"),
    ("UserProfile", "Database"),
]

RELATIONSHIP = ["is-a", "has-a", "is-a", "is-a", "has-a"]

for (child, parent), rel in zip(pairs, RELATIONSHIP):
    print(f"{child} -> {parent}: {rel}")
Solution
pairs = [
("Dog", "Animal"),
("Car", "Engine"),
("Manager", "Employee"),
("Rectangle", "Shape"),
("UserProfile", "Database"),
]

RELATIONSHIP = ["is-a", "has-a", "is-a", "is-a", "has-a"]

for (child, parent), rel in zip(pairs, RELATIONSHIP):
print(f"{child} -> {parent}: {rel}")

Explanation: "Is-a" signals inheritance — Dog is a type of Animal. "Has-a" signals composition — a Car has an Engine as a member object. Manager inherits from Employee because a Manager is a specialised Employee. UserProfile holds a reference to a database connection rather than being one.

# Categorise each relationship as "is-a" or "has-a".
# Fill in the RELATIONSHIP list with your answers.

pairs = [
  ("Dog", "Animal"),
  ("Car", "Engine"),
  ("Manager", "Employee"),
  ("Rectangle", "Shape"),
  ("UserProfile", "Database"),
]

RELATIONSHIP = [
  # TODO: fill each entry with "is-a" or "has-a"
]

for (child, parent), rel in zip(pairs, RELATIONSHIP):
  print(f"{child} -> {parent}: {rel}")
Expected Output
Dog -> Animal: is-a\nCar -> Engine: has-a\nManager -> Employee: is-a\nRectangle -> Shape: is-a\nUserProfile -> Database: has-a
Hints

Hint 1: Inheritance expresses "is-a": a Dog IS an Animal.

Hint 2: Composition expresses "has-a": a Car HAS an Engine as a component.


#2Build a Simple Composed ObjectEasy
compositiondelegation

Use composition to give a Car class an Engine. The Car.start() method should delegate to the engine — the simplest form of composition.

Python
class Engine:
    def start(self):
        return "Engine started"

class Car:
    def __init__(self):
        self.engine = Engine()

    def start(self):
        return self.engine.start()

car = Car()
print(car.start())
Solution
class Engine:
def __init__(self):
self._running = False

def start(self):
self._running = True
return "Engine started"

class Car:
def __init__(self):
self.engine = Engine() # has-a Engine

def start(self):
return self.engine.start() # delegation

car = Car()
print(car.start()) # Engine started

Explanation: Car is not an Engine, so inheritance is wrong. Instead we store an Engine as an attribute and forward the start() call. This keeps the two classes independently testable and swappable.

class Engine:
  def start(self):
      return "Engine started"

class Car:
  def __init__(self):
      # TODO: store an Engine instance as self.engine
      pass

  def start(self):
      # TODO: delegate to self.engine.start()
      pass

car = Car()
print(car.start())
Expected Output
Engine started
Hints

Hint 1: Create an Engine instance inside __init__ and assign it to self.engine.

Hint 2: In Car.start(), call self.engine.start() and return the result.


#3Apply a Logging MixinEasy
mixinmultiple-inheritance

Complete the LogMixin.log() method so that any class inheriting from it can log messages prefixed with its own class name in square brackets.

Python
class LogMixin:
    def log(self, message):
        print(f"[{self.__class__.__name__}] {message}")

class Service(LogMixin):
    def process(self, data):
        self.log(f"Processing: {data}")
        return data.upper()

svc = Service()
result = svc.process("hello")
print(result)
Solution
class LogMixin:
def log(self, message: str) -> None:
print(f"[{self.__class__.__name__}] {message}")

class Service(LogMixin):
def process(self, data: str) -> str:
self.log(f"Processing: {data}")
return data.upper()

svc = Service()
result = svc.process("hello")
print(result)
# [Service] Processing: hello
# HELLO

Explanation: self.__class__.__name__ resolves to the actual concrete class at runtime, so the same mixin works for any inheriting class. Mixins are narrow, stateless, and reusable — the ideal use case for multiple inheritance in Python.

class LogMixin:
  def log(self, message):
      # TODO: print "[ClassName] message"
      pass

class Service(LogMixin):
  def process(self, data):
      self.log(f"Processing: {data}")
      return data.upper()

svc = Service()
result = svc.process("hello")
print(result)
Expected Output
[Service] Processing: hello\nHELLO
Hints

Hint 1: Use self.__class__.__name__ to get the runtime class name.

Hint 2: Mixins add behaviour through multiple inheritance without storing state.


#4Replace Inheritance with CompositionEasy
compositionrefactoring

Refactor DataPipeline from an inheritance-based design to a composition-based one. It should hold a CSVReader as a component and delegate the read operation.

Python
class CSVReader:
    def read(self, path):
        return f"reading {path}"

class DataPipeline:
    def __init__(self):
        self.reader = CSVReader()

    def run(self, path):
        return self.reader.read(path)

pipeline = DataPipeline()
print(pipeline.run("data.csv"))
Solution
class CSVReader:
def read(self, path: str) -> str:
return f"reading {path}"

class DataPipeline:
def __init__(self) -> None:
self.reader = CSVReader() # composition: has-a CSVReader

def run(self, path: str) -> str:
return self.reader.read(path) # delegation

pipeline = DataPipeline()
print(pipeline.run("data.csv")) # reading data.csv

Explanation: By holding a CSVReader rather than being one, DataPipeline stays open for extension: you can swap in a JSONReader or ParquetReader without changing DataPipeline's interface.

# Original: inheritance-based design
class CSVReader:
  def read(self, path):
      return f"reading {path}"

# TODO: rewrite DataPipeline to use composition
# instead of inheriting from CSVReader.
class DataPipeline:
  def run(self, path):
      # delegate to a CSVReader instance
      pass

pipeline = DataPipeline()
print(pipeline.run("data.csv"))
Expected Output
reading data.csv
Hints

Hint 1: Store a CSVReader() in __init__ as self.reader.

Hint 2: Call self.reader.read(path) from run().


Medium

#5Inject a Dependency via ConstructorMedium
dependency-injectioncompositiontyping

Implement OrderService so it accepts any object that satisfies the Notifier protocol. The service should work with both EmailNotifier and SMSNotifier without modification.

Python
from typing import Protocol

class Notifier(Protocol):
    def send(self, message: str) -> None: ...

class EmailNotifier:
    def send(self, message: str) -> None:
        print(f"Email: {message}")

class SMSNotifier:
    def send(self, message: str) -> None:
        print(f"SMS: {message}")

class OrderService:
    def __init__(self, notifier: Notifier) -> None:
        self.notifier = notifier

    def place_order(self, item: str) -> None:
        msg = f"Order placed: {item}"
        print(msg)
        self.notifier.send(msg)

svc = OrderService(EmailNotifier())
svc.place_order("Book")
Solution
from typing import Protocol

class Notifier(Protocol):
def send(self, message: str) -> None: ...

class EmailNotifier:
def send(self, message: str) -> None:
print(f"Email: {message}")

class SMSNotifier:
def send(self, message: str) -> None:
print(f"SMS: {message}")

class OrderService:
def __init__(self, notifier: Notifier) -> None:
self.notifier = notifier # dependency injected via constructor

def place_order(self, item: str) -> None:
msg = f"Order placed: {item}"
print(msg)
self.notifier.send(msg)

# Swap notifier without touching OrderService:
svc = OrderService(EmailNotifier())
svc.place_order("Book")

svc2 = OrderService(SMSNotifier())
svc2.place_order("Pen")

Explanation: typing.Protocol provides structural typing — any class with a matching send() method satisfies Notifier without explicit inheritance. Constructor injection makes OrderService testable: in unit tests you pass a fake notifier that captures messages rather than sending real emails.

from typing import Protocol

class Notifier(Protocol):
  def send(self, message: str) -> None:
      ...

class EmailNotifier:
  def send(self, message: str) -> None:
      print(f"Email: {message}")

class SMSNotifier:
  def send(self, message: str) -> None:
      print(f"SMS: {message}")

class OrderService:
  def __init__(self, notifier):
      # TODO: store notifier
      pass

  def place_order(self, item: str) -> None:
      # TODO: print "Order placed: {item}" then notify
      pass

svc = OrderService(EmailNotifier())
svc.place_order("Book")
Expected Output
Order placed: Book\nEmail: Order placed: Book
Hints

Hint 1: Store the injected notifier as self.notifier.

Hint 2: Call self.notifier.send(...) after printing the order confirmation.


#6Mixin Stack — Serialisation and ValidationMedium
mixinmultiple-inheritanceMRO

Implement two mixins: ValidateMixin raises ValueError if any attribute is None, and SerializeMixin serialises the object to JSON. Combine them in a User class.

Python
import json

class ValidateMixin:
    def validate(self):
        for key, val in self.__dict__.items():
            if val is None:
                raise ValueError(f"{key} must not be None")

class SerializeMixin:
    def to_json(self):
        return json.dumps(self.__dict__)

class User(ValidateMixin, SerializeMixin):
    def __init__(self, name, email):
        self.name = name
        self.email = email

u = User("Alice", "[email protected]")
u.validate()
print(u.to_json())
Solution
import json

class ValidateMixin:
def validate(self) -> None:
for key, val in self.__dict__.items():
if val is None:
raise ValueError(f"Field '{key}' must not be None")

class SerializeMixin:
def to_json(self) -> str:
return json.dumps(self.__dict__)

class User(ValidateMixin, SerializeMixin):
def __init__(self, name: str, email: str) -> None:
self.name = name
self.email = email

u = User("Alice", "[email protected]")
u.validate() # no error
print(u.to_json()) # {"name": "Alice", "email": "[email protected]"}

# Fails validation:
try:
bad = User("Bob", None)
bad.validate()
except ValueError as e:
print(e) # Field 'email' must not be None

Explanation: Mixins add orthogonal capabilities. ValidateMixin knows nothing about serialisation and vice versa — they compose cleanly. Python's MRO ensures validate() and to_json() resolve without conflict because neither mixin defines the same method.

import json

class ValidateMixin:
  def validate(self):
      # TODO: raise ValueError if any attribute value is None
      for key, val in self.__dict__.items():
          pass

class SerializeMixin:
  def to_json(self):
      # TODO: return json.dumps of __dict__
      pass

class User(ValidateMixin, SerializeMixin):
  def __init__(self, name, email):
      self.name = name
      self.email = email

u = User("Alice", "[email protected]")
u.validate()
print(u.to_json())
Expected Output
{"name": "Alice", "email": "[email protected]"}
Hints

Hint 1: In ValidateMixin.validate(), iterate self.__dict__.items() and raise ValueError if any value is None.

Hint 2: In SerializeMixin.to_json(), return json.dumps(self.__dict__).


#7Strategy Pattern via CompositionMedium
strategycompositiontyping.Protocol

Implement the Strategy pattern using composition and typing.Protocol. The Sorter class should accept any strategy that satisfies SortStrategy and delegate to it.

Python
from typing import Protocol

class SortStrategy(Protocol):
    def sort(self, data: list) -> list: ...

class BubbleSort:
    def sort(self, data: list) -> list:
        data = list(data)
        n = len(data)
        for i in range(n):
            for j in range(n - i - 1):
                if data[j] > data[j + 1]:
                    data[j], data[j + 1] = data[j + 1], data[j]
        return data

class PythonSort:
    def sort(self, data: list) -> list:
        return sorted(data)

class Sorter:
    def __init__(self, strategy: SortStrategy) -> None:
        self._strategy = strategy

    def execute(self, data: list) -> list:
        return self._strategy.sort(data)

s = Sorter(PythonSort())
print(s.execute([3, 1, 4, 1, 5]))
Solution
from typing import Protocol

class SortStrategy(Protocol):
def sort(self, data: list) -> list: ...

class BubbleSort:
def sort(self, data: list) -> list:
arr = list(data)
n = len(arr)
for i in range(n):
for j in range(n - i - 1):
if arr[j] > arr[j + 1]:
arr[j], arr[j + 1] = arr[j + 1], arr[j]
return arr

class PythonSort:
def sort(self, data: list) -> list:
return sorted(data)

class Sorter:
def __init__(self, strategy: SortStrategy) -> None:
self._strategy = strategy

def swap_strategy(self, strategy: SortStrategy) -> None:
self._strategy = strategy # hot-swap at runtime

def execute(self, data: list) -> list:
return self._strategy.sort(data)

s = Sorter(BubbleSort())
print(s.execute([3, 1, 4, 1, 5])) # [1, 1, 3, 4, 5]

s.swap_strategy(PythonSort())
print(s.execute([3, 1, 4, 1, 5])) # [1, 1, 3, 4, 5]

Explanation: The Strategy pattern is composition at its most explicit. Sorter holds a strategy object and delegates work to it. Swapping strategies at runtime is trivial and requires zero changes to Sorter. The Protocol ensures structural type safety without forcing strategies to inherit from a base class.

from typing import Protocol

class SortStrategy(Protocol):
  def sort(self, data: list) -> list: ...

class BubbleSort:
  def sort(self, data: list) -> list:
      # TODO: implement bubble sort
      pass

class PythonSort:
  def sort(self, data: list) -> list:
      # TODO: use sorted()
      pass

class Sorter:
  def __init__(self, strategy):
      self._strategy = strategy

  def execute(self, data: list) -> list:
      return self._strategy.sort(data)

s = Sorter(PythonSort())
print(s.execute([3, 1, 4, 1, 5]))
Expected Output
[1, 1, 3, 4, 5]
Hints

Hint 1: BubbleSort needs a nested loop comparing adjacent elements and swapping.

Hint 2: PythonSort can return sorted(data).


#8Delegation with __getattr__Medium
delegationdunder-methodscomposition

Implement __getattr__ on TimestampedLogger so that any method not explicitly overridden (like flush) is transparently forwarded to the wrapped FileLogger.

Python
class FileLogger:
    def write(self, msg: str) -> None:
        print(f"FILE: {msg}")

    def flush(self) -> None:
        print("FILE: flushed")

class TimestampedLogger:
    def __init__(self, logger):
        self._logger = logger
        self._prefix = "2026-03-21"

    def write(self, msg: str) -> None:
        self._logger.write(f"{self._prefix} {msg}")

    def __getattr__(self, name: str):
        return getattr(self._logger, name)

tl = TimestampedLogger(FileLogger())
tl.write("Starting up")
tl.flush()
Solution
class FileLogger:
def write(self, msg: str) -> None:
print(f"FILE: {msg}")

def flush(self) -> None:
print("FILE: flushed")

def close(self) -> None:
print("FILE: closed")

class TimestampedLogger:
def __init__(self, logger: FileLogger) -> None:
self._logger = logger
self._prefix = "2026-03-21"

def write(self, msg: str) -> None:
# Override to prepend timestamp
self._logger.write(f"{self._prefix} {msg}")

def __getattr__(self, name: str):
# Transparent delegation: any attr not found on self
# is looked up on the wrapped logger.
return getattr(self._logger, name)

tl = TimestampedLogger(FileLogger())
tl.write("Starting up") # FILE: 2026-03-21 Starting up
tl.flush() # FILE: flushed (delegated)
tl.close() # FILE: closed (also delegated)

Explanation: __getattr__ is only triggered when Python cannot find the attribute through normal means. This makes it ideal for transparent delegation wrappers — you explicitly override what you need to intercept, and let everything else fall through to the wrapped object via getattr.

class FileLogger:
  def write(self, msg: str) -> None:
      print(f"FILE: {msg}")

  def flush(self) -> None:
      print("FILE: flushed")

class TimestampedLogger:
  def __init__(self, logger):
      self._logger = logger
      self._prefix = "2026-03-21"

  def write(self, msg: str) -> None:
      self._logger.write(f"{self._prefix} {msg}")

  def __getattr__(self, name):
      # TODO: forward any unknown attribute to self._logger
      pass

tl = TimestampedLogger(FileLogger())
tl.write("Starting up")
tl.flush()
Expected Output
FILE: 2026-03-21 Starting up\nFILE: flushed
Hints

Hint 1: __getattr__ is only called when normal attribute lookup fails.

Hint 2: Return getattr(self._logger, name) to delegate transparently.


Hard

#9Pluggable Pipeline with ProtocolHard
compositiontyping.Protocoldesign-patterns

Build a composable Pipeline that chains Step objects. Each step transforms a string. The pipeline passes the output of one step as the input to the next, enabling any combination of steps without changing the pipeline class.

Python
from typing import Protocol, List

class Step(Protocol):
    def process(self, data: str) -> str: ...

class StripStep:
    def process(self, data: str) -> str:
        return data.strip()

class UpperStep:
    def process(self, data: str) -> str:
        return data.upper()

class ReplaceStep:
    def __init__(self, old: str, new: str) -> None:
        self.old = old
        self.new = new

    def process(self, data: str) -> str:
        return data.replace(self.old, self.new)

class Pipeline:
    def __init__(self, steps: List[Step]) -> None:
        self.steps = steps

    def run(self, data: str) -> str:
        for step in self.steps:
            data = step.process(data)
        return data

p = Pipeline([StripStep(), UpperStep()])
print(repr(p.run("  hello world  ")))
Solution
from typing import Protocol, List

class Step(Protocol):
def process(self, data: str) -> str: ...

class StripStep:
def process(self, data: str) -> str:
return data.strip()

class UpperStep:
def process(self, data: str) -> str:
return data.upper()

class ReplaceStep:
def __init__(self, old: str, new: str) -> None:
self.old, self.new = old, new

def process(self, data: str) -> str:
return data.replace(self.old, self.new)

class Pipeline:
def __init__(self, steps: List[Step]) -> None:
self.steps = steps

def add_step(self, step: Step) -> "Pipeline":
self.steps.append(step)
return self # fluent interface

def run(self, data: str) -> str:
for step in self.steps:
data = step.process(data)
return data

p = Pipeline([StripStep(), ReplaceStep("world", "Python"), UpperStep()])
print(repr(p.run(" hello world "))) # 'HELLO PYTHON'

Explanation: The pipeline itself knows nothing about individual transformations — it simply owns a list of Step objects and chains their outputs. Adding a new transformation requires zero changes to Pipeline. This is the Open/Closed Principle enacted through composition. The Protocol keeps the design structurally typed without demanding inheritance.

from typing import Protocol, List

class Step(Protocol):
  def process(self, data: str) -> str: ...

class StripStep:
  def process(self, data: str) -> str:
      return data.strip()

class UpperStep:
  def process(self, data: str) -> str:
      return data.upper()

class Pipeline:
  def __init__(self, steps):
      # TODO: store steps
      pass

  def run(self, data: str) -> str:
      # TODO: pass data through each step in order
      pass

p = Pipeline([StripStep(), UpperStep()])
print(repr(p.run("  hello world  ")))
Expected Output
'HELLO WORLD'
Hints

Hint 1: Store the steps list in __init__.

Hint 2: In run(), iterate over self.steps and pass the result of each step into the next.


#10Multi-Mixin Class with Controlled StateHard
mixinmultiple-inheritanceMROdesign

Create CachedAuditedStore by combining AuditMixin, CacheMixin, and DataStore. All three use cooperative super().__init__(**kwargs) — your task is to compose them correctly and demonstrate the combined behaviour.

Python
class AuditMixin:
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self._audit_log = []

    def record(self, action: str) -> None:
        self._audit_log.append(action)

    def audit_trail(self) -> list:
        return list(self._audit_log)

class CacheMixin:
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self._cache = {}

    def cache_get(self, key: str):
        return self._cache.get(key)

    def cache_set(self, key: str, value) -> None:
        self._cache[key] = value

class DataStore:
    def __init__(self, name: str, **kwargs):
        super().__init__(**kwargs)
        self.name = name

    def fetch(self, key: str) -> str:
        return f"{self.name}:{key}"

class CachedAuditedStore(AuditMixin, CacheMixin, DataStore):
    pass

store = CachedAuditedStore(name="users")
val = store.fetch("user_1")
store.cache_set("user_1", val)
store.record(f"fetched {val}")
print(store.cache_get("user_1"))
print(store.audit_trail())
Solution
class AuditMixin:
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._audit_log: list = []

def record(self, action: str) -> None:
self._audit_log.append(action)

def audit_trail(self) -> list:
return list(self._audit_log)

class CacheMixin:
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._cache: dict = {}

def cache_get(self, key: str):
return self._cache.get(key)

def cache_set(self, key: str, value) -> None:
self._cache[key] = value

class DataStore:
def __init__(self, name: str, **kwargs):
super().__init__(**kwargs)
self.name = name

def fetch(self, key: str) -> str:
return f"{self.name}:{key}"

# MRO: CachedAuditedStore -> AuditMixin -> CacheMixin -> DataStore -> object
class CachedAuditedStore(AuditMixin, CacheMixin, DataStore):
pass

store = CachedAuditedStore(name="users")
val = store.fetch("user_1")
store.cache_set("user_1", val)
store.record(f"fetched {val}")

print(store.cache_get("user_1")) # users:user_1
print(store.audit_trail()) # ['fetched users:user_1']

# Inspect MRO:
print([c.__name__ for c in CachedAuditedStore.__mro__])

Explanation: The cooperative super().__init__(**kwargs) chain ensures every __init__ in the MRO is called exactly once and in the correct order. AuditMixin and CacheMixin each initialise their own private state without knowing about each other. name travels through the **kwargs pipeline until it reaches DataStore. This is the canonical pattern for mixin composition in Python.

class AuditMixin:
  def __init__(self, **kwargs):
      super().__init__(**kwargs)
      self._audit_log = []

  def record(self, action: str) -> None:
      self._audit_log.append(action)

  def audit_trail(self) -> list:
      return list(self._audit_log)

class CacheMixin:
  def __init__(self, **kwargs):
      super().__init__(**kwargs)
      self._cache = {}

  def cache_get(self, key: str):
      return self._cache.get(key)

  def cache_set(self, key: str, value) -> None:
      self._cache[key] = value

class DataStore:
  def __init__(self, name: str, **kwargs):
      super().__init__(**kwargs)
      self.name = name

  def fetch(self, key: str) -> str:
      return f"{self.name}:{key}"

# TODO: create CachedAuditedStore combining all three
# Then demonstrate: fetch a key, cache it, record the action.

store = CachedAuditedStore(name="users")
val = store.fetch("user_1")
store.cache_set("user_1", val)
store.record(f"fetched {val}")
print(store.cache_get("user_1"))
print(store.audit_trail())
Expected Output
users:user_1\n['fetched users:user_1']
Hints

Hint 1: Combine AuditMixin, CacheMixin, DataStore in that order.

Hint 2: All three __init__ methods use **kwargs and call super().__init__(**kwargs) so the MRO chain works correctly.

Hint 3: CachedAuditedStore just needs to pass name through to DataStore.


#11Refactor a Deep Inheritance Hierarchy to CompositionHard
compositionrefactoringdesigntyping.Protocol

Refactor the four-level inheritance chain into a flat TrainedDog class that uses composition only. Create separate behaviour classes and inject them. The public API (breathe, cuddle, bark, sit) must remain unchanged.

Python
class BreathBehaviour:
    def breathe(self) -> str:
        return "breathing"

class PetBehaviour:
    def cuddle(self) -> str:
        return "cuddling"

class DogBehaviour:
    def bark(self) -> str:
        return "woof"

class TrainingBehaviour:
    def sit(self) -> str:
        return "sitting"

class TrainedDog:
    def __init__(self):
        self._breath = BreathBehaviour()
        self._pet = PetBehaviour()
        self._dog = DogBehaviour()
        self._training = TrainingBehaviour()

    def breathe(self) -> str:
        return self._breath.breathe()

    def cuddle(self) -> str:
        return self._pet.cuddle()

    def bark(self) -> str:
        return self._dog.bark()

    def sit(self) -> str:
        return self._training.sit()

td = TrainedDog()
print(td.breathe())
print(td.cuddle())
print(td.bark())
print(td.sit())
Solution
from typing import Protocol

class Breather(Protocol):
def breathe(self) -> str: ...

class Cuddler(Protocol):
def cuddle(self) -> str: ...

class Barker(Protocol):
def bark(self) -> str: ...

class Trainer(Protocol):
def sit(self) -> str: ...

class BreathBehaviour:
def breathe(self) -> str:
return "breathing"

class PetBehaviour:
def cuddle(self) -> str:
return "cuddling"

class DogBehaviour:
def bark(self) -> str:
return "woof"

class TrainingBehaviour:
def sit(self) -> str:
return "sitting"

class TrainedDog:
def __init__(
self,
breath: Breather = None,
pet: Cuddler = None,
dog: Barker = None,
training: Trainer = None,
) -> None:
self._breath = breath or BreathBehaviour()
self._pet = pet or PetBehaviour()
self._dog = dog or DogBehaviour()
self._training = training or TrainingBehaviour()

def breathe(self) -> str:
return self._breath.breathe()

def cuddle(self) -> str:
return self._pet.cuddle()

def bark(self) -> str:
return self._dog.bark()

def sit(self) -> str:
return self._training.sit()

td = TrainedDog()
print(td.breathe()) # breathing
print(td.cuddle()) # cuddling
print(td.bark()) # woof
print(td.sit()) # sitting

Explanation: The deep hierarchy created a fragile coupling chain — changing Animal affected every subclass four levels below. The composed version is flat: each behaviour is an independent object that can be tested, swapped, and evolved without touching any other. The optional constructor injection also enables unit testing with fakes, and means future variants (e.g., a RobotDog with no PetBehaviour) can substitute any component independently.

# Fragile deep hierarchy — refactor this
class Animal:
  def breathe(self): return "breathing"

class Pet(Animal):
  def cuddle(self): return "cuddling"

class Dog(Pet):
  def bark(self): return "woof"

class TrainedDog(Dog):
  def sit(self): return "sitting"

# TODO: redesign using composition + Protocol so that
# TrainedDog does not inherit from any of the above.
# It should hold behaviour objects and delegate.

class TrainedDog:
  pass

td = TrainedDog()
print(td.breathe())
print(td.cuddle())
print(td.bark())
print(td.sit())
Expected Output
breathing\ncuddling\nwoof\nsitting
Hints

Hint 1: Create separate behaviour classes: BreathBehaviour, PetBehaviour, DogBehaviour, TrainingBehaviour.

Hint 2: Store them in TrainedDog.__init__ and delegate each method to the matching behaviour object.

© 2026 EngineersOfAI. All rights reserved.