You might often hear from developers the phrase “composition over inheritance”. But what does it mean? And why is it important? And how can it improve your codes maintainability and expandability? Well, let me show you!
What is composition?
Composition involves merging different elements to create something new, specifically, incorporating interface implementations instead of depending on inheritance. This approach, commonly known as dependency injection, is an essential principle for software developers to understand. Although composition utilizes dependency injection, they are not identical. Dependency injection can exist independently of composition, but composition always requires dependency injection.
Note: I use the term interface
throughout this article, and to avoid any potential confusion,
I mean this to mean the more generalized OOP concept of interfaces as defined here.
The rationale behind composition
When we’re developing a piece of software, there are a few things we generally try to avoid, and one of the big ones is coupling, where objects have a hard reliance upon each other.
This can cause problems down the line when we want to change some implementation details somewhere, and this is a problem because small changes can have widespread implications.
Inheritance represents a potent form of coupling because alterations to a superclass can ripple down to affect its subclasses. Such changes might necessitate widespread modifications across our codebase, incurring significant time and financial costs. Coupling also occurs when a class is dependent on another class. While this scenario is somewhat more manageable than inheritance, it still poses challenges. Altering the dependent class could force changes in the class that relies on it. To address these issues, composition is an effective strategy. Conceptually, composition relies on adhering to an interface’s contract, allowing flexibility in implementation as long as it meets the contract’s requirements. Practically, this often involves creating an abstract class or a protocol, followed by a class that implements it. This approach facilitates the interchangeability of implementations without necessitating alterations in any class that depends on that interface.
Expanding upon the idea of composition with design patterns
Composition is the backbone of many design patterns, such as strategy, pattern, and adapter patterns. These patterns are all very useful, and they all rely on composition to function. Composition can also be used on a functional level and is a key part of functional programming.
In practice
Let’s take a look at an example.
class EmailNotificationService:
def __init__(self):
self.notification_queue = []
def add_notification(self, email_address, subject, body):
self.notification_queue.append((email_address, subject, body))
def send_notifications(self):
for notification in self.notification_queue:
self.send_notification(notification)
def send_notification(self, notification):
email_address, subject, body = notification
print(f"Sending email to {email_address} with subject {subject} and body {body}")
def clear_notifications(self):
self.notification_queue = []
The current code isn’t inherently bad, however, it does present certain challenges. For instance, introducing a new service necessitates creating a new class, and adding a new notification type requires appending a method to the service. Suppose we decide to support push notifications; this would involve both adding a new method to the service and developing a new class dedicated to handling these notifications. Such a process increases the workload, complicates maintenance, and heightens the risk of introducing bugs.
We could take the approach of making an abstract notification service.
from abc import ABC, abstractmethod
class NotificationService(ABC):
def __init__(self):
self.notification_queue = []
def add_notification(self, notification):
self.notification_queue.append(notification)
def send_notifications(self):
for notification in self.notification_queue:
self.send_notification(notification)
@abstractmethod
def send_notification(self, notification):
pass
def clear_notifications(self):
self.notification_queue = []
class EmailNotificationService(NotificationService):
def send_notification(self, notification):
print(f"Sending email to {notification.email_address} with subject {notification.subject} and body {notification.body}")
class EmailNotification:
def __init__(self, email_address, subject, body):
self.email_address = email_address
self.subject = subject
self.body = body
class PushNotificationService(NotificationService):
def send_notification(self, notification):
print(f"Sending SMS to {notification.phone_number} with message {notification.message}")
And while this is an improvement, we still have to write a new subclass for each notification type. This means we still have coupling in our code, and changing the interface means we may have to change all of our subclasses.
Our class is also still responsible for too much, and to handle many types, we would need many instances, one of each to handle each type of request. This is where composition comes in.
from typing import Protocol
class Notification(Protocol):
def send(self):
pass
class NotificationService:
def __init__(self):
self.notification_queue = []
def add_notification(self, notification::P
Notificatio):
self.notification_queue.append(notification)
def send_notifications(self):
for notification in self.notification_queue:
notification.send()
def clear_notifications(self):
self.notification_queue = []
class EmailNotification:
def __init__(self, email_address, subject, body):
self.email_address = email_address
self.subject = subject
self.body = body
def send(self):
print(f"Sending email to {self.email_address} with subject {self.subject} and body {self.body}")
class SMSNotification:
def __init__(self, phone_number, message):
self.phone_number = phone_number
self.message = message
def send(self):
print(f"Sending SMS to {self.phone_number} with message {self.message}")
class PushNotification:
def __init__(self, device_id, message):
self.device_id = device_id
self.message = message
def send(self):
print(f"Sending push notification to {self.device_id} with message {self.message}")
class SlackNotification:
def __init__(self, channel, message):
self.channel = channel
self.message = message
def send(self):
print(f"Sending slack message to {self.channel} with message {self.message}")
In this structure, each notification manages its implementation, while the service’s sole role is dispatching notifications. This streamlines the introduction of new notification types; instead of depending on inheritance, we simply create a new class adhering to the notification protocol. This results in enhanced modularity and maintainability, alongside clear separation of responsibilities: the NotificationService focuses on sending notifications, and each notification is tasked with reaching its intended target. A significant advantage of this method in software development is the ease of testing. It’s possible to test each notification type and the notification service independently, without interdependence. This allows for the use of mock data and states as needed, enabling isolated component testing. Such an approach is invaluable for debugging, offering precise identification and resolution of bugs.
To see more examples of why I prefer composition over inheritance, check out my video!
Final thoughts
Utilizing composition in coding leads to more modular, maintainable, and easily testable software, along with a decrease in coupling. This technique further brings the perks of increased code reusability and a reduction in the amount of code required. Many codebases could see significant improvements with the integration of composition. This discussion might inspire you to identify areas in your own projects where composition could be a valuable tool for refining the design of your software.