The fifth SOLID principle: why high-level modules shouldn't depend on low-level details, constructor injection as the practical fix, and a full NotificationService refactor.
Published September 22, 2026
The last of the five SOLID letters, and the one that quietly underlies why dependency injection frameworks (Spring included) exist at all.
DIP has two parts, and most people only remember the first:
"High-level" means policy/business logic; "low-level" means implementation detail (a specific database, a specific email provider, a specific file format). The inversion in the name refers to who depends on whom: normally you'd think the business logic depends on its infrastructure; DIP says the dependency arrow should point the other way — infrastructure depends on an abstraction the business logic defines.
class EmailSender {
void send(String to, String message) { /* SMTP-specific code */ }
}
class NotificationService {
private final EmailSender emailSender = new EmailSender(); // concrete dependency, hard-wired
void notifyUser(String userEmail, String message) {
emailSender.send(userEmail, message);
}
}
NotificationService is a high-level policy class ("notify the user") directly bound to a low-level detail (EmailSender, specifically SMTP). Two concrete problems: you cannot add SMS or push notifications without editing NotificationService (this is also an OCP violation — see Single Responsibility & Open/Closed), and you cannot unit-test NotificationService without actually sending an email, since there's no seam to substitute a fake.
interface NotificationChannel { void send(String to, String message); }
class EmailChannel implements NotificationChannel {
public void send(String to, String message) { /* SMTP-specific code */ }
}
class SmsChannel implements NotificationChannel {
public void send(String to, String message) { /* SMS gateway code */ }
}
class NotificationService {
private final NotificationChannel channel; // depends on the abstraction, not a concrete class
NotificationService(NotificationChannel channel) { // constructor injection
this.channel = channel;
}
void notifyUser(String userEmail, String message) {
channel.send(userEmail, message);
}
}
Now NotificationService (high-level) and EmailChannel/SmsChannel (low-level) both depend on NotificationChannel (the abstraction) — neither depends on the other directly. Adding push notifications means writing a PushChannel class; NotificationService never changes. Testing NotificationService means passing a mock NotificationChannel — no real SMTP call required.
The pattern above — passing the dependency in through the constructor rather than instantiating it internally — is dependency injection, and it's the mechanical way DIP gets implemented in real Java code. This is also exactly what Spring's @Autowired/constructor injection automates: Spring's container looks at NotificationService's constructor, sees it needs a NotificationChannel, and wires in whichever bean implements it — the DIP structure is what makes that wiring possible in the first place. Without DIP (i.e. with the original hard-wired new EmailSender()), there's nothing for a DI container to inject into — the dependency is already baked in at compile time.
Q: Isn't "depend on abstractions" just the same advice as "program to an interface"?
A: Closely related but not identical — "program to an interface" is about the type you reference; DIP is specifically about the direction of the dependency between high-level and low-level modules. You can program to an interface and still violate DIP if the high-level module is the one that defines and owns a low-level-specific interface (e.g. an EmailSenderInterface shaped entirely around SMTP concerns) rather than an abstraction shaped around what the high-level policy actually needs.
Q: Does DIP mean every single class needs an interface? A: No — DIP matters most at architectural seams: boundaries between business logic and infrastructure (databases, external APIs, file systems, notification channels). Wrapping every trivial internal helper class in an interface "just in case" adds indirection without a real inversion benefit; reserve it for dependencies you genuinely expect to vary, swap, or mock in tests.
Q: How does constructor injection differ from field injection (@Autowired on a field)?
A: Constructor injection makes dependencies explicit and mandatory (the object cannot be constructed without them, and fields can be final), and makes the class trivially testable without a DI framework — just call the constructor directly with a fake. Field injection hides the dependency list, allows partially-constructed objects, and requires a DI container (or reflection) even in unit tests just to populate the fields — most modern Spring guidance now prefers constructor injection for exactly these reasons.
Q: Can DIP apply outside of class-to-class dependencies, e.g. between modules or services? A: Yes — the same principle at a coarser grain: a business-logic module shouldn't directly import a specific database driver's package; it should depend on a repository interface it owns, with the database-specific implementation living in a separate module that depends inward on that interface. This is the same shape that underlies hexagonal/ports-and-adapters architecture.