Dependency Inversion
Business policy defines the interface; infrastructure implements it. Both arrows point at the middle — and none of this requires a container.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
How do I let a business rule trigger an effect it must not know the mechanism of?
When an order ships, the customer gets notified. Right now that means an email through SendGrid. Sales have already asked for SMS, and the shipping rules must not have to change when they get it.
Shipping calls sendGrid.send(...) where the order transitions. It is one line, it is obvious what happens, and any reader can see the actual email being sent without following an indirection. In a system that will only ever send email, this is the correct code and adding an interface to it would be pure waste.
SMS arrives. The shipping rule now has an if on channel, and it has acquired a reason to change that has nothing to do with shipping (Divergent Change).
- SMS arrives. The shipping rule now has an
ifon channel, and it has acquired a reason to change that has nothing to do with shipping (Divergent Change). - The audit review is now triggered by a notification-vendor change, because the vendor call lives in the audited module. The cost of the change is a week of waiting, not an hour of typing.
- The shipping tests need an API key, so somebody adds a
if (env === "test") returninside the production path — the single most common way an untestable dependency becomes a production incident. - A second policy — "premium customers also get a push notification" — has nowhere to live except inside shipping, which now knows about customer tiers as well.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The notification provider is a third-party SDK that requires network access and an API key, and its client object cannot be constructed in a unit test.
- Shipping rules are audited: a change to that module triggers a review by someone outside the team, so churn there is expensive in calendar time, not just in effort.
- Java on the server, so an interface is an idiomatic, zero-friction construct; the same design in a language with first-class functions may not need a named type at all.
- There is no DI container in the codebase and nobody has budget to introduce one.
- An order that reaches Shipped must have exactly one notification attempt recorded, whatever the channel — the rule owns "notify", the channel owns "how".
- The shipping module must compile and its tests must run with no network, no API key and no third-party SDK on the classpath.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Shipping owns the decision that a notification must happen and what it must say.
- The interface — owned by shipping, not by infrastructure — owns the vocabulary: what a notification *is* in this system's terms.
- The SendGrid adapter owns everything about SendGrid: the SDK, the key, the retry semantics, the mapping from our vocabulary to theirs (Boundary Adapters).
- The composition root owns picking which adapter is in play (Wiring and the Composition Root).
- The seam is the interface, and the load-bearing detail is which side declares it. An interface declared in the infrastructure package and consumed by policy has not inverted anything — the arrow still points from policy to infrastructure, just via one more file.
- Declared by the policy side, the interface is part of policy's definition of what it needs. Infrastructure depends on policy. That is the inversion, and it is visible as an import statement, which is the only place it is ever real.
- The interface should be shaped by what the caller needs, not by what the provider offers.
notify(order, event)is policy vocabulary;sendTransactionalEmail(templateId, mergeVars)is SendGrid vocabulary wearing an interface (Interface Segregation, Critically).
Both arrows point at the middle
The picture people draw for DIP is a policy box, an interface, and an implementation box, with arrows from policy and implementation both pointing at the interface. It is a good picture and it is regularly reproduced by codebases that have not done it, because the picture does not show the one detail that decides everything: which package the interface file lives in.
Put the interface next to the implementation and you have a policy module that will not compile without the vendor package on the path. Put it next to the policy and the vendor package can be deleted and policy still builds. Same three boxes, opposite dependency graphs.
1// ── shipping/Notifier.java (policy package owns the interface)2package com.acme.shipping;3 4public interface Notifier {5 void orderShipped(OrderId id, TrackingCode code); // our nouns6}7 8// ── shipping/ShipOrder.java9package com.acme.shipping;10 11public final class ShipOrder {12 private final Notifier notifier;13 public ShipOrder(Notifier notifier) { this.notifier = notifier; }14 15 public void ship(Order order) {16 order.markShipped();17 notifier.orderShipped(order.id(), order.tracking());18 }19}20 21// ── adapters/SendGridNotifier.java (depends on shipping, not vice versa)22package com.acme.adapters;23import com.acme.shipping.Notifier; // <-- the inverted arrow24 25public final class SendGridNotifier implements Notifier { /* SDK lives here */ }The only thing that makes this DIP rather than decoration is line 3 of the third file: adapters imports shipping. Move Notifier into adapters and every other line stays identical while the design becomes its own opposite.
Three ideas, three questions, three different failure modes
Direction, inversion and injection get taught in one breath and they are not the same thing. Each answers a different question, each can be done without the others, and each has its own way of going wrong. Teams that collapse them buy a container, register every class, and end up with the original dependency graph plus a startup-time failure mode.
The test for whether you have understood the split: it is entirely possible to have perfect dependency direction with no interfaces at all, and it is entirely possible to have a fully configured container with policy importing the vendor SDK directly.
- A container is a tool for the third column only. It has no opinion about the first two and cannot give them to you (Wiring and the Composition Root).
- Most real improvements in a codebase are in the first column, which is the one with no acronym and no library (Dependency Direction).
- The second column is the only one of the three that adds a permanent artefact — the interface — and therefore the only one with an ongoing maintenance cost.
| Direction | Inversion | Injection | |
|---|---|---|---|
| The question | Which module should know the other exists? | How do I turn an arrow that points the wrong way? | How does this object get hold of its collaborator? |
| The move | Move sequencing up, or pass data down | Declare the interface on the consuming side | Pass it in rather than construct it |
| Where it is visible | The import graph | Which package the interface file is in | The constructor signature |
| Can exist without the others? | Yes — most well-directed code has no interfaces | Yes — an inverted port can be newed at the top of main | Yes — you can inject a concrete class and invert nothing |
| Its own failure mode | A god-object service that sequences everything | The interface declared next to its only implementation | Constructor with fourteen parameters, or a container nobody can trace |
| What it costs | Over-fetching and a deeper stack | A file, a name, and a frozen vocabulary | Wiring that has to live somewhere |
The interface with one implementation
The characteristic residue of DIP applied as a rule is an interface named FooService with exactly one implementation named FooServiceImpl, in the same package, with identical method signatures. It is worth naming as a smell because it is so common that it reads as normal, and because a smell is a question rather than a verdict: there are real cases where this exact shape is right.
The useful diagnostic is not the implementation count. It is whether the interface would still be worth writing if you knew for certain there would never be a second implementation. Sometimes the answer is yes — because the interface is what keeps the vendor SDK off the policy classpath, or because the test double is the second implementation and that is a legitimate one.
looks like PaymentService and PaymentServiceImpl in the same package, one implementing the other, every method identical, and the interface changing in the same commit as the implementation every single time.
suggests The interface was added because "classes have interfaces", not because a boundary was needed. It is not inverting anything — both files are on the same side — and it forces every reader to make an extra hop with no information gained.
fix Inline it. Delete the interface, rename the implementation to the good name, and let the concrete class be the type. If a second implementation appears later, extracting the interface then is a mechanical refactor your IDE does in one keystroke — and by then you will know from two real cases what the interface should actually say (The Rule of Three).
How to build it
Most important first.
- Write the interface from the caller's side, in the caller's language, in the caller's package. If you cannot name its methods without using the vendor's nouns, you are not ready to draw the boundary yet.
- Keep it small enough that a second implementation is genuinely plausible. A twelve-method port is a vendor SDK with a coat on.
- Put the implementation in a package that depends on the policy package and is depended on by nobody except the composition root.
- Check the arrow by deleting the adapter and compiling. If policy still compiles, the inversion is real; if it does not, it was decoration (Stable Dependencies).
- Do this for volatile dependencies only. Inverting a dependency on a pure formatting function costs a file and buys nothing (Volatile Dependencies).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Adding SMS under the naive design: edit the audited shipping module, add a branch, get an out-of-team review, update shipping tests to stub two vendors. Roughly a week of calendar time, most of it waiting.
- Adding SMS after inversion: one new file implementing
Notifier, one line changed in the composition root, one adapter test. Shipping is untouched, so the audit is not triggered. Roughly a day. - Adding a *rule* — "do not notify for warehouse-internal transfers" — is one edit inside shipping under either design. Inversion did nothing for this change, which is the honest half of the story.
- Adding a notification concept the interface cannot express — a channel that needs a delivery receipt callback — costs a change to the interface and to every implementation of it. That is the price of having an interface at all, and it is charged exactly when the abstraction was drawn on the wrong axis.
- Every inversion adds a file, a name and an indirection. Navigating "where does this actually go" now requires knowing the wiring, and IDE jump-to-definition lands on the interface rather than the code that runs.
- The interface freezes a vocabulary early. If it is wrong, changing it is now an N-implementation change instead of a one-file change, so inversion converts a cheap mistake into an expensive one.
- It buys nothing at all for the changes that stay inside policy, which in most codebases are the majority of changes.
What can go wrong
- The interface is declared in the infrastructure package. Extremely common, produces a diagram that looks inverted and an import graph that is not.
- The interface leaks the provider: a
Notifierwhose method takes atemplateIdbinds every future implementation to a template model that only SendGrid has (Leaky Abstractions). - The abstraction is written before the second implementation exists, along the wrong axis, and when SMS actually arrives it does not fit — because SMS has a length limit and no subject line, and nobody modelled that (Premature Abstraction).
- The team adopts a container, registers every class in it, and believes the inversion is done. Nothing was inverted; the graph is identical and now it fails at startup instead of at compile time (Dependency Inversion, Critically).
- Shipping depends on the
Notifierinterface it owns, and on nothing else outside the domain. - The SendGrid adapter depends on shipping (for the interface and the vocabulary) and on the SendGrid SDK. Both of those dependencies point away from the stable core.
- The composition root depends on everything, which is why it is the only place allowed to.
- Note what has *not* happened: nothing depends on a container, a framework, or an annotation. Inversion is a source-code arrangement (Dependency Injection is how the object is handed over, and it is a different question).
- "DIP means using a DI container." It does not mention one. Inversion is about which module declares the interface; injection is about how an object receives a collaborator; a container is one mechanism for the second. A codebase can do all of DIP with
newin a singlemain()function (Wiring and the Composition Root). - "Depend on abstractions, not concretions — so everything gets an interface." The principle is about *volatile* concretions. Depending on
String, on a decimal type, or on a stable in-house value object is depending on a concretion and is entirely correct (Dependency Inversion, Critically). - "The interface belongs with the implementation, that is where the code is." This is the single mistake that makes the whole exercise pointless, and it is what most project templates do by default.
- "Now we can swap providers." Rarely true and rarely the point. The provider swap almost never happens; the testability and the change-isolation happen every week (The Cost of Change).
- divergent-change
- shotgun-surgery
Testing it, and how it ages
- Shipping is tested with an in-memory recording
Notifierwritten by hand in three lines — not a mocking framework, because the assertion is "a notification was requested", not "a method was called with these arguments" (Test Doubles, Precisely). - The adapter is tested against SendGrid's sandbox or a recorded HTTP fixture. That is the only test that can catch the vendor changing its API, and it is the one people skip (Where a Test Must Be Real).
- A contract test that every implementation of
Notifiermust pass — same inputs, same observable promises — so a second adapter cannot quietly weaken the guarantee (Contract Tests, Liskov Substitution, Critically). - An architecture test asserting that the shipping package imports nothing from the adapter packages. Without it the inversion decays in about six months.
- The second implementation is where the abstraction gets its real shape. It is normal and healthy for the interface to change when the second one lands — that is the design being corrected by evidence rather than guessed (The Rule of Three).
- When a third channel arrives with genuinely different semantics (a queued digest, say), the single interface starts straining, and the right move is usually to split it rather than to add optional parameters.
- The design ages badly if the number of implementations stays at one for years. That is the state in which the interface is pure cost, and the right response is to inline it back, which is a five-minute refactor nobody ever does (Speculative Generality).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALThe core claim — that the side declaring the interface determines the direction of the source dependency — follows from how compilation units reference each other and holds in any language with modules.
- PARADIGM-SPECIFICIn an OO language the inversion is a named interface type and a class implementing it. In a language with first-class functions the same inversion is a function parameter, and no interface, package or adapter class is involved:
ship(order, notify)inverts the dependency completely. In Haskell or Scala the same thing again appears as a type-class constraint or a reader effect. The idea survives the translation; the ceremony does not, and importing the OO ceremony into a functional codebase is a common and expensive mistake. - CONTESTEDThe strongest opposing case, made most forcefully by people who maintain large ports-and-adapters codebases: inversion multiplies the number of files and names per feature, and the option it buys — a second implementation — is exercised in a minority of ports. Measured over a codebase's life, they argue, the total indirection cost exceeds the total isolation benefit, and the cases where it genuinely paid (databases, clocks, third-party APIs) are a short and predictable list that does not need a principle to identify. This is a serious argument and the list really is short; what it undersells is that the testability benefit is collected on every change, not only when an implementation is swapped.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — the contract test that every adapter must pass is the mechanism that keeps an inverted boundary honest, and its design belongs to them.
- — System Design — once the adapter crosses a network the interface acquires timeouts, partial failure and retries, which changes what the port is allowed to promise.