Dependency Inversion Principle
Why hardwiring a high-level class to a low-level implementation makes testing and extension painful, and how depending on a shared abstraction instead makes both sides swappable.
Learning objectives
- State the Dependency Inversion Principle and explain what 'inversion' actually refers to
- Identify tight coupling between a high-level class and a low-level implementation
- Refactor a class that constructs its own dependencies into one that receives them through its constructor, typed as an abstraction
- Distinguish Dependency Inversion (the principle) from Dependency Injection (the technique)
◆ Story
An appliance that plugs into a standard wall socket can be swapped for any other appliance instantly — the wall doesn't know or care whether a lamp or a toaster is plugged in, it only knows "standard plug." An appliance that's hardwired directly into the wall's internal wiring can never be swapped without an electrician tearing into the wall itself.
The Dependency Inversion Principle (DIP) says high-level code (the wall) and low-level code (the appliance) should both depend on a shared abstraction (the standard plug interface) — high-level code should never depend directly on a low-level implementation's concrete details.
This is the last of the five SOLID principles, and arguably the one with the biggest single payoff — it's the reason "just swap in a mock for testing" is even possible. The worked example: a NotificationService that starts hardwired to email and SMS, and ends up not knowing either one exists.
Start with two low-level services that know how to actually send something, and a high-level service that uses them directly. NotificationService constructs its own EmailService and SmsService internally, inside its own constructor, and exposes one method per channel.
This runs, and prints correctly. The trouble starts the moment the roster of channels needs to grow, or the moment this class needs to be tested in isolation from any real email or SMS infrastructure.
💻 Code example
class EmailService { void sendEmail(String message) { System.out.println("Sending email: " + message); } } class SmsService { void sendSms(String message) { System.out.println("Sending SMS: " + message); } } class NotificationService { private EmailService emailService; // a "high-level" class depending directly on "low-level" concrete classes private SmsService smsService; NotificationService() { this.emailService = new EmailService(); // constructs its own dependencies internally this.smsService = new SmsService(); } void notifyByEmail(String message) { emailService.sendEmail(message); } void notifyBySms(String message) { smsService.sendSms(message); } }
- Tight coupling to implementation details. If
EmailService's method is ever renamed fromsendEmailto something else,NotificationServicehas to change too — a change to a low-level detail rippling directly into high-level business logic. - Adding a channel means editing tested code. Supporting WhatsApp means a new field, a new constructor line, and a new method on
NotificationServiceitself — the exact same "reopening working code" problem the Open/Closed Principle names directly. - Testing is genuinely difficult.
NotificationServiceconstructs its own realEmailServiceandSmsServiceinternally — there's no way to substitute a fake version for a unit test without actually changing this class's code, which means testingNotificationService's own logic in isolation is effectively impossible as written.
This is what DIP means by "high-level modules should not depend on low-level modules": right now, NotificationService (high-level — it represents a business capability) is wired directly to EmailService and SmsService (low-level — specific delivery mechanisms), instead of both sides depending on something shared and stable.
NotificationService doesn't actually care how a message gets delivered — only that, given a message, it gets sent somehow. Every channel — email, SMS, and whatever comes next — shares exactly one behavior in common: "given a message, send it." That shared behavior is the abstraction both sides should depend on. NotificationService should be handed a channel from the outside, typed as that shared abstraction, rather than constructing specific concrete services internally.
Both the high-level class and the low-level classes now depend on the same new NotificationChannel interface — neither depends on the other directly anymore. That mutual dependency on a shared abstraction, instead of one depending on the other, is precisely what "inversion" refers to in this principle's name.
NotificationService loses its two separate notifyByEmail/notifyBySms methods entirely, collapsing into one notify() — because it genuinely no longer needs to know which channel it's holding. Adding WhatsApp later means writing one new class implementing NotificationChannel; NotificationService is not touched at all, and a unit test for notify() can pass in a trivial fake NotificationChannel that just records what it was called with, with no real email server or SMS gateway required.
💻 Code example
interface NotificationChannel { void send(String message); // the one thing every channel must be able to do } class EmailService implements NotificationChannel { public void send(String message) { System.out.println("Sending email: " + message); } } class SmsService implements NotificationChannel { public void send(String message) { System.out.println("Sending SMS: " + message); } } class NotificationService { private final NotificationChannel channel; // depends on the INTERFACE, not a concrete class NotificationService(NotificationChannel channel) { // supplied through the constructor, not built internally this.channel = channel; } void notify(String message) { channel.send(message); // doesn't know or care whether this is email, SMS, or anything else } } // usage NotificationService emailNotification = new NotificationService(new EmailService()); emailNotification.notify("Your order has been shipped"); // adding WhatsApp later — the entire diff, NotificationService is not touched class WhatsAppService implements NotificationChannel { public void send(String message) { System.out.println("Sending WhatsApp message: " + message); } } NotificationService whatsapp = new NotificationService(new WhatsAppService()); whatsapp.notify("Your ride has arrived");
▲ Edge case — Dependency Injection is not the same thing as Dependency Inversion
These two terms are related but not the same. Dependency Inversion is the design principle from this topic: depend on abstractions, not concrete implementations. Dependency Injection is a specific technique for supplying those dependencies from outside — passing NotificationChannel into NotificationService's constructor, rather than NotificationService constructing it internally. You can apply Dependency Injection without genuinely achieving Dependency Inversion — if the constructor parameter were typed as the concrete EmailService instead of the NotificationChannel interface, injecting it would only move the tight coupling around, not remove it. The interface is what actually inverts the dependency; the injection is just how it gets delivered.
▲ Edge case — inversion has a real cost when there's genuinely only one implementation, forever
Introducing an interface for a dependency that will provably never have a second implementation — a single, fixed system clock, say — adds a layer of indirection that doesn't pay for itself. DIP earns its value specifically when a dependency is genuinely likely to vary (different channels, different environments, a real implementation vs. a test double), not as a reflex applied to every single field a class happens to have.
This exact pattern — constructor-injected interfaces instead of hardwired concrete classes — is precisely what makes the Spring Framework's entire dependency injection model work. Every @Autowired constructor parameter typed as an interface, with Spring supplying a concrete implementation at runtime, is Dependency Inversion, automated at framework scale. It's also the entire reason mocking libraries like Mockito work at all: they can only substitute a fake implementation for something that was depended on as an interface in the first place.
JDBC is a classic, older example of the exact same idea: application code is written against the java.sql.Connection and java.sql.Driver interfaces, and the actual low-level driver — MySQL, PostgreSQL, or anything else — is swapped in without the application code changing at all. SLF4J follows the identical shape for logging: application code depends on SLF4J's abstraction, and the actual logging backend (Logback, Log4j2) is swapped in independently, without touching a single log statement in the application.
Q: What's the actual difference between Dependency Inversion and Dependency Injection?
A: Dependency Inversion is the principle — depend on abstractions, not concrete implementations; Dependency Injection is a technique for supplying those dependencies from outside, which only truly achieves inversion if what's injected is an abstraction, not a concrete class.
Q: What real, practical benefit does DIP give you for testing?
A: A class depending on an interface can be tested with a trivial fake implementation standing in for a real, expensive, or unreliable dependency — no real database, network call, or external service required.
Q: In the original NotificationService, what specifically made it "high-level" and EmailService "low-level"?
A: NotificationService represents a business capability (notifying a user) that doesn't inherently care about delivery mechanics; EmailService represents one specific, swappable mechanism for actually delivering a message — the business concept shouldn't be rewritten every time the delivery mechanism changes.
Q: Why did notifyByEmail() and notifyBySms() collapse into a single notify() method after the fix?
A: Because NotificationService no longer knows or cares which concrete channel it's holding — it only knows it satisfies NotificationChannel's send() contract, so one method covers every current and future channel.
Q: Does injecting a dependency through a constructor automatically satisfy DIP?
A: No — only if what's injected is typed as an abstraction. Injecting a concrete EmailService directly into a constructor parameter still leaves the high-level class coupled to that specific implementation.
Want a visual for this concept?
Generate a diagram tailored to “Dependency Inversion Principle” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →