Engineering Atlas › Low-Level Design

Dependency inversion principle (DIP)

11 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The "before": policy welded to detail

A billing service sends reminders for unpaid invoices. The first version, written in a hurry:

import os, sendgrid, psycopg
from datetime import datetime, timedelta, timezone

class ReminderJob:
    def run(self):
        conn = psycopg.connect(os.environ["DB_URL"])
        rows = conn.execute("SELECT id, email, due_at FROM invoices WHERE paid = false")
        now = datetime.now(timezone.utc)
        mail = sendgrid.SendGridAPIClient(os.environ["SG_KEY"])
        for inv_id, email, due_at in rows:
            if due_at - now < timedelta(days=3):
                mail.send(build_message(email, inv_id))
                conn.execute("UPDATE invoices SET reminded_at = now() WHERE id = %s", [inv_id])

The rule that matters to the business is one line: remind when an invoice is due within three days. Everything else is detail, yet the rule cannot be tested without a Postgres database, a SendGrid key and control of the current time. Switching from SendGrid to SES means editing the class that holds the business rule.

The "after": policy owns its interfaces

Invert the dependencies. The reminder module declares what it needs, in its own terms:

class Clock(Protocol):
    def now(self) -> int: ...

class InvoiceRepo(Protocol):
    def unpaid(self) -> list[Invoice]: ...
    def mark_reminded(self, invoice_id: str, at: int) -> None: ...

class Notifier(Protocol):
    def send(self, customer: str, message: str) -> None: ...

class ReminderService:
    def __init__(self, clock: Clock, repo: InvoiceRepo, notifier: Notifier):
        self.clock, self.repo, self.notifier = clock, repo, notifier

A separate adapters package contains PostgresInvoiceRepo, SesNotifier and SystemClock, each importing the reminder module to implement its interfaces. A main.py (the composition root) constructs them and passes them in. The reminder module imports nothing from Postgres or AWS. The import graph now points inward, toward the policy.

Tests now construct the service with a fixed clock, an in-memory repo and a recording notifier, and run in microseconds. Swapping vendors means writing a new adapter, not editing the rule. And the interfaces describe the domain (unpaid, mark_reminded) rather than the database (execute, cursor): the abstraction belongs to the policy, so it speaks the policy's language.

Inversion is not the same as injection

Dependency injection (passing collaborators in) is a technique. Dependency inversion is a statement about which module owns the abstraction. You can inject a psycopg.Connection into a service: that is injection without inversion, because the policy still depends on the driver's API. You can also invert without a framework: a constructor with three parameters and a 20-line main is enough for most services. DI containers (Spring, Guice, .NET's built-in container, Go's Wire or Fx, Python's dependency-injector) automate the wiring when the object graph gets large, at the cost of making it less visible.

When inversion is overkill

Not every dependency deserves an interface. The standard library's string functions, a pure math helper, an internal data class: these are stable, deterministic and fast, and wrapping them adds nothing. The useful test is volatility and side effects. Invert a dependency when it does I/O, reads time or randomness, has a vendor you might replace, or is slow enough to keep out of unit tests. Leave it concrete otherwise.

Signs of over-application: an IStringFormatter; a UserServiceImpl that is the only implementation of UserService and has no fake; a container configuration file longer than the code it wires; new engineers unable to find which class runs in production without starting the app.

The same shape elsewhere

