Skip to content

Dependency Inversion

TL;DR: High-level modules shouldn't depend on low-level modules — both should depend on abstractions. If your NotificationService creates an EmailSender directly, you can't switch to SMS or push notifications without rewriting the service. Depend on a MessageSender interface instead, and inject the implementation.

Dependency Inversion Principle

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:

  1. Can't switch implementations — want to send SMS instead of email? You have to modify NotificationService. That violates OCP too.
  2. Can't test in isolation — every test of NotificationService actually sends emails (or you have to use reflection hacks to swap the sender).
  3. 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 MessageSender and verify that NotificationService calls 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