Appearance
9.3.9 — Dependency Inversion, Inversion of Control and Dependency Injection
The most important file in the delivery app is the one that decides what a customer pays. It imports a payment company's software kit at the top:
typescript
// pricing/checkout.ts
import Stripe from "stripe"; // (1)
export class Checkout {
private stripe = new Stripe(process.env.STRIPE_KEY!); // (2)
async pay(order: Order): Promise<Receipt> {
const charge = await this.stripe.charges.create({ // (3)
amount: order.total.cents,
currency: "inr",
source: order.cardToken,
});
return Receipt.of(charge.id); // (4)
}
}Line (1) imports the vendor's kit. Line (2) builds a client using a secret key from the environment. Line (3) calls the vendor's own method with the vendor's own field names. Line (4) converts the answer back.
Four lines, and the business rules of the company now sit downstream of a vendor's design decisions. Here is what that costs, concretely:
You cannot run this without the internet. Exercising the checkout means talking to the vendor's test servers, which needs credentials, is slow, and fails when their test environment is down.
A vendor upgrade edits your business logic. When the kit releases a new major version and renames charges.create, the file you edit is the pricing file.
Switching vendors means rewriting this file. Not adding to it. Rewriting it, along with every other file that imports the kit.
The vendor's vocabulary leaks into yours. source, charges, currency: "inr" as a lowercase string — these are their words, and once they are spread across twelve files they are your words too.
Underneath all four is one structural fact, and Dependency Inversion is the name for fixing it.
1. What the principle says
Robert Martin's two-part statement:
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 pieces of vocabulary first, because the words are doing real work.
High-level means closer to the reason the business exists. Pricing, orders, eligibility rules. These change when the business changes.
Low-level means closer to a machine or a vendor. A database driver, a payment kit, a file system, an email service. These change when technology or a vendor changes.
The natural direction of dependency is high depending on low — the pricing code imports the payment kit, because that is what you need in order to charge a card. Inversion means turning that arrow around, and it is worth being very clear about what is inverted, because the name confuses people.
What is inverted is the direction of the source-code import, and along with it, the ownership of the interface. In the natural arrangement, the vendor's kit defines the shape and your code adapts to it. After inversion, your code defines the shape and the vendor's kit is adapted to fit. The important thing you own is not the interface file. It is the decision about what the shape should be.
2. The fix
typescript
// pricing/payment-gateway.ts — owned by the business side, not the vendor
export interface PaymentGateway { // (1)
charge(amount: Money, token: CardToken): Promise<ChargeId>;
refund(charge: ChargeId, amount: Money): Promise<RefundId>;
}
// pricing/checkout.ts — imports nothing from any vendor
import type { PaymentGateway } from "./payment-gateway"; // (2)
export class Checkout {
constructor(private readonly payments: PaymentGateway) {} // (3)
async pay(order: Order): Promise<Receipt> {
const id = await this.payments.charge(order.total, order.cardToken); // (4)
return Receipt.of(id);
}
}
// infrastructure/stripe-gateway.ts — the only file that knows the vendor exists
import Stripe from "stripe";
import type { PaymentGateway } from "../pricing/payment-gateway"; // (5)
export class StripeGateway implements PaymentGateway {
constructor(private readonly client: Stripe) {}
async charge(amount: Money, token: CardToken): Promise<ChargeId> {
try {
const charge = await this.client.charges.create({ // (6)
amount: amount.cents,
currency: amount.currency.toLowerCase(),
source: token.value,
});
return ChargeId.of(charge.id);
} catch (err) {
throw translateStripeError(err); // (7)
}
}
async refund(charge: ChargeId, amount: Money): Promise<RefundId> { /* … */ }
}Line (1) is the whole trick, and its location is the point. The interface lives in the pricing folder, with the code that needs it, described in the business's own words: Money, CardToken, ChargeId. Not source, not a lowercase currency string.
Line (2) is a type-only import, which disappears entirely when the code is compiled (3.7.1). So at runtime, the pricing folder has no dependency at all.
Line (3) receives whoever is playing the role instead of creating one.
Line (4) is now written in your vocabulary. A reader of the pricing code learns nothing about any vendor, which is exactly right, because the vendor has nothing to do with pricing.
Line (5) is the inverted arrow, and it is worth pausing here. The infrastructure file imports from the pricing folder. The dependency now points from the low-level detail towards the high-level rule. Before, it pointed the other way. Nothing else about the code changed; the arrow turned around.
Line (6) is where the vendor's vocabulary is confined. It exists in exactly one file.
Line (7) translates their errors into yours, so a caller can handle a declined card without learning the vendor's error codes. Skipping this is the most common half-finished version of this pattern: the interface is clean and the errors still leak the vendor everywhere.
3. Three names people confuse
These three get used interchangeably and mean different things. Being able to separate them is a reliable senior signal in an interview.
Dependency Inversion (DIP) is the principle: point your dependencies at abstractions that the high-level code owns.
Inversion of Control (IoC) is a broader idea about who is in charge of the flow. Normally your code calls a library. Under inversion of control, a framework calls your code. Every event handler you have ever written is this — you do not call the browser, you hand it a function and it calls you when a click happens. It is sometimes called the Hollywood principle: "don't call us, we'll call you."
Dependency Injection (DI) is one technique: give an object its collaborators instead of letting it create them. constructor(private payments: PaymentGateway) is the whole thing.
The relationship in one line: DI is a way to achieve DIP, and both are examples of IoC. And the important practical note — you can have DI with no DIP at all:
typescript
class Checkout {
constructor(private readonly stripe: Stripe) {}
}The dependency is injected. It is still a vendor class. Nothing was inverted, and none of the four costs from the top of this page went away. Injecting a concrete class is not Dependency Inversion. It is one small step: you can now pass a fake in, and that is worth something, but the vendor's vocabulary and churn are still in your business file.
4. Somebody has to choose: the composition root
If nothing creates its own dependencies, something must. The answer is one place, near the start of the program, usually called the composition root:
typescript
// main.ts — the only file that names concrete classes
const stripe = new Stripe(config.stripeKey);
const payments = new StripeGateway(stripe); // (1)
const orders = new PostgresOrderRepository(pool);
const checkout = new Checkout(payments, orders); // (2)
startServer(checkout);Line (1) picks the vendor. Line (2) hands it to the business rules. Everything downstream of this file talks to roles only.
This is not a workaround, it is the design. Knowledge of concrete classes used to be sprinkled through every file that happened to need something; now it is gathered into one file where it is visible and reviewable. If you want to know what this system is actually built from, you read one file.
On frameworks and containers. Some frameworks do this wiring for you, reading types or annotations to build the graph automatically. They are useful when the graph is genuinely large or when objects have different lifetimes — one instance per web request, one per customer account — because managing that by hand does breed bugs. But they are optional machinery, not the principle. Hand-wiring scales much further than people expect, it is greppable, and its failures are compile errors rather than start-up surprises (9.4.24 covers the ladder from hand-wiring upward).
5. What to invert and what to leave alone
Inverting everything is the classic over-application, and it produces a codebase where every simple thing takes a detour.
Invert these, because each is either volatile or awkward to run:
| Depend on a role | Because the real thing |
|---|---|
| the database | needs a running server |
| a payment or email vendor | changes on their schedule, costs money to call |
the clock (now: () => Date) | makes tests depend on when they run |
| random numbers, id generators | make results unrepeatable |
| the file system, the network | need a real machine and real permissions |
The clock is the one people skip and later regret. Any rule involving "expires after 30 days" or "the promotion ends at midnight" is untestable when the code reads the real clock, and worse, it is unverifiable in the one case you care about, which is the boundary.
Do not invert these:
Your own value objects. Money, OrderId, Percentage — these are your vocabulary, not a dependency, and injecting them buys nothing.
Standard library functions with no side effects. JSON.parse, Math.max, array methods. There is no second implementation and no test pain.
Anything with exactly one implementation and no test difficulty, where nobody can name the second one. That is speculation (9.3.4).
The test: is there a plausible second implementation, or is the real one painful to use in a test? A clock passes on the second count. Math.max fails both.
6. Interview calibration
The forty-five second answer: "High-level modules should not depend on low-level ones; both should depend on an abstraction, and crucially the high-level side owns that abstraction. Concretely, the pricing code stops importing a payment vendor's kit and instead defines a PaymentGateway interface in its own folder, in its own vocabulary. The vendor adapter then imports that interface, so the source-code dependency now points from the detail towards the rule — that reversal is what 'inversion' names. What it buys is that the business rules can be read and run without any vendor present, a vendor upgrade touches one infrastructure file instead of the pricing logic, and the vendor's field names and error codes live in exactly one place. I would add that injecting a dependency is not the same as inverting one — constructor(private stripe: Stripe) is injection with no inversion, because the vendor is still in my business file."
The follow-up that separates people: "so should everything be behind an interface?" Say no, and give the test: a plausible second implementation, or real pain using the concrete thing in a test. Then name what you would not invert — your own value objects and pure standard library calls — because knowing where a principle stops is the part that shows experience.
Recall
- High-level modules should not depend on low-level ones; both depend on an abstraction — and the high-level side owns it. High-level means close to the business; low-level means close to a machine or a vendor.
- What gets inverted is the direction of the import, and with it the ownership of the shape. Before: pricing imports the vendor. After: the vendor adapter imports pricing's interface.
- Four costs of the un-inverted version: you cannot run it without the network, a vendor upgrade edits your business logic, switching vendors is a rewrite, and the vendor's vocabulary spreads through your code. Translate their errors too, or the vendor still leaks everywhere.
- Three names: DIP is the principle, IoC is the broader "the framework calls you" idea (every event handler), DI is the technique of receiving collaborators. DI achieves DIP; both are kinds of IoC. Injecting a concrete vendor class is DI with no inversion.
- Somebody must choose, and that is the composition root — one file near startup that names concrete classes. Knowledge that was sprinkled everywhere is now in one reviewable place. Containers are optional machinery for large graphs and per-request lifetimes.
- Invert: the database, vendors, the clock, random numbers and ids, the file system, the network. Do not invert: your own value objects, pure standard library calls, anything with one implementation and no test pain. The test is a plausible second implementation or real pain in a test.
Self-test: What exactly is inverted, and where does the interface file live? Why is constructor(private stripe: Stripe) not Dependency Inversion? Separate DIP, IoC and DI in one sentence each. Name the dependency people forget to invert and the bug that follows. Give the two-part test for whether something deserves a role.
Quiz Bank
InterviewExplain Dependency Inversion, and distinguish it from Inversion of Control and Dependency Injection.
Dependency Inversion is the principle: high-level modules should not depend on low-level ones, and both should depend on an abstraction — with the high-level side owning that abstraction. High-level means close to why the business exists, such as pricing or eligibility rules. Low-level means close to a machine or a vendor, such as a database driver or a payment kit.
What is actually inverted is the direction of the source-code import, and with it the ownership of the shape. Before, the pricing file imports the vendor's kit and adapts to their field names. After, the pricing folder defines a PaymentGateway interface in its own vocabulary, and the vendor adapter imports that. The arrow that pointed from the rules down to the detail now points from the detail up to the rules.
What it buys, concretely: the business rules can be read and run with no vendor present, a vendor's breaking upgrade touches one infrastructure file rather than the pricing logic, and the vendor's vocabulary and error codes exist in exactly one place. That last part is worth stressing, because the common half-finished version has a clean interface and still lets vendor error codes leak into every caller.
Inversion of Control is a broader idea about who drives the flow. Normally your code calls a library; under inversion of control a framework calls your code. Every event handler is an example — you hand the browser a function and it calls you on a click.
Dependency Injection is one technique: give an object its collaborators rather than letting it create them.
The relationship: DI is a way to achieve DIP, and both are kinds of IoC. And the distinction that matters in practice — constructor(private stripe: Stripe) is dependency injection with no inversion at all. The dependency is injected and it is still a vendor class sitting in a business file, so the vendor's vocabulary and release schedule are still your problem. You gained the ability to pass a fake; you gained nothing else.
AppliedShould every dependency be behind an interface? How do you decide?
No, and applying it everywhere is the more common mistake among engineers who have just learned the principle, because it looks like craftsmanship and nobody wants to challenge it in review. Every role you introduce adds one hop of indirection that every future reader pays.
The two-part test: is there a plausible second implementation, or is the real thing painful to use in a test? One yes is enough.
Things that pass. The database, because it needs a running server. A payment or email vendor, because they change on their own schedule and cost money to call. The clock — pass now: () => Date — because a rule about "expires after 30 days" is otherwise untestable at exactly the boundary you care about. Random numbers and id generators, because they make results unrepeatable. The file system and the network, for the same reason as the database.
The clock is the one people skip and later regret, so it is worth naming explicitly.
Things that fail. Your own value objects — Money, OrderId, Percentage are your vocabulary, not a dependency, and injecting them adds a parameter and buys nothing. Pure standard library calls like JSON.parse or Math.max, where there is no second implementation and no test pain. And anything with one implementation whose second nobody can name, which is speculation.
A practical middle step for cases you are unsure about: in TypeScript you can narrow at the call site with Pick<Vendor, "charge"> instead of declaring a new interface, and because types are structural you can introduce the real interface later over classes that never mentioned it, with no edits to them. So waiting is nearly free, which means guessing early is a choice rather than a necessity.