SOLIDIntermediate 14 min Lesson 5 of 5

Dependency Inversion — Depend on Contracts

High-level logic should not depend on low-level details. Learn how injecting dependencies makes code testable, swappable and honest.

SOLID · Lesson 5 of 5
0/5 done(0%)

What is it? #

Dependency Inversion says high-level policy should not depend on low-level details. Both should depend on an abstraction.

Concretely: your order processing logic should not import a specific payment SDK, a specific email library and a specific database driver. It should depend on small interfaces, and something else should decide which implementations get used.

The "inversion" is in who owns the contract. Normally the low-level module defines what it offers; here the high-level module defines what it needs, and implementations conform to that.

The practical technique is dependency injection: pass collaborators in rather than creating them inside.

Think of it like this #

A kitchen designed around one specific oven model. Replacing the oven means rebuilding the kitchen.

A kitchen designed around a standard appliance slot fits any oven that matches the slot. The kitchen defines the requirement; the manufacturer conforms to it.

Simple example #

An order checkout flow that directly creates a Stripe client, an SMTP connection and a database cursor. Nothing about it can be tested without network access, and changing any provider means editing the business logic.

Code #

PYTHON
# ---------- BAD: policy welded to specific tools ----------
import smtplib
import sqlite3


class Checkout:
    def process(self, order):
        # creates its own dependencies — no way to substitute them
        connection = sqlite3.connect("shop.db")
        connection.execute("INSERT INTO orders VALUES (?, ?)", (order.id, order.total))
        connection.commit()

        smtp = smtplib.SMTP("smtp.example.com", 587)
        smtp.sendmail("[email protected]", order.email, f"Order {order.id} confirmed")
        smtp.quit()
        return True
TEXT
What is wrong

- Testing requires a real database file and a real SMTP server.
- Switching email providers means editing checkout logic.
- The class decides its own dependencies, so callers cannot configure it.
- A failure in email sending is tangled with order persistence.
- The business rule (what checkout means) is buried in plumbing.
PYTHON
# ---------- BETTER: depend on what you need, receive it from outside ----------
from abc import ABC, abstractmethod


class OrderRepository(ABC):
    @abstractmethod
    def save(self, order) -> None: ...


class Notifier(ABC):
    @abstractmethod
    def notify(self, to: str, message: str) -> None: ...


class Checkout:
    """High-level policy. Knows the steps, not the tools."""

    def __init__(self, repository: OrderRepository, notifier: Notifier):
        self.repository = repository
        self.notifier = notifier

    def process(self, order) -> bool:
        if order.total <= 0:
            raise ValueError("order total must be positive")
        self.repository.save(order)
        self.notifier.notify(order.email, f"Order {order.id} confirmed")
        return True


# --- Low-level details, written to fit the contract ---
class SqliteOrderRepository(OrderRepository):
    def __init__(self, connection):
        self.connection = connection

    def save(self, order):
        with self.connection:
            self.connection.execute(
                "INSERT INTO orders (id, total) VALUES (?, ?)", (order.id, order.total)
            )


class SmtpNotifier(Notifier):
    def __init__(self, client):
        self.client = client

    def notify(self, to, message):
        self.client.sendmail("[email protected]", to, message)


# --- Test doubles, trivially written ---
class InMemoryOrderRepository(OrderRepository):
    def __init__(self): self.saved = []
    def save(self, order): self.saved.append(order)


class RecordingNotifier(Notifier):
    def __init__(self): self.sent = []
    def notify(self, to, message): self.sent.append((to, message))


# --- Wiring happens once, at the edge of the application ---
def test_checkout_saves_and_notifies():
    repo, notifier = InMemoryOrderRepository(), RecordingNotifier()
    checkout = Checkout(repo, notifier)

    class FakeOrder: id, total, email = "A-1", 999.0, "[email protected]"

    assert checkout.process(FakeOrder()) is True
    assert len(repo.saved) == 1
    assert notifier.sent[0][0] == "[email protected]"

How it works #

Checkout now declares what it needs: something that can save an order, and something that can notify a customer. Those two tiny interfaces are owned by the high-level code, which is the inversion.

The concrete classes — SQLite, SMTP — implement those interfaces. They are details, and details are replaceable.

Dependencies arrive through the constructor. Checkout never calls sqlite3.connect or constructs an SMTP client, so it has no opinion about which ones are used.

The test at the bottom is the clearest evidence of the benefit. It runs instantly, needs no network or filesystem, and asserts on real behaviour rather than on mocked internals. Writing that test against the original version would require a database file and a mail server.

Wiring moves to the edge of the application — a startup module, a factory, or a framework's dependency container. That is the one place where concrete choices are made, and it is easy to find.

There is a cost: more indirection, and the wiring has to be maintained. The principle is worth applying at boundaries that cross into other systems. Inside a module, calling a helper function directly is perfectly fine.

Real-world use #

This is the backbone of testable application design. Frameworks such as Spring, NestJS and FastAPI provide dependency injection precisely because wiring by hand at scale becomes tedious.

The layered structure most teams settle on follows from it: domain logic at the centre depending on nothing, infrastructure at the edges implementing interfaces the domain defines. That is the core idea behind hexagonal and clean architectures.

Concrete payoffs show up regularly. Swapping an email provider becomes one new class. Running tests without external services becomes normal. Adding a caching layer means wrapping an interface rather than editing business logic.

The overuse warning matters too. Injecting everything, including utility functions and standard library calls, produces constructors with twelve parameters and no clarity. Inject the things that cross a boundary or need substituting in tests.

Common mistakes #

  • Creating dependencies inside a class, which makes substitution impossible.
  • Defining the interface to mirror one vendor’s SDK, so nothing else can implement it.
  • Injecting everything, including trivial helpers, until constructors are unreadable.
  • Scattering wiring across the codebase instead of keeping it at the edge.
  • Mocking your own interfaces so heavily that tests assert on calls rather than behaviour.

Practice #

Refactor a ReportJob that currently reads from a database, formats a CSV and uploads to S3. Define the interfaces it needs, inject them, and write one test using in-memory implementations that asserts the report contents without touching any external system.

Quick quiz

  1. 1. What does Dependency Inversion invert?

  2. 2. What is dependency injection?

  3. 3. Why does this make testing easier?

  4. 4. Where should the concrete wiring live?

  5. 5. What is a sign of over-applying this principle?

Summary

  • High-level logic should depend on small interfaces it defines, not on concrete tools.
  • Pass dependencies in through the constructor rather than creating them inside.
  • Tests become fast and self-contained with in-memory implementations.
  • Keep all concrete wiring at the edge of the application.
  • Apply it at boundaries, not to every internal helper.