Dependency inversion vs dependency injection
Article.
Two ideas with similar names, constantly confused in interviews and in code.
- Dependency inversion (DIP) is a design principle, the D in SOLID: high-level policy must not depend on low-level detail; both depend on an abstraction, and the abstraction belongs to the policy.
- Dependency injection (DI) is a technique: an object receives its collaborators from outside (constructor, parameter, framework) instead of creating or importing them itself.
DIP is about which way the source-code dependency points. DI is about who wires objects together at run time. You can have either without the other, and the interesting failures are in the gap.
“We use dependency injection everywhere, so our domain is decoupled from the database.” Is it? Not necessarily, as an import check shows below. The examples were run with Python 3.14, mypy 2.4 and import-linter 2.15.
The direction of a dependency
Without inversion
SignupService ──imports──> SesMailer
(domain) (infra)
With inversion
SignupService ──> Mailer (Protocol, owned by domain)
(domain) ▲
│ implements
SesMailer (infra)Run-time calls go the same way in both pictures: the service calls the mailer. What inverts is the source-code dependency: in the second picture the infrastructure module imports the domain’s abstraction, and the domain imports nothing from infrastructure. The domain can then be tested, reused and reasoned about without SES, and changing the mail provider touches only the outer layer.
Who owns the abstraction
The part people skip: the abstraction is owned by the high-level module
and shaped by what it needs (send(to, subject, body)), not by what the SES
SDK happens to offer. An interface that mirrors the vendor’s API, kept in the
infrastructure package, is a wrapper, not an inversion.
Dependency inversion in Python: Protocols
# app/domain/ports.py: the port, owned by the domain
class Mailer(Protocol):
def send(self, to: str, subject: str, body: str) -> None: ...
# app/domain/signup.py
class SignupService:
def __init__(self, mailer: Mailer) -> None:
self.mailer = mailer
# app/infra/ses.py: no import of Mailer needed
class SesMailer:
def send(self, to: str, subject: str, body: str) -> None: ...typing.Protocol is structural: SesMailer satisfies Mailer by having the
right method, without inheriting from it or importing it. That is duck typing
made checkable, and it lets the infrastructure side stay free of domain
imports too. A type checker enforces the contract:
$ mypy app
app/main.py:5: error: Argument 1 to "SignupService" has incompatible
type "BrokenMailer"; expected "Mailer" [arg-type]
Expected: def send(self, to: str, subject: str, body: str) -> None
Got: def send(self, to: str) -> NoneWhat runtime_checkable checks
At run time it does not:
isinstance(BrokenMailer(), Mailer) # @runtime_checkable Protocol
Trueruntime_checkable checks only that the method names exist, not their
signatures, so an isinstance guard passes a broken adapter. Rely on the type
checker in CI; use an abc.ABC instead when you need inheritance-based
checks or shared default behaviour.
Dependency injection in Python: plain arguments first
# the composition root: the only module that names concrete classes
def build_signup(settings: Settings) -> SignupService:
return SignupService(mailer=SesMailer(region=settings.aws_region))
# a test injects a fake through the same constructor
def test_register_sends_welcome():
sent = []
class FakeMailer:
def send(self, to, subject, body): sent.append(to)
SignupService(FakeMailer()).register("a@b.c")
assert sent == ["a@b.c"]Constructor injection plus one composition root covers most applications:
no framework, explicit wiring, and fakes without monkeypatch. The forms
Python adds:
| Form | Where it fits |
|---|---|
| constructor arguments | services and their collaborators |
| function parameters | handlers, pure functions |
FastAPI Depends |
per-request objects in a web app |
| pytest fixtures | test wiring |
dependency-injector |
large graphs reused outside HTTP |
FastAPI’s Depends is DI scoped to a request, with caching and teardown
(Dependencies and injection);
pytest fixtures are DI for tests. A container library earns its place when
the same object graph must be built for a web app, Celery workers and CLI
commands, and the hand-written composition root has become long
(DI containers and the dependency-injector library).
DI without DIP
# app/domain/signup.py
from app.infra.ses import SesMailer # the domain imports infrastructure
class SignupService:
def __init__(self, mailer: SesMailer) -> None: # injected, but not inverted
self.mailer = mailerThe mailer is injected, so tests can pass a fake, but the domain module still imports the SES module and its SDK, and its type is the concrete class. An import-layering contract shows the difference:
[importlinter:contract:layers]
name = domain must not depend on infrastructure
type = layers
layers =
app.main
app.infra
app.domaininverted design (Protocol in the domain):
domain must not depend on infrastructure KEPT
injected, not inverted (SesMailer type):
domain must not depend on infrastructure BROKEN
- app.domain.signup -> app.infra.ses (l.1)Same constructor, same tests passing; one design has the dependency pointing inward and one does not. That is the senior point: DI makes dependencies replaceable at run time; DIP makes them point the right way in the source. Injection without inversion buys testability and indirection but not decoupling.
DIP without DI
The reverse also exists. A domain module that defines a port and receives an
implementation from a registry or a service locator (get_mailer() looking
up a global) is inverted, with no injection: the dependency points the right
way, but every caller hides what it needs behind a global lookup, and tests
must mutate that global. It works, and it is why injection is the usual
companion: it makes the dependency visible in the signature.
Where it breaks in production
Hidden dependencies through imports
A service imports requests, boto3 or the ORM at module level and calls
them directly. Nothing is injected, tests resort to monkeypatch on import
paths that break when someone moves a module, and the domain cannot run
without network credentials. The test that needs monkeypatch is the signal.
Interfaces for everything
Every class gets an IFoo Protocol with exactly one implementation and no
second in sight. Indirection without a payoff makes code harder to navigate.
Introduce a port where there is a boundary worth protecting: external
services, storage, clocks and randomness, anything that is slow or
nondeterministic in tests.
The abstraction shaped like the vendor
A PaymentGateway port whose methods mirror Stripe’s API one to one. When
the second provider arrives, the “abstraction” cannot represent it, and the
domain changes anyway. Shape ports by the use case (charge(order)), not by
the first vendor.
Containers as service locators
A DI container queried from inside business code
(container.mailer() in a function body) is a service locator again: the
dependency is hidden, and the code depends on the container. Resolve from the
container only at the composition root.
The trade not taken
Full inversion everywhere, hexagonal architecture with ports for every outward call, gives the cleanest dependency graph and the most indirection. In Python, duck typing already allows substitution without any interface, so many teams take a lighter path.
Constructor injection everywhere, Protocols only at real boundaries, and an import-linter contract that keeps the domain free of infrastructure imports. That captures most of DIP’s benefit, the direction of dependencies, enforced in CI, without an interface per class (Clean Architecture).
Related
Interview angle 5
- “Dependency inversion vs dependency injection?” - DIP is a principle:
high-level policy and low-level detail both depend on an abstraction that
the policy owns, so source dependencies point inward. DI is a technique:
objects receive collaborators from outside. DIP decides the direction; DI
does the wiring.
- Follow-up: “Can you have one without the other?” - yes. Injecting a
concrete
SesMailerinto the domain is DI without DIP: the import check broke onapp.domain.signup -> app.infra.ses. A port resolved through a global registry is DIP without DI.
- Follow-up: “Can you have one without the other?” - yes. Injecting a
concrete
- “How do you do DIP in Python without interfaces?” -
typing.Protocol: structural, so adapters need not import or inherit it. mypy rejected an adapter with the wrong signature.- Follow-up: “Why not check with
isinstance?” -runtime_checkableonly checks method names: it returnedTruefor the broken adapter.
- Follow-up: “Why not check with
- “Do you need a DI container in Python?” - usually not: constructor
injection plus one composition root,
Dependsin FastAPI, fixtures in tests. A container pays off when one graph is built for several entry points. - “Who owns the interface?” - the module that uses it. A port defined in the infrastructure package, shaped like the vendor’s API, inverts nothing.
- “How do you know the domain is decoupled?” - enforce it: an import-linter layers contract in CI fails the build when the domain imports infrastructure.