LESSON 59 · DEEPER PYTHON31 min

Choose Composition Over Unnecessary Inheritance

Build a service around an injected sender object so its behavior can change without subclassing the service.

Example check: Worked and guided code samples are checked against CPython 3.13. Practice uses Pyodide 0.29.5 (Python 3.13) in your browser. How we check lessons

Video lesson: Choose Composition Over Unnecessary Inheritance

The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube

Read the video transcript

Select a timestamp to watch that moment on YouTube.

  1. 00:00

    Classes can collaborate in more than one way. Inheritance says a specialized object is a kind of its base class. Composition says an object has another object as a part or dependency. A notification service uses a sender to deliver messages; it is not itself an email sender. If we inherit from EmailSender merely to reuse send, the service becomes coupled to email delivery. We will run that design, try to inject a different sender, and observe its limitation. Then we will make the sender an explicit constructor dependency and test the service with a recording fake. This is a design bug rather than a syntax typo, so the visible demonstration matters.

  2. 00:44

    The broken version can send one email-style message, but it cannot accept an arbitrary compatible sender through its constructor. The companion PythonLessonLab lesson has a browser console, solved examples, a checked task, mistakes, and a quiz. Before the run, say the relationship aloud: does NotificationService is-a EmailSender sound true, or does NotificationService has-a sender sound true? Use the answer to predict which design will be easier to vary and test. Inheritance remains valuable when a subclass really honors a stable base contract; this lesson shows the cost of using it just to borrow a method.

  3. 01:26

    The first notification works and returns email: Ready. That is why this design can survive an initial demonstration. But when we try to construct the same service with LogSender, Python raises TypeError. The class inherited email behavior instead of accepting a sender dependency. We could create another subclass, add conditionals, or rewrite the hierarchy, but the service's real job is to coordinate a sender that satisfies a small interface. It should not need a new class for every delivery mechanism. Notice that LogSender has a send method with the same basic contract.

  4. 02:06

    The obstacle is not that it lacks behavior; the obstacle is that our service design has no place to receive it. An inheritance relationship can also make readers think the service is substitutable wherever an EmailSender is expected, even if that is not what the domain means. The failure here is observable at construction time, but the underlying issue is a false is-a relationship. We will compare the two relationships directly, then move sender into an instance attribute. In Python, a small collaborating object does not need to inherit from a shared base class to be useful. A Protocol can document the expected shape for type checking later, but the runtime call remains simple.

  5. 02:51

    issubclass reports True because the notification class was declared as a subclass of EmailSender. LogSender can also send a message, and hasattr confirms it exposes the method name we need. The class tree is not representing the service's actual dependency. A notification service should store whichever sender it receives and delegate one message to it. That is composition: the service has a sender. It does not need to claim that every service is an email sender. This shift also improves testing. A fake sender can record calls without opening a network connection or printing to a real console.

  6. 03:32

    The test can ask whether the message was passed unchanged, whether send was called exactly once, and whether its result was returned. Those are the behaviors users of the service care about. Merely checking that a subclass exists would not verify them. Composition does not mean inheritance is forbidden. A sender family might have a useful shared abstraction in another design. The key is to place inheritance where a true substitution relationship exists, and to use an instance-held collaborator when one component uses another. Now we will implement a small service with only a constructor and notify method.

  7. 04:12

    The service now works with either sender instance without changing its class. The constructor stores the dependency in self.sender. notify calls send once with the original message and returns the result. Returning matters: if we only called send and forgot the return statement, notify would return None even when the sender produced a receipt. We used one PrefixSender class configured two ways for a compact demonstration, but two unrelated classes with compatible send methods would also work. This small requirement is sometimes called duck typing: the service cares about the operation it needs, not the sender's family tree.

  8. 04:54

    Keep the expected contract clear. A real sender may raise an exception, return a message identifier, or perform I/O. The service should decide whether to propagate or handle those outcomes based on product needs. This exercise chooses straightforward delegation and preserves the returned value. In the browser task, you are given an EchoSender and asked to complete NotificationService. The checker also injects a fake sender that records calls. If you hard-code EchoSender inside the service, the prepared example may appear to work, but the fake test will expose the coupling. Store the object passed by the caller instead.

  9. 05:35

    The first call returns receipt one, and the second returns receipt two. The fake records exactly First and Second in order, so the service did not duplicate, strip, or replace either message. The assertions do not depend on EmailSender, a network, or a global log. They verify the collaboration boundary. The printed message list makes the call history visible before the success line. If you remove return from notify, the receipt assertions fail. If you instantiate a new sender inside notify, the recording fake sees no messages and another assertion fails. Those failures point directly to the design contract.

  10. 06:16

    Complete the browser lesson using the sender supplied to __init__, not a hard-coded class. Then answer the quiz about is-a and has-a relationships. Composition often makes systems easier to vary, but it is not a universal command to avoid inheritance. If a subclass genuinely is a substitutable specialized form of a base class, inheritance can express that clearly. Here the domain sentence is simpler: a notification service has a sender and asks it to send. Model that sentence directly and you get a small service, independent transport choices, and tests that observe what actually happened.

