Dependency Inversion
TL;DR: High-level modules shouldn't depend on low-level modules — both should depend on abstractions. If your
NotificationServicecreates anEmailSenderdirectly, you can't switch to SMS or push notifications without rewriting the service. Depend on aMessageSenderinterface instead, and inject the implementation.

Why This Matters in Interviews
DIP is the principle that ties the others together. When an interviewer watches you design a system, they're checking whether your high-level business logic is coupled to specific implementations. If your OrderService directly instantiates a MySQLDatabase, a StripePaymentGateway, and a SendGridEmailer, your design is rigid, untestable, and impossible to extend without surgery.
The candidates who stand out are the ones who naturally reach for interfaces and constructor injection. They design systems where the business logic says what needs to happen, and the wiring layer decides how.
The Problem: Hard-Coded Dependencies
// BAD: NotificationService is welded to EmailSender
class EmailSender {
public void send(String to, String message) {
System.out.println("Sending email to " + to + ": " + message);
// SMTP connection, authentication, HTML formatting...
}
}
class NotificationService {
private EmailSender emailSender;
public NotificationService() {
this.emailSender = new EmailSender(); // Hard-coded dependency
}
public void notifyUser(String userId, String message) {
String email = lookupEmail(userId);
emailSender.send(email, message);
}
private String lookupEmail(String userId) {
return userId + "@example.com";
}
}
// BAD: Same problem in C++
class EmailSender {
public:
void send(const std::string& to, const std::string& message) {
std::cout << "Sending email to " << to << ": " << message << std::endl;
}
};
class NotificationService {
private:
EmailSender emailSender; // Hard-coded dependency
public:
void notifyUser(const std::string& userId, const std::string& message) {
std::string email = userId + "@example.com";
emailSender.send(email, message);
}
};
# BAD: Same problem in Python
class EmailSender:
def send(self, to: str, message: str) -> None:
print(f"Sending email to {to}: {message}")
class NotificationService:
def __init__(self):
self._sender = EmailSender() # Hard-coded dependency
def notify_user(self, user_id: str, message: str) -> None:
email = f"{user_id}@example.com"
self._sender.send(email, message)
What's Wrong Here?
NotificationService directly creates an EmailSender in its constructor. This creates three problems:
- Can't switch implementations — want to send SMS instead of email? You have to modify
NotificationService. That violates OCP too. - Can't test in isolation — every test of
NotificationServiceactually sends emails (or you have to use reflection hacks to swap the sender). - High-level logic depends on low-level detail — the notification policy (who gets notified, when) is coupled to the notification mechanism (SMTP email). These change for different reasons.
Interview tip: When you see
new ConcreteClass()inside a constructor, that's a DIP red flag. The class is deciding its own dependencies instead of receiving them. This makes the class rigid and untestable.
The Fix: Depend on Abstractions, Inject Implementations
// GOOD: NotificationService depends on an abstraction
interface MessageSender {
void send(String to, String message);
}
class EmailSender implements MessageSender {
@Override
public void send(String to, String message) {
System.out.println("Sending email to " + to + ": " + message);
// SMTP logic...
}
}
class SmsSender implements MessageSender {
@Override
public void send(String to, String message) {
System.out.println("Sending SMS to " + to + ": " + message);
// Twilio API calls...
}
}
class PushNotificationSender implements MessageSender {
@Override
public void send(String to, String message) {
System.out.println("Sending push to " + to + ": " + message);
// Firebase Cloud Messaging...
}
}
class NotificationService {
private final MessageSender sender;
// Dependency is INJECTED, not created
public NotificationService(MessageSender sender) {
this.sender = sender;
}
public void notifyUser(String userId, String message) {
String contact = lookupContact(userId);
sender.send(contact, message);
}
private String lookupContact(String userId) {
return userId + "@example.com";
}
}
// GOOD: Same fix in C++
class MessageSender {
public:
virtual ~MessageSender() = default;
virtual void send(const std::string& to, const std::string& message) = 0;
};
class EmailSender : public MessageSender {
public:
void send(const std::string& to, const std::string& message) override {
std::cout << "Sending email to " << to << ": " << message << std::endl;
}
};
class SmsSender : public MessageSender {
public:
void send(const std::string& to, const std::string& message) override {
std::cout << "Sending SMS to " << to << ": " << message << std::endl;
}
};
class NotificationService {
private:
std::unique_ptr<MessageSender> sender;
public:
// Dependency is INJECTED via constructor
explicit NotificationService(std::unique_ptr<MessageSender> sender)
: sender(std::move(sender)) {}
void notifyUser(const std::string& userId, const std::string& message) {
std::string contact = userId + "@example.com";
sender->send(contact, message);
}
};
# GOOD: Same fix in Python
from abc import ABC, abstractmethod
class MessageSender(ABC):
@abstractmethod
def send(self, to: str, message: str) -> None:
pass
class EmailSender(MessageSender):
def send(self, to: str, message: str) -> None:
print(f"Sending email to {to}: {message}")
class SmsSender(MessageSender):
def send(self, to: str, message: str) -> None:
print(f"Sending SMS to {to}: {message}")
class PushNotificationSender(MessageSender):
def send(self, to: str, message: str) -> None:
print(f"Sending push to {to}: {message}")
class NotificationService:
def __init__(self, sender: MessageSender):
self._sender = sender # Injected, not created
def notify_user(self, user_id: str, message: str) -> None:
contact = f"{user_id}@example.com"
self._sender.send(contact, message)
Now NotificationService doesn't know or care whether it's sending email, SMS, or push notifications. The caller decides:
// Wiring happens at the top level — not inside the business logic
NotificationService emailNotifier = new NotificationService(new EmailSender());
NotificationService smsNotifier = new NotificationService(new SmsSender());
// Testing is trivial — inject a fake
MessageSender fakeSender = (to, msg) -> System.out.println("FAKE: " + msg);
NotificationService testNotifier = new NotificationService(fakeSender);
Interview tip: Notice that testing becomes trivial once you apply DIP. You inject a fake
MessageSenderand verify thatNotificationServicecalls it correctly — no real emails, no real SMTP servers. This is a concrete benefit you can mention in interviews.
DIP Is a Principle, Not a Technique
People often confuse Dependency Inversion (the principle) with Dependency Injection (the technique). They're related but different:
| Concept | What it is | Example |
|---|---|---|
| Dependency Inversion Principle | High-level modules should depend on abstractions, not concrete implementations | NotificationService depends on MessageSender interface, not EmailSender class |
| Dependency Injection | A technique where dependencies are passed in from outside rather than created internally | new NotificationService(new EmailSender()) — the sender is injected via the constructor |
DIP tells you what to depend on (abstractions). Dependency injection tells you how to provide those dependencies (pass them in). You can follow DIP without using a DI framework — constructor injection is often all you need.
Interview tip: If an interviewer asks about DIP, don't immediately start talking about Spring or Guice. Start with the principle: "high-level modules shouldn't depend on low-level modules." Then show the technique: "I'd inject the dependency through the constructor." Frameworks are an implementation detail.
Putting It All Together
Here's how all five SOLID principles connect. Each one solves a specific design problem, and together they guide you toward systems that are modular, testable, and easy to extend.
| Principle | Core Idea | What It Prevents |
|---|---|---|
| SRP | Keep classes focused — one reason to change | Changes in one concern rippling into unrelated code |
| OCP | Support future requirements without modifying existing code | Growing if/else chains and risky edits to tested code |
| LSP | Prevent brittle hierarchies — subclasses must honor the base class contract | Surprise exceptions, instanceof checks, broken polymorphism |
| ISP | Keep interfaces clean — no class should implement methods it doesn't use | Empty method stubs, forced coupling to irrelevant behavior |
| DIP | Decouple business logic from implementation details — depend on abstractions | Hard-coded dependencies, untestable code, rigid architectures |
You don't need to name-drop these principles in an interview. What matters is that your design reflects them. If an interviewer sees that you separate concerns cleanly, design for extension, build substitutable hierarchies, keep interfaces focused, and inject dependencies — they know you understand SOLID.
Focus on the reasoning behind your choices. The principles will show through naturally.
Quick Recap
| Concept | What it means | Why it matters |
|---|---|---|
| Depend on abstractions | High-level modules use interfaces, not concrete classes | You can swap implementations without touching business logic |
| Inject, don't create | Pass dependencies in through the constructor | Classes are testable and flexible — they don't control their own wiring |
| DIP != DI | DIP is the principle (what to depend on); DI is the technique (how to provide it) | Understanding the distinction shows depth beyond framework knowledge |
| Enables testing | Inject fakes/mocks in tests instead of relying on real infrastructure | Fast, reliable tests that don't need databases, APIs, or file systems |