Low-level designCore9 min
Dependency inversion principle
The code that decides what to do shouldn't depend on the code that does the low-level work. Both depend on an interface the decision-making code owns, so details can change without touching the policy.
A plan that waters a greenhouse
WateringPlan decides which zones need water and for how long. ValveBoard opens a numbered channel on the relay board.
1 · The situation
A watering plan that drives a valve board
A greenhouse waters its beds automatically. Each zone has a soil moisture reading, and WateringPlan decides which zones need water and for how long: the drier the soil below the target, the longer the valve stays open. That rule is the part the growers care about and change.
The valves hang off a relay board. ValveBoard knows how to drive it: pulse(channel, seconds) opens one numbered channel. The first version has the plan create a board and call it. The names are the same in all four languages; a zone is a small value with a name, a channel and a reading.
import java.util.List;
record Zone(String name, int channel, int moisture) {}
class ValveBoard {
void pulse(int channel, int seconds) {
System.out.println("board: channel " + channel + " open for " + seconds + " s");
}
}
class WateringPlan {
private final ValveBoard board = new ValveBoard();
private final int target;
WateringPlan(int target) {
this.target = target;
}
int water(List<Zone> zones) {
int watered = 0;
for (Zone zone : zones) {
if (zone.moisture() >= target) continue;
board.pulse(zone.channel(), 2 * (target - zone.moisture()));
watered++;
}
return watered;
}
}
public class Main {
public static void main(String[] args) {
List<Zone> siteA = List.of(
new Zone("Herbs", 1, 22),
new Zone("Tomatoes", 2, 41),
new Zone("Seedlings", 3, 30));
WateringPlan plan = new WateringPlan(35);
int watered = plan.water(siteA);
System.out.println("Site A: watered " + watered + " of " + siteA.size() + " zones");
}
}The class diagram of this version, drawn from its code: the plan owns a board and calls it directly.
board: channel 1 open for 26 s board: channel 3 open for 10 s Site A: watered 2 of 3 zones
2 · The change
A second site with other valves
The growers open a second site. Its valves come from another vendor, RainLink, and are opened over HTTP by zone name rather than by channel number. The watering rule is the same.
In this design the rule can't be reused as it is. WateringPlan names ValveBoard, creates it, and speaks its language of channels and pulses:
class WateringPlan {
private final ValveBoard board = new ValveBoard();
private final int target;
WateringPlan(int target) {
this.target = target;
}
int water(List<Zone> zones) {
int watered = 0;
for (Zone zone : zones) {
if (zone.moisture() >= target) continue;
board.pulse(zone.channel(), 2 * (target - zone.moisture()));
watered++;
}
return watered;
}
}Supporting RainLink means editing the plan: a second field, a flag for which site it's at, and a branch around every valve call. That's an edit to the growers' rule for a reason that has nothing to do with watering. It isn't the only cost:
What the plan depends on | What that costs |
|---|---|
The | Every new kind of valve edits |
The board's own terms: channels and pulses | A vendor whose valves have names instead of channels doesn't fit the plan's calls |
A board it creates itself | Testing the rule needs a real board, or a way in that the plan doesn't offer |
The direction of the dependency is the problem. The policy, the code that makes the decisions, depends on a detail, the code that talks to hardware. Details change more often than policy, and each change reaches upward.
3 · The fix
Let the plan own an interface, and the boards implement it
Start from the first version. Ask what the plan needs from a valve, in the plan's own words, and make that an interface:
Valves has one method, open(zone, seconds). It speaks in zones and seconds, the plan's terms, not in channels and pulses.
interface Valves {
void open(Zone zone, int seconds);
}Site A prints exactly what it printed before, and site B is watered by the same rule:
board: channel 1 open for 26 s board: channel 3 open for 10 s Site A: watered 2 of 3 zones rainlink: POST /zones/lettuce/open?seconds=16 Site B: watered 1 of 2 zones
The middle step is worth remembering. Adding an interface changed nothing about the dependency while the plan still wrote new ValveBoard(): a class that creates its collaborator depends on it, whatever type its field has.
4 · The principle
Depend on abstractions the policy owns
The dependency inversion principle has two parts:
- High-level modules shouldn't depend on low-level modules; both should depend on abstractions.
WateringPlanandValveBoardboth depend onValves. - Abstractions shouldn't depend on details; details should depend on abstractions.
Valvesmentions zones and seconds, nothing about relays or HTTP, and each board implements it.
After the fix: the plan depends on Valves, and both boards implement it. No arrow leaves the plan toward a board.
What's inverted is the direction of the source code dependency, not the direction of the calls. At run time the plan still calls the board. In the code, the arrow used to run from the plan to the board; now it runs from each board up to the plan's interface.
Aspect | Before | After |
|---|---|---|
Who calls whom at run time | The plan calls the board | The plan calls the board, through |
Whose code names whom | The plan names | Each board names |
Where the interface lives | There is none | With the plan, the code that needs it |
What a new vendor changes |
| Nothing that exists: one new class |
Ownership matters. The interface belongs with the policy that uses it. In a larger codebase, WateringPlan and Valves sit in one package and the boards in another; the hardware package imports the watering package, never the reverse. An interface that sits next to ValveBoard and copies its methods would keep the old direction under a new name.
5 · When breaking it is fine
Stable details don't need an interface
The fix has costs:
- An extra type for every inverted dependency.
- A level of indirection when reading: jumping to
open()lands on the interface, not on the code that runs. - Wiring. Something, here
main, now has to create the boards and hand them over.
Depend on the concrete class when the detail is stable and you'd never swap it:
- The standard library.
WateringPlanuses a list of zones directly; nobody needs an interface in front ofArrayList. - Values.
Zoneis plain data, and depending on it costs nothing. - One implementation, no tests that need a fake. An interface with a single class behind it and no prospect of another is a mirror that has to be kept in step.
Dependency | Invert it? | Why |
|---|---|---|
Hardware, a network service, a vendor SDK | Yes | It changes for reasons the policy doesn't share, and tests need a stand-in |
The clock, randomness, the file system | Usually | Tests need to control them |
Lists, strings, your own value types | No | Stable, with nothing to swap |
6 · Getting it right
Write the interface in the policy's words
- Name the interface for the need, not the device.
Valves.open(zone, seconds), notRelayBoard.pulse(channel, ms). - Don't leak details through it. No channel numbers, HTTP status codes or vendor exceptions in the interface; each implementation translates its own.
- Keep it as small as the policy needs. One method here; see Interface segregation.
- Move creation out. The policy should neither create nor look up its details. Who does it, and where, is the subject of Dependency injection.
- Wrap what you can't edit. For a vendor library with its own types, write a small class that implements your interface and calls the library: an adapter.
Each language draws the boundary a little differently:
Language | The policy's interface | How a detail meets it |
|---|---|---|
Java | An interface in the policy's package |
|
C++ | An abstract class in the policy's header | Derives from it, in its own source file; the policy's files never include the board's header |
JavaScript | A documented shape; the policy's module imports no board module | Any object with |
Python | A | Any class with |
7 · Look-alikes
Inversion, injection and their relatives
These names are often used as if they meant the same thing. They answer different questions.
Idea | The question it answers |
|---|---|
Dependency inversion | Which way should the source code dependency between policy and detail point? |
Dependency injection | How does an object get the collaborators it depends on, without creating them? |
Inversion of control | Who drives the program: your code, or a framework that calls your code? |
Strategy | How does an object switch between ways of doing one job? |
Adapter | How does an existing class fit an interface it wasn't written for? |
Ports and adapters | How does a whole application keep its core free of frameworks and devices? This principle, applied at the scale of the architecture |
The checks recognize the fixed version as Strategy as well as dependency injection, and the shape is the same: a class holding one of several implementations, given from outside. The intent differs. Strategy is about choosing a behavior; dependency inversion is about keeping the policy's code free of its details, even when there's only ever one detail at a time.
8 · In practice
Pricing at the garage
In the parking-lot practice problem, ParkingLot is the policy and pricing is a detail that changes on match days. The lot holds a pricing interface and is given an implementation, so a new pricing scheme never edits the lot.
The checks flag a lot that creates its own pricing with new: the same half-step as the seam version here, an interface on the field and a concrete class behind it. Design the parking lot with the lot depending only on what it owns.
Check yourself
4 questions
Takeaways
Remember this
- High-level policy and low-level details should both depend on an abstraction.
- The abstraction belongs to the policy and is written in its terms, not the device's.
- What's inverted is the source code dependency, not the direction of the calls.
- Adding an interface isn't enough: a class that creates its collaborator still depends on it.
- Stable details, such as the standard library and your own values, don't need an interface.