SOLID Principles Simplified #5: Dependency Inversion Principle
Why does changing my email service break my entire notification system?
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
NotificationServiceand 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 inNotificationService- even thoughNotificationServiceshouldn't care how email gets sent.You can't easily swap or test in isolation: Want to write a unit test for
NotificationServicewithout actually sending real emails? Good luck, because it's hard-wired to the realEmailSender.
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.
NotificationServicedepends onINotificationChannel(the abstraction), not onEmailSenderdirectly.EmailSender,SMSSenderandSlackSenderall implementINotificationChannel.
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?
NotificationServiceno longer knows or cares whether it's sending email, SMS or Slack. It only knows aboutINotificationChannel.Adding a new channel means writing a new class that implements the interface.
NotificationServicedoes 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.