• A Practical Example of When Abstraction Actually Helps

    In my last post, I talked about how I decide when to abstract and when to keep things simple. It is easy to stay in the world of principles with that kind of topic. It is harder, and more useful, to show what that actually looks like in code. This is a simple example of a situation where abstraction is not only helpful, but becomes the obvious next step once the pattern is clear.


    #The Starting Point

    Imagine a system where you need to send notifications. At first, you only support email.

    The code might look something like this:

     1class NotificationService
     2{
     3    public function sendWelcomeEmail(User $user)
     4    {
     5        mail($user->email, 'Welcome!', 'Thanks for signing up.');
     6    }
     7
     8    public function sendPasswordReset(User $user, string $token)
     9    {
    10        mail($user->email, 'Reset Password', "Use this token: {$token}");
    11    }
    12}
    

    This is simple and easy to follow. There is no reason to abstract anything yet.


    #The First Sign of Change

    Now you are asked to support SMS notifications.

    A quick approach might look like this:

     1class NotificationService
     2{
     3    public function sendWelcome(User $user)
     4    {
     5        mail($user->email, 'Welcome!', 'Thanks for signing up.');
     6        $this->sendSms($user->phone, 'Welcome!');
     7    }
     8
     9    public function sendPasswordReset(User $user, string $token)
    10    {
    11        mail($user->email, 'Reset Password', "Use this token: {$token}");
    12        $this->sendSms($user->phone, "Reset token: {$token}");
    13    }
    14
    15    private function sendSms(string $phone, string $message)
    16    {
    17        // SMS logic here
    18    }
    19}
    

    This works, but a pattern is starting to form.

    Every notification now needs to know:

    • What message to send
    • Which channels to send it through
    • How each channel is implemented

    At this point, the responsibilities are starting to blur.


    #Where This Starts to Break Down

    If you keep going down this path, a few things happen:

    • Every method grows as more channels are added
    • Changes to one channel affect every notification
    • It becomes harder to test individual pieces

    More importantly, the system is now mixing two concerns:

    • What the notification is
    • How it is delivered

    That is a good signal that an abstraction might help.


    #Introducing a Small Abstraction

    Instead of letting each method handle delivery, you can separate the idea of a notification from the channels that deliver it.

    Start with a simple interface:

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

    Then implement specific channels:

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

    Now your service becomes simpler:

     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}
    

    #Why This Abstraction Works

    This abstraction is not trying to predict every future need. It is solving a problem that already exists.

    A few things improve immediately:

    • Adding a new channel does not require changing existing logic
    • Each channel is isolated and easier to test
    • The service focuses on coordination, not implementation details

    Most importantly, the abstraction maps cleanly to a real concept. A "channel" is something you can describe without referencing code.


    #What Would Have Been Too Early

    If this abstraction had been introduced when only email existed, it would have added unnecessary complexity.

    You would have:

    • Extra classes with no real benefit
    • Indirection without a clear problem
    • More code to maintain without a payoff

    At that stage, the simpler version was the correct choice.


    #The Pattern Behind the Example

    The key shift here is separating concerns:

    • The notification defines the message
    • The channel defines how it is delivered

    Once those responsibilities become distinct, the abstraction becomes natural.


    #Closing Thought

    Good abstractions rarely feel clever. They feel obvious in hindsight. The goal is not to design the perfect system upfront. It is to recognize when the code is starting to tell you what it needs. In this case, the moment multiple delivery methods appeared, the abstraction stopped being optional and started being useful.

    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