Understand the concept

Inheritance models an is-a relationship: a specialized class reuses or extends a base class's contract. Composition models a has-a relationship: one object holds another object and delegates work to it. A notification service is not a kind of email sender; it uses a sender. Making it inherit EmailSender couples it to one delivery mechanism and forces a subclass when the mechanism changes.

A composed NotificationService accepts a sender object in its constructor and calls sender.send(message) in notify. This is dependency injection in simple form. The service can use EmailSender, ConsoleSender, or a fake sender in tests without changing its own code. Python's dynamic typing makes this easy: the object needs a compatible send method, even if it does not inherit from a common base class.

The task defines NotificationService with __init__(sender) and notify(message). notify must return exactly what sender.send(message) returns, so the caller can observe a receipt. It should call send once and pass the message unchanged. Hidden checks use a small fake object to record calls; that verifies the behavior instead of relying on a particular class name or print output.

Inheritance still has good uses when subclasses truly satisfy a common interface and shared behavior is stable. Composition does not eliminate design choices: the injected dependency should have a clear contract and errors should be handled at the right boundary. For this lesson, the rule is practical: if you say the object uses another object, store and delegate; if you say it is a valid substitute for a base type, consider inheritance.

  • Inheritance expresses an is-a relationship
  • Composition expresses a has-a relationship
  • Injected dependencies make testing easier
  • Delegation should preserve inputs and return values

See it step by step

Read the code, predict the output, then compare it with the result.

01. Inject one of two senders

PYTHON
class PrefixSender:
    def __init__(self, prefix):
        self.prefix = prefix
    def send(self, message):
        return self.prefix + message

class NotificationService:
    def __init__(self, sender):
        self.sender = sender
    def notify(self, message):
        return self.sender.send(message)

print(NotificationService(PrefixSender('email: ')).notify('Ready'))
print(NotificationService(PrefixSender('log: ')).notify('Ready'))
EXPECTED OUTPUT
email: Ready
log: Ready

The service delegates to an injected sender; its code is unchanged when the sender changes.

02. Use a fake sender to observe a call

PYTHON
class RecordingSender:
    def __init__(self):
        self.messages = []
    def send(self, message):
        self.messages.append(message)
        return len(self.messages)

sender = RecordingSender()
print(sender.send('First'))
print(sender.send('Second'))
print(sender.messages)
EXPECTED OUTPUT
1
2
['First', 'Second']

A test double records the exact messages and returns simple receipts.

A closer look

Follow the reasoning, inspect each result, then try the suggested changes in the console below.

01 / 03

Test the relationship before using inheritance

Inheritance can represent a specialized object that is a valid substitute for a base class. A notification service is not a kind of email sender; it coordinates delivery through a sender. An inherited send method works at first, but it couples the service to the email implementation. Switching transport then pressures us to change the class hierarchy.

This small example shows the coupling. EmailNotificationService inherits the email sender's behavior and can only use that route. If we want a log sender, we would need another subclass or conditionals. The issue is a design mismatch, not a syntax error. Use inheritance when the subclass really maintains the base contract, not simply to borrow one method.

PYTHON
class EmailSender:
    def send(self, message):
        return 'email: ' + message

