Skip to main content

Command Palette

Search for a command to run...

SOLID Principles Simplified #5: Dependency Inversion Principle

Why does changing my email service break my entire notification system?

Updated
8 min readView as Markdown

The Problem

You're building a NotificationService for your app. First requirement is to send notifications over email.

Simple enough. You write an EmailSender class and inside NotificationService, you create one directly:

class NotificationService {
    EmailSender emailSender;
public:
    void notify(std::string message){
        emailSender.send(message)
    }
};

Works great. Ship it.

Two months later: "Can we also send SMS notifications?" Sure. You add an if check and an SMSSender inside the same class.

A few weeks after that: "Product wants slack notifications now too." You add yet another branch.

Somewhere in this process, you notice something uncomfortable:

Every time a new notification channel shows up, you have to crack open NotificationService and edit it.

And every time someone changes how EmailSender works internally - a new SDK version, a config change - you're nervously re-testing NotificationService, even though it's actual job (deciding when to notify someone) has nothing to do with SMTP settings.

Your high-level "notify the user" logic has become tightly welded to low-level details it shouldn't even know about.

If you've ever felt like a small, low-level change forced you to touch a big, important, unrelated class - you've already met the exact problem the Dependency Inversion Principle exists to solve.

Why does this problem exist?

This one sneaks up on almost every developer because the "wrong" way often looks like the obvious and natural way to write code. Here is how it usually pays out:

  • You need a feature, so you write the class that needs it (NotificationService) and the class that provides it (EmailSender).

  • The easiest thing to do is just to create the low-level object directly inside the high-level class - EmailSender emailSender; - and call it a day. It compiles, it works, why complicate it?

  • As requirements grow, more low-level classes get created and wired directly into the same high-level class.

  • Now your important business logic (when and why to notify someone) is buried inside a class that also knows how SMTP works, how the SMS gateway's API is shaped and how Slack's web data looks.

The core issue:

Your high-level policy code (the "what should happen") has become dependent on low-level implementation details (the "how it happens").

This creates two very specific pains:

  • Change is contagious: A change in EmailSender's constructor, method signature or behavior can ripple upward and force changes in NotificationService - even though NotificationService shouldn't care how email gets sent.

  • You can't easily swap or test in isolation: Want to write a unit test for NotificationService without actually sending real emails? Good luck, because it's hard-wired to the real EmailSender.

The direction of dependency is the real problem: the important, high-level code is depending on the replaceable, low-level details -- when in reality, it should be the other way around.

The Concept

This is exactly what the Dependency Inversion Principle (DIP) - the "D" in SOLID addresses. It states:

High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.

In simple terms:

Your important business logic shouldn't be wired directly to specific implementations (like a specific email library). Instead, both sides should depend on a shared "contract" (an interface) and the low-level implementation is the one that has to conform to it, not the other way around.

Simple Explanation

Let's break it down piece-by-piece.

What's a "high-level module"? The part of your code that contains the important decisions - the what and why. In our example, that's NotificationService: "when something happens, notify the user."

What's a "low-level module"? The part of your code that handles the nitty-gritty how. That is EmailSender, SMSSender, SlackSender - the actual mechanics of delivering a message.

What's an "abstraction" here? An interface - a contract that says "anything that wants to be a notification channel must provide a send() method." It doesn't care how send() works internally, only that it exists.

So what does DIP actually ask you to do? Instead of NotificationService creating and depending on EmailSender directly, you introduce an interface - say, INotificationChannel that both sides depend on.

  • NotificationService depends on INotificationChannel (the abstraction), not on EmailSender directly.

  • EmailSender, SMSSender and SlackSender all implement INotificationChannel.

DIP is closely related to a technique called Dependency Injection - where the actual implementation is handed to a class from the outside, usually through its constructor, instead of the class creating in itself.

Real-world analogy

Think about a wall power socket. Your lamp, your laptop charger, your phone charger - none of them are wired directly into the electrical system of your house. They all just plug into the same standard socket.

The electrical system doesn't know or care whether you plug in a lamp or a laptop. It only cares that whatever you plug in follows the socket's shape and voltage (the contract) and your lamp doesn't know or care how electricity is generated behind the wall - coal plant, solar panels, a generator - it just needs power delivered through that same standard plug shape.

Both sides, the house wiring (high-level) and the device (low-level), depend on the plug standard (the abstraction). Neither depends directly on the other.

Simple Software Example

The Bad Approach

class EmailSender {
    public:
        void send(std::string message){
            std::cout<<"Sending Email: "<<message<<std::endl;
        }
};

clas Notificationervice {
    EmailSender emailSender;
  public:
    void notify(std::string message){
        emailSender.send(message);
    }
};

The Better Approach

class INotificationChannel {
  public:
    virtual void send(std::string message) = 0;
    virtual ~INotificationChannel() = default;
};

class EmailSender : public INotificationChannel {
  public:
    void send(std::string message) override {
        std::cout<<"Sending Email: "<<message<<std::endl;
    }
};

class SMSSender : public INotificationChannel {
  public:
    void send(std::string message) override {
        std::cout<<"Sending SMS: "<<message<<std::endl;
    }
};

class NotificationService {
    INotificationChannel& channel;
  public:
    NotificationService(INotificationChannel& channel) : channel(channel) {}
    void notify(std::string message){
        channel.send(message);
    }
};

int main(){
    EmailSender emailSender;
    NotificationService service(emailSender);
    service.notify("Your order has shipped!");
    
    SMSSender smsSender;
    NotificationService smsService(smsSender);
    smsService.notify("Your OTP is 4821!");
    return 0;
}

Why this is better?

  • NotificationService no longer knows or cares whether it's sending email, SMS or Slack. It only knows about INotificationChannel.

  • Adding a new channel means writing a new class that implements the interface. NotificationService does not need to change at all.

  • Testing becomes easy. You can pass in a fake notification channel that just records messages instead of actually sending them.

  • The how (EmailSender, SMSSender) is now depending on the contract (INotificationChannel), not the other way around. That is the inversion.

Does DIP mean I should never let one class use another directly?

No, DIP applies specifically to the relationship between high-level policy code and low-level implementation details. DIP matters most at the boundary where important business logic meets replaceable, detail-heavy implementations (databases, file systems, external APIs etc.)

Isn't this the same as dependency injection?

They are related but not identical. DIP is the principle: depend on abstractions, not concrete details. Dependency Injection is a technique commonly used to satisfy that principle - passing in the implementation from outside (through a constructor) instead of creating it inside the class.

Doesn't adding an interface for everything create unnecessary complexity?

If you add an abstraction for something that will genuinely never have more than one implementation and never needs to be swapped out or mocked in tests, you're adding ceremony without benefits. DIP is most valuable exactly at the seams where change, testing or multiple implementations are realistic.

When should I use this?

  • A class that contains important business rules also directly creates instances of database clients, HTTP clients or third party SDK objects.

  • You can't write a unit test for a class without touching a real external system.

  • Every time a third-party library updates or changes, a class that has nothing to do with that library still needs edits.

Key Takeaways

If you remember only a few things from this article, remember these:

  • DIP says that high-level modules shouldn't depend directly on low-level modules, both should depend on abstractions.

  • The inversion is about the direction of dependency. Implementation details should conform to a contract your high-level logic defines, not the other way around.

  • Without DIP, low-level changes ripple upward into important business logic that shouldn't care about those details.

  • DIP makes testing easier, since real implementations can be swapped for fakes/mocks.

  • Apply it at meaningful boundaries (databases, external services, SDKs) - not as a rule for every single class relationship.

From My Engineering Notebook

This one took me the longest to actually feel, out of all five SOLID principles. Interfaces I understood. Inversion sounded like something out of a textbook.

The way I currently think about it:

Your business logic should be the one setting the terms. The database, the email provider, the SDK - those are just plugs that need to fit the socket your logic defines. Not the other way around.

A

Good explanation, and the section on not adding an interface for everything is the one most write-ups leave out. I'd add that this matters more now that agents write a lot of first drafts. Left to themselves they construct the concrete sender inside the service every time, because that's the shape most of the training data has, and they will also add an interface for a class that will only ever have one implementation if you tell them to 'apply SOLID'. Both need a rule in the repo. The agent side of DIP, if useful: https://prickles.org/tenet/dependency-inversion-principle/A11

O

Really good insight and it matches what I've seen too. AI just reproduce whatever data they are trained on and most real-world code out there uses dependency inline. The flip side you mentioned is more interesting. Tell an agent to apply SOLID and it treats DIP as an interface regardless of whether it needs or not. Also, the Prickles article you have mentioned is helpful too as you have mentioned the "why" part in detail and also how AI agents use DIP.