Software engineering

Composition over Inheritance: A Notification System Case Study

Stop fighting your notification code. Discover why class hierarchies fail, how composition creates flexibility inheritance can't match, and a blueprint for systems that evolve without rewriting.

One of the most persistent challenges in object-oriented design is deciding whether to solve a problem using inheritance or composition. If you haven't encountered this dilemma firsthand, the notification system is a textbook example that will quickly illustrate why this decision matters.

Notification System Inheritance

The Inheritance Approach

At first glance, inheritance seems like the natural solution. After all, Email Notification, SMS Notification, and Push Notification are all types of notifications, similar to how a Car and Bike are both types of Vehicles we all have studied in universities. The class hierarchy appears clean and intuitive.

However, this illusion of elegance crumbles the moment your system grows.

In a real-world notification system, the channel (email, SMS, push) is just one dimension of the problem. As requirements evolve, you inevitably need to support additional dimensions:

  • Retry Policy: none, exponential backoff, fixed delay
  • Formatting: HTML, text, template-based
  • Routing: provider A or provider B
  • Localization: en, fr, es, etc.

With inheritance, modeling these dimensions leads to a combinatorial explosion of classes RetryingEmailNotification, LocalizedEmailNotification, RetryingLocalizedEmailNotification, TrackedRetryingLocalizedEmailNotificationand HighPriorityTrackedRetryingLocalizedEmailNotification

With just 3 channels and 5 optional behaviors, you don't get 8 classes, you get dozens, and the number keeps growing. This is the class explosion problem, and it's a sign that your design has gone wrong.

Baked-in Behavior Decisions

When behavior is encoded into the class type itself, changing channels becomes expensive. If the same notification needs to go to both email and SMS, you need two separate objects—one for each channel.

This inevitably leads to conditional logic at the factory level:

if urgent → SmsNotification else EmailNotification

if providerDown → EmailProviderBNotification else EmailProviderANotification

The problem simply shifts from one place to another without actually being solved.

Violation of the Liskov Substitution Principle

Inheritance comes with a contract. If EmailNotification is a subtype of Notification, it should behave exactly as the Notification base class promises. The base class guarantees that send() will always return messageId, scheduledAt and attachments.

But SMS and push notifications might not support all these features. When they don't, they throw NotSupportedException.

Code that works perfectly with Notification suddenly breaks when a subtype is passed in. This violates the fundamental principle of inheritance and creates brittle, unpredictable systems.

The Lesson

These issues aren't bugs in your implementation, they're symptoms of a fundamental design problem. We've tried to force a solution using inheritance, but as we've seen, it only pushes the complexity around without actually solving it.

The real question we need to ask ourselves is:

Are we thinking about this problem correctly in the first place?

This requires us to fundamentally rethink how we structure our code. Instead of asking what inheritance hierarchy can model this? we need to ask how can we separate these concerns and combine them flexibly?

The path forward demands a different architectural approach—one that treats these features as independent, composable units rather than as variations of a single type.

A Fundamental Shift: Notification as Data, Not Behavior

The first mental shift required is deceptively simple:

A notification is not behavior. A notification is data.

Ask yourself, who is the recipient? what is the message? when should it be sent? what metadata is attached?

That’s a notification.

Everything else, how it gets sent, what happens if it fails, whether the user has opted out is separation of concerns. This reframing changes everything. Instead of thinking of “email notification” as a type, you start thinking of email as a delivery strategy. Instead of asking “what is a notification?”, you ask “what is in a notification?”

A notification has a channel, provider, policies applied to it as pipeline it flows through. Each responsibility becomes its own component, and the system transforms from a hierarchy problem into an orchestration problem.

The NotificationService: Single Entry Point, Flexible Execution

In this model, a NotificationService becomes the single entry point and coordinator. When you call send(notificationRequest), the service doesn't care whether the message will go out as email or SMS. Its job is to orchestrate the entire process:

  1. Validate the request
  2. Resolve routing rules to determine which channel and provider to use
  3. Apply cross-cutting policies:
    • opt-out checks
    • quiet hours
    • rate limiting
    • retries
    • tracking
  4. Dispatch the message through the chosen channel

This separation of concerns is subtle but powerful. The service focuses on orchestration, not on channel-specific details.

Channels as Strategies

Each channel is now just a strategy: a specialized component with a single responsibility.

  • An EmailChannel knows how to turn a generic message into an email payload.
  • An SmsChannel knows how to enforce SMS constraints and build an SMS payload.
  • A PushChannel knows how to prepare push-specific data.

They own their domain expertise and nothing more.

Critically, these channels do not own retry logic, tracking logic, or compliance logic. That complexity lives elsewhere, freeing each channel to stay focused and testable.

Providers as Runtime Decisions

Providers sit one level lower in the system. An email channel can work with different email providers such as SendGrid, AWS SES, Mailgun without requiring new classes or inheritance hierarchies. Switching providers is no longer a type system decision. It becomes a routing or configuration decision. If a provider is down, you fail over at runtime.

This level of flexibility is nearly impossible to achieve cleanly with inheritance because provider choice gets baked into the type itself. With composition, it's a simple configuration change.

Cross-Cutting Concerns Become Composable Policies

Perhaps the most significant improvement comes from how you handle concerns that span multiple channels retry behavior, quiet hours, rate limits, idempotency, tracking and templating

These concerns no longer get tangled inside subclasses. Instead, they become composable policies that wrap or intercept the send process. You can add or remove them without touching channel or provider code, reorder them, combine them differently and test them independently. This makes the entire system more testable, more reasoned about, and far more adaptable to change.

Alignment with Business Reality

What makes this approach particularly elegant is how well it aligns with how the business actually thinks about requirements.

Product requirements are rarely phrased as:

  • “We need a new type of notification.”

They’re phrased as “we need retries", “we need compliance”, “we need fallback behavior” or “we need rate limiting”.

Composition allows you to model those requirements directly. Inheritance forces you into a type hierarchy that was never designed for that flexibility.

A Design That Ages Well

In practice, this architecture evolves gracefully over time. You can start small a notification service, router, couple of channels and simple providers

As requirements grow, you add new policies and strategies instead of rewriting existing code. The system extends rather than modifies. You don’t break old behavior when adding new features. This is exactly what you want in a codebase that will live for years and accumulate requirements over time.

Notification System Design


The Core Principle

The fundamental lesson is that inheritance works best when behavior is stable and differences are truly about type.

Notification systems aren't like that.

Their complexity comes from:

  • combinations of behavior
  • runtime decisions
  • cross-cutting concerns that span channels and providers

Composition maps naturally to this use-case. It lets you model flexibility, change, and combination without the brittleness of type hierarchies.

Once you see this shift clearly—once you've felt the freedom of composable policies and flexible routing—the design choice becomes hard to unsee.