Backend / Theory foundations / 08_dependency_inversion_vs_injection.md

Dependency inversion vs dependency injection

Updated 5 interview angles 7 min read source
On this page9
  1. The direction of a dependency
  2. Dependency inversion in Python: Protocols
  3. Dependency injection in Python: plain arguments first
  4. DI without DIP
  5. DIP without DI
  6. Where it breaks in production
  7. The trade not taken
  8. Related
  9. Interview angle

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

text
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

python
# 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:

text
$ 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) -> None

What runtime_checkable checks

At run time it does not:

text
isinstance(BrokenMailer(), Mailer)    # @runtime_checkable Protocol
True

runtime_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

python
# 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

python
# 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 = mailer

The 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:

ini
[importlinter:contract:layers]
name = domain must not depend on infrastructure
type = layers
layers =
    app.main
    app.infra
    app.domain
text
inverted 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).

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 SesMailer into the domain is DI without DIP: the import check broke on app.domain.signup -> app.infra.ses. A port resolved through a global registry is DIP without DI.
  • “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_checkable only checks method names: it returned True for the broken adapter.
  • “Do you need a DI container in Python?” - usually not: constructor injection plus one composition root, Depends in 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.