The Principle, Stated Precisely
The Dependency Inversion Principle (DIP), the “D” in SOLID, has two clauses that people routinely misquote. Robert C. Martin’s original wording:
- 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.
Two terms carry the weight here, so pin them down:
- High-level module = code that holds policy — the business rules, the orchestration, the “what should happen.” Example: an
OrderServicedeciding that a paid order must be confirmed and a receipt sent. - Low-level module = code that holds mechanism — the concrete details of how something is done. Example:
StripeClient,PostgresOrderRepo,SmtpMailer.
The naive dependency graph points downward: OrderService → StripeClient → SDK. Policy depends on detail. DIP says invert that arrow. Both the high-level policy and the low-level mechanism should point at an abstraction the high-level module owns.
NAIVE (policy depends on detail) INVERTED (both depend on abstraction)
+----------------+ +----------------+
| OrderService | (high-level) | OrderService | (high-level)
+----------------+ +----------------+
| |
v v
+----------------+ +================+ <- abstraction
| StripeClient | (low-level) | PaymentGateway| OWNED by the
+----------------+ +================+ high-level layer
^
| implements
+----------------+
| StripeClient | (low-level)
+----------------+
The “inversion” is the direction of source-code dependency, not the direction of runtime control or data flow. At runtime OrderService still calls into Stripe. But in the source graph, the arrow from the concrete StripeClient now points up at an interface defined alongside the policy. That re-pointing is the entire principle.
A Concrete Violation
Here is a NotificationService (policy: “when an account is locked, alert the user”) wired directly to a concrete email implementation. This is the smell DIP targets.
// SMELL: high-level policy reaches down into a concrete detail.
class SmtpEmailClient {
send(to: string, subject: string, body: string): void {
// opens a socket to smtp.acme.com, speaks SMTP...
console.log(`SMTP -> ${to}: ${subject}`);
}
}
class NotificationService {
private email = new SmtpEmailClient(); // <-- hard-wired 'new'
accountLocked(userEmail: string): void {
// business rule lives here, but it's welded to SMTP
this.email.send(userEmail, "Account locked", "We locked your account.");
}
}What is actually wrong here? Walk the consequences — each is an interview talking point:
- Untestable in isolation. To unit-test
accountLockedyou must let it open a real SMTP socket, or monkey-patch the global. There is no seam to inject a fake. - Unswappable. Product wants SMS or a Slack ping instead. You edit
NotificationService— a policy class — to change a mechanism. That is an Open/Closed violation born directly from the DIP violation. - Compile-time coupling. In a compiled language,
NotificationServicewon’t build without the SMTP module and its transitive SDK. Your business core drags infrastructure into every consumer. - The arrow points the wrong way. The most valuable, most stable code in your system (policy) now depends on the most volatile code (a third-party transport). Volatility flows into your core.
Adding an interface but still calling new SmtpEmailClient() inside NotificationService fixes almost nothing — if the field is typed as the interface, call sites depend on the abstraction, but the construction-site source dependency on the concrete type remains. DIP is about where the concrete type is named, not merely about whether an interface exists somewhere. Push that new out to the edge.
The Refactor
Define an abstraction owned by the policy layer (it expresses what the policy needs, in the policy’s vocabulary — Notifier.notify, not SmtpClient.send). Make NotificationService depend only on that. Push concrete construction out to the edge.
// Abstraction owned by the high-level layer, in ITS vocabulary.
interface Notifier {
notify(userId: string, message: string): void;
}
// High-level policy: depends ONLY on the abstraction.
class NotificationService {
constructor(private readonly notifier: Notifier) {} // injected
accountLocked(userId: string): void {
this.notifier.notify(userId, "We locked your account.");
}
}
// Low-level details: depend on (implement) the abstraction.
class EmailNotifier implements Notifier {
notify(userId: string, message: string): void {
/* real impl resolves email + speaks SMTP; stubbed here */
console.log(`email ${userId}: ${message}`);
}
}
class SlackNotifier implements Notifier {
notify(userId: string, message: string): void {
console.log(`slack ${userId}: ${message}`);
}
}
// The composition root wires concretes to abstractions — one place, the edge.
const svc = new NotificationService(new SlackNotifier());
svc.accountLocked("u_42");Now product’s “use Slack” request is a one-line change at the composition root, with zero edits to policy. And the test writes itself:
// A fake Notifier — no sockets, no network, instant.
class SpyNotifier implements Notifier {
calls: { userId: string; message: string }[] = [];
notify(userId: string, message: string): void {
this.calls.push({ userId, message });
}
}
const spy = new SpyNotifier();
new NotificationService(spy).accountLocked("u_42");
console.assert(spy.calls.length === 1);
console.assert(spy.calls[0].userId === "u_42");Define the interface in the policy’s vocabulary. As for where it lives: the Clean Architecture / Hexagonal stance is that the high-level layer should own the port — if Notifier lives in the email package, your core transitively depends on email. (Some layering schemes instead place ports in a separate shared contracts module that both sides depend on; that is also legitimate, as long as the port does not live inside an adapter.) The classic structure: high-level layer declares the port; the infrastructure layer implements it. This is “Ports and Adapters” (Hexagonal) — DIP applied at the module boundary.
DIP vs. Dependency Injection vs. IoC
These three are constantly conflated. Keep them straight — it’s a frequent senior-level probe.
| Concept | Category | Answers the question | Example |
|---|---|---|---|
| DIP | A design principle | Which way should source dependencies point? | ”Policy depends on the Notifier interface, not EmailNotifier.” |
| Dependency Injection (DI) | A technique | How does an object get its collaborators? | Passing notifier into the constructor instead of new-ing it. |
| Inversion of Control (IoC) | A broad pattern | Who is in charge of flow / lifecycle? | A framework or container constructs and wires objects; your code is called, it doesn’t call. |
The relationships:
- DI is one way to achieve DIP, but neither implies the other. You can inject a concrete class (DI without DIP — no abstraction, still coupled). You can satisfy DIP via a service locator, a factory, or a default parameter (DIP without constructor DI). They overlap but are independent axes.
- IoC is the umbrella; DI is a kind of IoC. “Inversion of control” originally describes any flip of “who calls whom” — the template method pattern, event callbacks, and the main loop of a framework are all IoC. DI is specifically the inversion of who supplies dependencies (Fowler’s framing: DI is a subset of IoC).
- A DI container (Spring, .NET’s
IServiceProvider, etc.) is an IoC container that automates DI. It is a tool, not the principle. You do not need one to follow DIP — a hand-written composition root (themain/wiring shown above) is enough.
“We use Spring, so we follow DIP” is a non-sequitur, and interviewers listen for it. A container injects whatever you tell it to. If your bean depends on a concrete StripeClient, the container faithfully injects a concrete StripeClient and DIP is still violated. The container automates the mechanism (DI); it does not supply the judgment (choosing the right abstraction).
The DI-without-DIP trap (highest-signal senior misconception)
Injecting a concrete type satisfies DI but not DIP. The source arrow still points at the concretion:
DI without DIP DI with DIP
+----------------+ +----------------+
| OrderService | | OrderService |
+----------------+ +----------------+
| ctor injects | ctor injects
v a CONCRETE type v an ABSTRACTION
+----------------+ +================+
| StripeClient | <- still a | PaymentGateway|
+----------------+ source dep on +================+
the concretion ^ implements
+----------------+
| StripeClient |
+----------------+
Passing new StripeClient() into the constructor moved the new, but OrderService’s source still names StripeClient. You gained a little testability (you can pass a subclass) but kept the coupling and the volatility inflow. The fix is the same: depend on an interface you own.
How DIP Ties SOLID Together
DIP is the keystone — the others tend to collapse without it, and it pays them back:
- Open/Closed (OCP): You extend behavior by adding a new implementation of an existing abstraction (a
SlackNotifier) without modifying the policy. That open-for-extension/closed-for-modification property is delivered by depending on an abstraction — i.e., by DIP. - Liskov Substitution (LSP): DIP is only safe if every implementation is a valid substitute for the abstraction. If
SlackNotifierthrows whereEmailNotifiersilently succeeds, swapping it breaks the policy. DIP creates the substitution point; LSP governs whether substitution is sound. - Interface Segregation (ISP): DIP says depend on abstractions; ISP says make those abstractions narrow and client-specific. A fat
Notifierforcing an SMS adapter to implementattachPdf()is a DIP target that’s badly shaped — ISP fixes the shape so implementations aren’t dragged into irrelevant methods. - Single Responsibility (SRP): Separating policy from mechanism so they can depend on a shared abstraction requires that they be different classes with different reasons to change. SRP gives you the seam; DIP points the dependency across it correctly.
One-line synthesis for an interview: SRP separates concerns into distinct modules; DIP points the dependencies between those modules toward abstractions; OCP, LSP, and ISP describe how to keep those abstractions extensible, substitutable, and lean. DIP is the arrow that makes the separation pay off.
Relation to design patterns
Most “decoupling” patterns are DIP made concrete:
- Strategy — the client depends on a strategy interface, not a concrete algorithm. Pure DIP.
- Factory / Abstract Factory — exists to push concrete
newcalls out of policy, so policy names only abstractions. Factories are how you keep DIP without a container. - Adapter — wraps a third-party concretion behind your abstraction, so your core depends on your port, not their SDK.
- Observer — subject depends on an
Observerinterface, not on its concrete listeners.
The Interview Signal
When an interviewer hears you reason about DIP correctly, here’s what they conclude:
- You think in terms of policy vs. mechanism and know which is the valuable, stable part to protect.
- You design testable seams by default — you reach for an injected abstraction rather than mocking globals or hitting real I/O.
- You can name an abstraction in the client’s language and resist leaking vendor concepts (
charge(), notstripeCreateChargeV2()). - You don’t cargo-cult: you can articulate when an abstraction is premature (a single implementation, internal-only, never tested in isolation — a YAGNI trap) versus genuinely warranted (volatile dependency, an I/O boundary, a need to fake in tests).
In an interview, say it out loud as you draw the dependency graph: “I’ll have OrderService depend on a PaymentGateway interface that I define in the domain layer. StripeGateway implements it in the infrastructure layer, and I wire the concrete one in the composition root. That keeps my business rules free of Stripe and lets me drop in a fake gateway in tests without touching the network.” That single sentence demonstrates you understand the principle, the technique that delivers it, and why it pays off — which is exactly the signal the interviewer is listening for.