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:
- A shared pattern emerges
- An abstraction simplifies the system
- New requirements stretch the abstraction
- 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.