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.