• When a Good Abstraction Starts to Break Down

    In my last post, I walked through an example of introducing abstraction into a notification system once multiple delivery channels appeared. At first, the abstraction worked well.

    Every channel shared the same contract:

     1$channel->send($user, $message);
    

    That gave us a few clear benefits:

    • New channels were easy to add
    • Delivery logic stayed isolated
    • The notification service became simpler

    At that stage, the abstraction matched the problem well. But abstractions rarely stay frozen. As systems grow, requirements start exposing assumptions that were hidden early on. This is where good abstractions often begin to break down.


    #The Original Abstraction

    The initial design looked something like this:

     1interface Channel
     2{
     3    public function send(User $user, string $message): void;
     4}
    

    Then individual channels implemented the interface:

     1class EmailChannel implements Channel
     2{
     3    public function send(User $user, string $message): void
     4    {
     5        mail($user->email, 'Notification', $message);
     6    }
     7}
    
     1class SmsChannel implements Channel
     2{
     3    public function send(User $user, string $message): void
     4    {
     5        // SMS logic here
     6    }
     7}
    

    And the notification service coordinated delivery:

     1class NotificationService
     2{
     3    public function __construct(private array $channels)
     4    {
     5    }
     6
     7    public function send(string $message, User $user): void
     8    {
     9        foreach ($this->channels as $channel) {
    10            $channel->send($user, $message);
    11        }
    12    }
    13}
    

    Simple. Clean. Easy to understand.

    At first.


    #Where Things Start Changing

    The abstraction begins to strain once channels stop behaving similarly.

    For example:

    • Email needs attachments
    • SMS has character limits
    • Push notifications support titles and action URLs
    • Slack messages support formatting blocks
    • Some channels require retries or async queues

    Now the original interface starts feeling too small for the problem.


    #The First Failure Mode

    One common reaction is expanding the interface:

     1interface Channel
     2{
     3    public function send(
     4        User $user,
     5        string $message,
     6        ?string $subject = null,
     7        array $attachments = [],
     8        array $metadata = []
     9    ): void;
    10}
    

    This technically works, but the abstraction is starting to leak.

    Now:

    • SMS receives attachments it ignores
    • Push notifications receive subjects they do not use
    • Metadata means something different to every channel

    The abstraction no longer represents a clean shared behavior. It has become a “universal” interface trying to satisfy incompatible needs.


    #The Second Failure Mode

    Another common outcome is channel-specific logic leaking upward:

     1foreach ($channels as $channel) {
     2    if ($channel instanceof SmsChannel) {
     3        $channel->send($user, substr($message, 0, 160));
     4        continue;
     5    }
     6
     7    if ($channel instanceof EmailChannel) {
     8        $channel->send($user, $message, $subject, $attachments);
     9        continue;
    10    }
    11
    12    $channel->send($user, $message);
    13}
    

    At this point, the abstraction has effectively collapsed. The caller now understands implementation details for every channel, which defeats the original goal of isolation.


    #Why This Happens

    The original abstraction assumed all notification channels behaved similarly enough to share the same input structure. That assumption was true early on. Over time, though, the differences between channels became more important than their similarities.

    This is a common lifecycle for abstractions:

    1. A shared pattern emerges
    2. An abstraction simplifies the system
    3. New requirements stretch the abstraction
    4. The abstraction either evolves or breaks

    That does not mean the abstraction was wrong. It means the system evolved beyond the assumptions it was built on.


    #Refactoring the Boundary

    At this stage, I would probably shift the abstraction entirely. Instead of forcing every channel through the same generic parameters, I would introduce channel-specific message objects.

    Start with a base message type:

     1abstract class Message
     2{
     3    public function __construct(
     4        public User $user
     5    ) {
     6    }
     7}
    

    Then define channel-specific messages:

     1class EmailMessage extends Message
     2{
     3    public function __construct(
     4        User $user,
     5        public string $subject,
     6        public string $body,
     7        public array $attachments = []
     8    ) {
     9        parent::__construct($user);
    10    }
    11}
    
     1class SmsMessage extends Message
     2{
     3    public function __construct(
     4        User $user,
     5        public string $body
     6    ) {
     7        parent::__construct($user);
     8    }
     9}
    
     1class PushNotificationMessage extends Message
     2{
     3    public function __construct(
     4        User $user,
     5        public string $title,
     6        public string $body,
     7        public ?string $actionUrl = null
     8    ) {
     9        parent::__construct($user);
    10    }
    11}
    

    #Updating the Channels

    Now each channel receives the message type it actually understands.

     1class EmailChannel implements Channel
     2{
     3    public function send(Message $message): void
     4    {
     5        if (!$message instanceof EmailMessage) {
     6            throw new InvalidArgumentException('Invalid message type.');
     7        }
     8
     9        mail(
    10            $message->user->email,
    11            $message->subject,
    12            $message->body
    13        );
    14
    15        // Handle attachments here
    16    }
    17}
    
     1class SmsChannel implements Channel
     2{
     3    public function send(Message $message): void
     4    {
     5        if (!$message instanceof SmsMessage) {
     6            throw new InvalidArgumentException('Invalid message type.');
     7        }
     8
     9        $body = substr($message->body, 0, 160);
    10
    11        // SMS delivery logic here
    12    }
    13}
    

    The abstraction has shifted.

    Instead of:

    • “all channels behave the same”

    the system now says:

    • “all channels participate in the same workflow”

    That is a much more stable boundary.


    #What Changed?

    The important shift here is not just technical. It is conceptual.

    The original abstraction focused on uniform behavior:

    • every channel receives the same data

    The newer abstraction focuses on coordination:

    • every channel is part of the same notification system

    That is a subtle but important difference. The first abstraction optimized for simplicity. The second optimized for flexibility as the system evolved.


    #The Important Lesson

    One of the biggest misconceptions about abstraction is the idea that there is a “perfect” abstraction waiting to be discovered. In reality, abstractions are temporary agreements between the current system requirements and the current understanding of the problem. As either of those change, the abstraction may need to change too.

    That is normal.

    Good engineering is not about designing abstractions that last forever. It is about recognizing when the shape of the problem has changed enough that the abstraction no longer fits cleanly.

    profile image of Justin Thomas

    Justin Thomas

    Justin Thomas is a software engineer who enjoys building useful things on the internet. He’s the creator of Solic.io, a tool that helps websites reduce spam and surface real conversations. He writes about coding, product development, and the realities of shipping software.

    More posts from Justin Thomas