This is the trust-but-verify shape. You can only verify what you can control, and you can only control what you inject. The same idea appears in hardware (a device driver implements the kernel's interface, not the reverse), in plugin systems (a browser defines the extension API and extensions depend on it) and in AI tooling: an agent framework that defines a Tool interface and lets you plug in implementations is dependency inversion, and it is what makes agent runs replayable in tests with fake tools.

Down the stack

Layer 1The contract

The policy module promises behaviour in terms of its own interfaces: given a clock reading, a repository's contents and a notifier's responses, it produces a defined sequence of calls. Testable form: with injected fakes, the same inputs always give the same calls and the same result, and the module's import list contains no infrastructure package.

Layer 2The mechanism

Constructor injection stores references in fields; each call goes through an interface. Wiring happens once, at startup, in a composition root. Containers automate that step in different ways. Spring scans the classpath, reads annotations by reflection and builds a bean graph, often creating proxies for scoped or transactional beans. Go's Wire generates plain constructor calls at compile time, so there is no runtime container. Uber's Fx resolves the graph by reflection at startup. In Python and TypeScript, most teams pass dependencies explicitly; monkeypatching is a weaker substitute, because it patches a module global rather than the one object under test.

Package boundaries enforce the inversion. Go forbids import cycles, so if adapters imports reminder, reminder cannot import adapters; the compiler checks the direction. Tools such as import-linter for Python and ArchUnit for Java let you write that rule for languages that do not enforce it.

Layer 3The machine

Each call through an injected interface is an indirect call: about 1 to 3 ns with a warm branch predictor, plus a lost inlining opportunity. Compared to what these dependencies do (a Postgres query at 0.5 to 5 ms, an HTTP call to an email vendor at 50 to 300 ms) that is under one part in 100,000. Injection is effectively free on I/O paths. It is not free in a tight CPU loop, where an injected Hasher called 100 million times can cost a few hundred milliseconds that a direct call would not; keep inversion at I/O boundaries, not inside hot kernels.

The visible runtime costs come from containers. Reflection-based wiring at startup can take hundreds of milliseconds to several seconds in large Spring applications, which matters for serverless cold starts; ahead-of-time processing (Spring AOT, GraalVM native images) or compile-time wiring (Wire, Dagger) moves that work to build time. Proxies add a frame and sometimes an allocation per call. Injected singletons are shared across threads or goroutines, so a fake that is safe in a single-threaded test may race in production; real adapters need their own locking or connection pools.

Why it's built this way

  1. Business rules change at one rate, infrastructure at another; coupling them makes each change pay for both.
  2. Side effects (time, network, disk) make code slow and nondeterministic to test.
  3. So isolate side effects behind interfaces the policy owns, and pass implementations in.
  4. Interfaces add an indirection per call, which costs nanoseconds against milliseconds of I/O.
  5. Wiring large graphs by hand gets tedious, so containers emerged, and their own costs (startup, opacity) are why many teams still prefer a hand-written composition root.

Diagram

flowchart LR
  Main["main: composition root"] --> Svc["ReminderService"]
  Main --> PG["PostgresInvoiceRepo"]
  Main --> SES["SesNotifier"]
  Svc --> IRepo["InvoiceRepo interface"]
  Svc --> INot["Notifier interface"]
  PG -.->|implements| IRepo
  SES -.->|implements| INot

Worked example

The reminder job runs hourly over about 40,000 unpaid invoices. Before the refactor, its test suite had two tests, both needing Docker Postgres; the suite took 38 seconds and failed about once a week when the run crossed midnight UTC, because datetime.now() moved during the test. No test covered the "notifier failed for one customer" case, and in production a single SendGrid 500 aborted the loop, so every invoice after the failing one was skipped until the next hour.

After inversion, the team wrote 14 unit tests with a fixed clock, an in-memory repo and a notifier fake that can be told to fail for specific customers. The suite runs in 0.2 seconds. The failure test immediately exposed the abort bug: the fix was to catch the send error, leave that invoice unmarked so it is retried next hour, and continue. They also found that the "remind at most once per 24 hours" rule had an off-by-one at exactly 86,400 seconds, which was invisible with a real clock.

One integration test still runs the Postgres adapter against a real database to check the SQL. That is the right split: policy tested with fakes, adapter tested against the real thing.

Trade-offs

OptionStrengthsWeaknessesChoose when
Direct instantiation inside policySimple, easy to navigateUntestable side effects, vendor lock-in in the ruleScripts and prototypes
Constructor injection, hand-written rootExplicit, no magic, fast startupWiring boilerplate in large graphsMost services
Reflection DI containerWires large graphs, scopes, lifecyclesStartup cost, hidden wiring, runtime errorsLarge Java or .NET apps with established conventions
Compile-time DI (Wire, Dagger)Checked wiring, no runtime costCode generation stepLarge Go or Android graphs
Monkeypatching globals in testsNo production changesBrittle, leaks between tests, hides design problemsLegacy code before a refactor

Pitfalls in production

Leadership lens

Check yourself

  1. What exactly is inverted in dependency inversion?

    Show answer

    The direction of the source-code dependency. The low-level adapter imports and implements an interface owned by the high-level policy, even though at runtime the policy calls the adapter.

  2. A service takes a psycopg.Connection in its constructor. Is that dependency inversion?

    Show answer

    It is injection but not inversion: the policy still depends on the driver's API. Inversion needs an interface in the policy's own terms, such as InvoiceRepo.unpaid().

  3. Why is injecting an interface essentially free for a repository call but possibly costly for a hash function?

    Show answer

    The indirect call costs nanoseconds, which vanishes against milliseconds of database I/O but adds up when called hundreds of millions of times in a CPU-bound loop where inlining matters.

  4. Why should time be an injected dependency?

    Show answer

    Real time makes tests nondeterministic and makes boundary cases (exactly 24 hours later, midnight, DST) impossible to reproduce; an injected clock lets tests set it precisely.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →