Back to the path
Level 2 intermediate 25 min #solid#dependency-inversion#dependency-injection#ioc#abstractions#testability#lld-interview

Dependency Inversion Principle: Program to Abstractions

The "D" in SOLID — why high-level policy should never reach down to concrete details. We dissect a violation, refactor it across three languages, then untangle DIP from dependency injection and IoC and show how it makes the rest of SOLID (and your tests) actually work.

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:

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
  2. 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 OrderService deciding 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: OrderServiceStripeClient → 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)
                                            +----------------+
Key idea

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 accountLocked you 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, NotificationService won’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.
Common pitfall

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");
Tip

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.

ConceptCategoryAnswers the questionExample
DIPA design principleWhich way should source dependencies point?”Policy depends on the Notifier interface, not EmailNotifier.”
Dependency Injection (DI)A techniqueHow does an object get its collaborators?Passing notifier into the constructor instead of new-ing it.
Inversion of Control (IoC)A broad patternWho 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 (the main/wiring shown above) is enough.
Watch out

“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 SlackNotifier throws where EmailNotifier silently 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 Notifier forcing an SMS adapter to implement attachPdf() 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.
Key idea

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 new calls 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 Observer interface, 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(), not stripeCreateChargeV2()).
  • 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.

Assessment

Pass mark: 70% on the concept questions unlocks the next lesson.

1. According to the Dependency Inversion Principle, what exactly is being 'inverted'?

2. An OrderService receives a concrete StripeClient through its constructor: `new OrderService(new StripeClient())`. Which statement is most accurate?

3. Where should the `Notifier` port (interface) live to genuinely satisfy DIP?

4. Which of the following are TRUE about the relationship between DIP, DI, IoC, and design patterns? (Select all that apply.) (select all)

Design problem 5

Design the dependency structure for an `OrderService.placeOrder(order)` that (a) charges the customer via Stripe and (b) persists the order to Postgres. Specify the abstractions, which module each abstraction and each implementation lives in, where the concrete wiring happens, and how you would unit-test placeOrder without touching the network or a database. Show the key types in one language and explain the dependency arrows.