class EmailNotificationService(EmailSender):
    def notify(self, message):
        return self.send(message)

print(EmailNotificationService().notify('Ready'))
EXPECTED OUTPUT
email: Ready
Follow the reasoning
  1. The service inherits send from EmailSender.
  2. notify works but delivery is fixed to email.
  3. Ask whether the service is truly an EmailSender.
02 / 03

Inject a dependency and delegate

Composition stores a sender object inside the service. The constructor receives the dependency; notify passes the message to sender.send and returns the result. The same service code now works with different sender implementations. Each sender needs the compatible send method, but neither sender must inherit from a common base class for this small example.

Notice that notify does not add spaces, strip text, or call send twice. Such changes would be separate features requiring a stated contract. Delegation should preserve the message and result. A caller can choose the sender at construction time, which is useful when configuration differs between production, development, and tests.

PYTHON
class PrefixSender:
    def __init__(self, prefix):
        self.prefix = prefix
    def send(self, message):
        return self.prefix + message

class NotificationService:
    def __init__(self, sender):
        self.sender = sender
    def notify(self, message):
        return self.sender.send(message)

print(NotificationService(PrefixSender('email: ')).notify('Ready'))
print(NotificationService(PrefixSender('log: ')).notify('Ready'))
EXPECTED OUTPUT
email: Ready
log: Ready
Follow the reasoning
  1. The service stores the supplied sender.
  2. notify delegates exactly once and returns the receipt.
  3. Changing sender objects leaves service code untouched.
03 / 03

Verify behavior with a recording fake

A fake sender in a test can record every message and return a simple receipt. It has no network connection or email side effect. The service under test receives the fake through the same constructor used for real senders. This checks the collaboration between objects rather than a specific inheritance tree.

Run two notifications and inspect both receipts and the recorded calls. The task checker uses a similar fake to catch double delivery, modified input, and a dropped return value. In a larger application, a Protocol can document the sender shape statically, but the runtime delegation remains the same. Keep the interface small and meaningful.

PYTHON
class RecordingSender:
    def __init__(self):
        self.messages = []
    def send(self, message):
        self.messages.append(message)
        return len(self.messages)

class NotificationService:
    def __init__(self, sender):
        self.sender = sender
    def notify(self, message):
        return self.sender.send(message)

sender = RecordingSender()
service = NotificationService(sender)
print(service.notify('First'))
print(service.notify('Second'))
print(sender.messages)
EXPECTED OUTPUT
1
2
['First', 'Second']
Follow the reasoning
  1. The fake records inputs without sending anything externally.
  2. Each call returns one receipt.
  3. The two recorded messages prove exactly one delegation per notify call.

Try it in Python

Edit the example and run it. Python starts in your browser the first time you click Run.

Python console

Ready to run
Need input()? Add one value per line
Ctrl / ⌘ + Enter to run
OUTPUT
Your output appears here.
Your turnComplete NotificationService. Store the supplied sender and have notify(message) call sender.send(message) exactly once, passing the unchanged message and returning the result. The prepared output is sent: Hello.
Need a hint?

Assign self.sender = sender in __init__. In notify, return self.sender.send(message).

Complete the task and select Check task to verify your code.

    Quick quiz

    Three questions. You can change your answers and try again.

    01.What relationship does composition describe here?

    02.Why can a fake sender test NotificationService?

    03.What should notify do with the send result in this task?

    Typical mistakes

    Everyone meets these errors. See what causes them and how to fix them.

    Inheriting from a dependency

    COMMON MISTAKE
    class NotificationService(EmailSender):
        def notify(self, message):
            return self.send(message)
    FIX
    class NotificationService:
        def __init__(self, sender): self.sender = sender
        def notify(self, message): return self.sender.send(message)

    What happens: Changing to another sender requires changing the class hierarchy.
    The service uses a sender rather than being one.

    Dropping the delegated result

    COMMON MISTAKE
    def notify(self, message):
        self.sender.send(message)
    FIX
    def notify(self, message):
        return self.sender.send(message)

    What happens: notify returns None even if send produces a receipt.
    Return the dependency's result when it is part of the service contract.

    Browse this lesson’s problem fixes

    Sources and further reading