The Dependency Inversion Principle says:
- 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 language, our business logic shouldn’t care whether we’re sending emails via SendGrid or SMTP or carrier pigeon. It should depend on an abstraction like MessageSender, and the specific implementation is plugged in from outside.
The Problem: Tight Coupling
Here’s what tight coupling looks like:
# BAD -- NotificationService directly creates its dependency
class EmailSender:
def send(self, to, message):
print(f"Email to {to}: {message}")
class NotificationService:
def __init__(self):
self.sender = EmailSender() # hardcoded dependency!
def notify(self, user, message):
self.sender.send(user.email, message)
// BAD -- NotificationService directly creates its dependency
class EmailSender {
send(to, message) {
console.log(`Email to ${to}: ${message}`);
}
}
class NotificationService {
constructor() {
this.sender = new EmailSender(); // hardcoded dependency!
}
notify(user, message) {
this.sender.send(user.email, message);
}
}
// BAD -- NotificationService directly creates its dependency
class EmailSender {
void send(String to, String message) {
System.out.println("Email to " + to + ": " + message);
}
}
class NotificationService {
private EmailSender sender = new EmailSender(); // hardcoded!
void notify(User user, String message) {
sender.send(user.getEmail(), message);
}
}
What’s wrong here? NotificationService (high-level) directly depends on EmailSender (low-level). Want to switch to SMS? We have to modify NotificationService. Want to test without actually sending emails? Can’t — it’s hardcoded.
The Fix: Depend on Abstractions
We introduce an interface and inject the dependency from outside:
from abc import ABC, abstractmethod
# The abstraction
class MessageSender(ABC):
@abstractmethod
def send(self, to, message):
pass
# Concrete implementations
class EmailSender(MessageSender):
def send(self, to, message):
print(f"Email to {to}: {message}")
class SMSSender(MessageSender):
def send(self, to, message):
print(f"SMS to {to}: {message}")
class SlackSender(MessageSender):
def send(self, to, message):
print(f"Slack to {to}: {message}")
# High-level module depends on abstraction, not details
class NotificationService:
def __init__(self, sender: MessageSender): # injected!
self.sender = sender
def notify(self, to, message):
self.sender.send(to, message)
# We control what gets plugged in
service = NotificationService(SMSSender())
service.notify("Alice", "Your order shipped!")
# For testing -- just pass a mock
class MockSender(MessageSender):
def __init__(self):
self.sent = []
def send(self, to, message):
self.sent.append((to, message))
test_service = NotificationService(MockSender())
// The abstraction (interface by convention in JS)
// Any object with a send(to, message) method works
class EmailSender {
send(to, message) {
console.log(`Email to ${to}: ${message}`);
}
}
class SMSSender {
send(to, message) {
console.log(`SMS to ${to}: ${message}`);
}
}
// High-level module depends on abstraction
class NotificationService {
constructor(sender) {
// injected from outside!
this.sender = sender;
}
notify(to, message) {
this.sender.send(to, message);
}
}
// Plug in whatever we want
const service = new NotificationService(new SMSSender());
service.notify("Alice", "Your order shipped!");
// For testing -- just pass a mock
const mockSender = { send: (to, msg) => {} };
const testService = new NotificationService(mockSender);
// The abstraction
interface MessageSender {
void send(String to, String message);
}
class EmailSender implements MessageSender {
public void send(String to, String message) {
System.out.println("Email to " + to + ": " + message);
}
}
class SMSSender implements MessageSender {
public void send(String to, String message) {
System.out.println("SMS to " + to + ": " + message);
}
}
// High-level module depends on abstraction
class NotificationService {
private MessageSender sender; // interface, not concrete class
NotificationService(MessageSender sender) { // injected!
this.sender = sender;
}
void notify(String to, String message) {
sender.send(to, message);
}
}
// NotificationService service = new NotificationService(new SMSSender());
// service.notify("Alice", "Your order shipped!");
Now NotificationService doesn’t know or care whether it’s sending emails, SMS, or Slack messages. We pass in whatever MessageSender implementation we want. This is called Dependency Injection — the most common way to apply DIP.
Dependency Injection Explained Simply
Dependency Injection (DI) just means: instead of a class creating its own dependencies, we give them to it from outside.
There are three ways to inject:
1. Constructor Injection (most common, what we showed above)
class Service:
def __init__(self, repo): # dependency passed in constructor
self.repo = repo
2. Setter Injection
class Service:
def set_repo(self, repo): # dependency set after construction
self.repo = repo
3. Method Injection
class Service:
def do_work(self, repo): # dependency passed per method call
repo.save(data)
Constructor injection is preferred because the object is fully ready to use the moment it’s created. No risk of forgetting to set a dependency.
Before and After
| Without DIP | With DIP |
|---|---|
| Classes create their own dependencies | Dependencies are injected from outside |
| Changing a dependency = modifying the class | Changing a dependency = passing a different object |
| Hard to test (real emails get sent) | Easy to test (pass a mock) |
| Tight coupling | Loose coupling |
Why It Matters
DIP is the principle that makes everything else work together. When we follow DIP:
- OCP becomes natural — we extend by adding new implementations, not modifying existing code
- Testing becomes easy — we inject mocks instead of real services
- Swapping implementations is painless — switch from MySQL to PostgreSQL by injecting a different repository
In LLD interviews, whenever we have a service that talks to an external system (database, API, notification service), we should define an interface for it and inject the implementation. That’s the interviewer’s “this person knows what they’re doing” signal.