Filter concepts by levelShowing all levels.

Python · Dependency Injection

Dependency Injection

Concepts
7

Why a class should depend on an abstraction rather than a concrete class, the two ways to hand a dependency in (constructor and function injection), a container that automates wiring several dependencies together, the FastAPI Depends() pattern, and the testing and hidden-state problems dependency injection actually solves.

This section

Injecting and testing dependencies

Depend on an abstraction, hand the dependency in rather than build it, automate the wiring at scale, and use that same seam to substitute a fake in tests.

Dependency inversion

coreintermediate

Dependency inversion means a class depends on an abstraction (like an ABC with a send method) instead of one concrete class it builds itself. That abstraction is what makes swapping the real implementation for a test double possible.

Think of it as

A concrete dependency is a class hardwired to one specific brand of plug. An abstraction is the wall socket standard — anything that fits the socket works, real appliance or test rig, without rewiring the wall. OrderService should depend on the socket shape (Mailer), never on one specific plug (SmtpMailer).

python
from abc import ABC, abstractmethod

class Mailer(ABC):
    @abstractmethod
    def send(self, to, subject): ...

class OrderService:
    def __init__(self, mailer: Mailer):   # depends on the abstraction
        self.mailer = mailer

What we're doing: Compare a class hardwired to one concrete mailer against a class that depends on a Mailer abstraction instead.

dependency_inversion.pypython
class SmtpMailer:
    def send(self, to, subject):
        return f"SMTP: sent {subject!r} to {to}"


class OrderService:
    def __init__(self):
        self.mailer = SmtpMailer()          # tightly coupled to one concrete class

    def place_order(self, email):
        return self.mailer.send(email, "Order confirmed")


from abc import ABC, abstractmethod

class Mailer(ABC):
    @abstractmethod
    def send(self, to, subject): ...

class SmtpMailer2(Mailer):
    def send(self, to, subject):
        return f"SMTP: sent {subject!r} to {to}"

class OrderService2:
    def __init__(self, mailer: Mailer):     # depends on the Mailer abstraction
        self.mailer = mailer

    def place_order(self, email):
        return self.mailer.send(email, "Order confirmed")


svc = OrderService()
print(svc.place_order("a@example.com"))

svc2 = OrderService2(SmtpMailer2())
print(svc2.place_order("a@example.com"))
7
OrderService builds SmtpMailer itself — the concrete class is baked into the constructor.
20
Mailer is an abstraction: any class with a matching send(to, subject) satisfies it.
24
OrderService2 depends on Mailer, not SmtpMailer2 — the caller decides which concrete class to pass in.
Output
SMTP: sent 'Order confirmed' to a@example.com
SMTP: sent 'Order confirmed' to a@example.com

Why this works: Both versions print the same line, which is the point — inverting the dependency changes who is allowed to choose the concrete class, not what the code does today. OrderService can only ever use SmtpMailer; OrderService2 can accept SmtpMailer2, a different mailer, or a test fake, because it only requires the Mailer shape.

Depending on a concrete class and calling it "flexible" because it has a constructor argument

Wrong

python
class OrderService:
    def __init__(self, mailer: "SmtpMailer" = None):
        self.mailer = mailer or SmtpMailer()   # still only ever an SmtpMailer

Better

python
class OrderService:
    def __init__(self, mailer: Mailer):        # any Mailer-shaped object
        self.mailer = mailer

What you see: A test double with the wrong methods still passes type-checks, because the parameter is typed as the concrete class, not the abstraction it should satisfy — the coupling is only hidden, not removed.

Why: Typing a parameter as SmtpMailer (even with a default) still names the concrete class, so every caller and every type-checker is reasoning about SmtpMailer specifically. Typing it as Mailer states the actual contract — send(to, subject) — and any class satisfying it is a legal argument, which is what makes swapping implementations safe.

Depending on a concrete class vs. an abstraction

Tightly coupled

  • +OrderService creates SmtpMailer itself
  • +Swapping mailers means editing OrderService
  • +A test cannot avoid sending real email

Depends on an abstraction

  • OrderService accepts anything shaped like Mailer
  • SmtpMailer, a queue-based mailer, or a fake all fit
  • A test passes a fake with no code changes
  • Tightly coupled
    • OrderService creates SmtpMailer itself
    • Swapping mailers means editing OrderService
    • A test cannot avoid sending real email
  • Depends on an abstraction
    • OrderService accepts anything shaped like Mailer
    • SmtpMailer, a queue-based mailer, or a fake all fit
    • A test passes a fake with no code changes

Remember: Depend on the abstraction (what a dependency must do), not the concrete class (how one particular version does it) — that is what makes swapping in a test double possible.

See also: constructor injection · testing with injected dependencies · abc module

Constructor injection

coreintermediate

Constructor injection passes a dependency into __init__ as a parameter, instead of the class creating it itself. UserService(repository) receives its Repository from the caller, so any object with the right methods can be passed in.

Think of it as

Constructor injection is handing someone their tools at the start of the job, instead of them walking to the supply closet and grabbing one specific brand themselves. UserService.__init__ receives the repository it will use for its entire lifetime — it never goes looking for one.

python
class UserService:
    def __init__(self, repository):
        self.repository = repository   # injected, not constructed here

    def get_username(self, user_id):
        return self.repository.get_user(user_id)["name"]

What we're doing: Inject a Repository through the constructor, then compare it against a version that constructs its own dependency and cannot be swapped.

constructor_injection.pypython
class Repository:
    def get_user(self, user_id):
        return {"id": user_id, "name": "Ada Lovelace"}


class UserService:
    def __init__(self, repository: Repository):
        self.repository = repository        # injected once, at construction

    def get_username(self, user_id):
        return self.repository.get_user(user_id)["name"]


service = UserService(Repository())
print(service.get_username(42))


class FakeRepository:
    def get_user(self, user_id):
        return {"id": user_id, "name": "fake-user"}


swapped = UserService(FakeRepository())      # same class, different dependency
print(swapped.get_username(42))
7
__init__ takes repository as a parameter — it does not build one itself.
8
self.repository stores whatever was passed in, for every later method call on this instance.
22
The same UserService class works unchanged with FakeRepository — nothing inside UserService needed to change.
Output
Ada Lovelace
fake-user

Why this works: UserService.get_username never mentions Repository or FakeRepository by name — it only calls .get_user(user_id) on whatever self.repository is. That is what makes passing a completely different object at construction time work without touching UserService itself.

Constructing the dependency inside __init__ instead of receiving it

Wrong

python
class UserServiceWrong:
    def __init__(self):
        self.repository = Repository()   # created here, not injected

    def get_username(self, user_id):
        return self.repository.get_user(user_id)["name"]

Better

python
class UserService:
    def __init__(self, repository):
        self.repository = repository     # caller supplies it

    def get_username(self, user_id):
        return self.repository.get_user(user_id)["name"]

What you see: There is no parameter to pass a FakeRepository through — the only way to test UserServiceWrong is against a real Repository, or by monkeypatching the class itself.

Why: Repository() inside __init__ hardcodes which class gets built, the same way a global does — the constructor decides, not the caller. Moving that one line to a parameter is the entire difference between untestable and testable code here.

The dependency is handed in, not built inside

Repository()

built by the caller

UserService(repository)

passed into __init__

self.repository

stored, used for the instance’s life

  1. Repository() — built by the caller
  2. UserService(repository) — passed into __init__
  3. self.repository — stored, used for the instance’s life

Remember: Pass a dependency into __init__ as a parameter; do not build it with ClassName() inside the constructor — that one change is what makes swapping it in tests possible.

See also: dependency inversion · function injection · testing with injected dependencies

Function injection

standardintermediate

Function injection passes a dependency straight into a function call rather than through a class. total_price(items, tax_calculator) takes the tax rule as an argument, so a different calculator can be passed without editing total_price.

Think of it as

Constructor injection fixes a dependency for an object's whole lifetime; function injection hands it over for one call only. A function that takes its dependency as a parameter is not tied to any particular implementation — only to the shape it calls.

python
def total_price(items, tax_calculator):
    subtotal = sum(items)
    return subtotal + tax_calculator(subtotal)   # dependency passed in, not hardcoded

What we're doing: Pass a callable dependency into a function two ways: an optional override parameter, and a required parameter for a pluggable calculation.

function_injection.pypython
def send_email(to, subject, sender=None):
    sender = sender or (lambda t, s: print(f"[default sender] {s} -> {t}"))
    sender(to, subject)


def fake_sender(to, subject):
    print(f"[fake sender] would send {subject!r} to {to}")


send_email("a@example.com", "Welcome")
send_email("a@example.com", "Welcome", sender=fake_sender)


def total_price(items, tax_calculator):
    subtotal = sum(items)
    return subtotal + tax_calculator(subtotal)


def flat_tax(amount):
    return round(amount * 0.08, 2)


print(total_price([10, 20, 30], flat_tax))
1
sender is a parameter, not a fixed name — send_email does not decide how mail actually gets sent.
2
A default is provided but any caller can override it with sender=... on that one call.
11
fake_sender is injected on this call only; the next call could pass a different sender again.
Output
[default sender] Welcome -> a@example.com
[fake sender] would send 'Welcome' to a@example.com
64.8

Why this works: send_email("a@example.com", "Welcome") uses the fallback lambda because no sender was passed; the second call injects fake_sender instead, for that call only. total_price works the same way with flat_tax — the tax rule is a parameter, so a different rate or formula needs no change to total_price itself.

Remember: Pass a dependency as a function parameter — often a callable itself — rather than hardcoding which implementation the function uses inside its body.

See also: constructor injection · testing with injected dependencies · default arguments

Dependency containers

coreadvanced

A dependency container is a registry that knows how to build each dependency and wires them together automatically. container.resolve("user_service") builds the Repository, then the UserService that needs it, without you writing that chain by hand.

Think of it as

Manually wiring dependencies is following a recipe yourself, step by step, every time you cook. A container is a kitchen assistant who already knows every recipe in the book — you ask for the finished dish, and it fetches whatever ingredients that dish (and its ingredients' ingredients) needs.

python
class Container:
    def __init__(self):
        self._singletons = {}
        self._factories = {
            "repository": lambda c: Repository(),
            "user_service": lambda c: UserService(c.resolve("repository")),
        }

    def resolve(self, name):
        if name not in self._singletons:
            self._singletons[name] = self._factories[name](self)
        return self._singletons[name]

What we're doing: Build a small container that wires a Repository into a UserService and a Mailer into a NotificationService, resolving each by name.

dependency_container.pypython
class Repository:
    def get_user(self, user_id):
        return {"id": user_id, "name": "Grace Hopper"}


class Mailer:
    def send(self, to, subject):
        return f"sent {subject!r} to {to}"


class UserService:
    def __init__(self, repository):
        self.repository = repository

    def get_username(self, user_id):
        return self.repository.get_user(user_id)["name"]


class NotificationService:
    def __init__(self, mailer):
        self.mailer = mailer


class Container:
    def __init__(self):
        self._singletons = {}
        self._factories = {
            "repository": lambda c: Repository(),
            "mailer": lambda c: Mailer(),
            "user_service": lambda c: UserService(c.resolve("repository")),
            "notification_service": lambda c: NotificationService(c.resolve("mailer")),
        }

    def resolve(self, name):
        if name not in self._singletons:
            self._singletons[name] = self._factories[name](self)
        return self._singletons[name]


container = Container()
user_service = container.resolve("user_service")
print(user_service.get_username(7))
print(container.resolve("user_service") is user_service)
25
_factories maps each name to a function that knows how to build it — the recipe, not the finished object.
28
user_service's factory calls c.resolve("repository") itself — resolving one name can trigger resolving another.
32
resolve() only calls the factory the first time; after that it returns the cached instance from _singletons.
39
Asking for "user_service" twice returns the exact same object — the container cached it after building it once.
Output
Grace Hopper
True

Why this works: Calling container.resolve("user_service") triggers the "user_service" factory, which itself calls c.resolve("repository") — building the Repository first, then passing it into UserService. The second resolve("user_service") call finds "user_service" already in _singletons and returns that same object, which is why the identity check prints True.

Reaching for a container before the project has more than a couple of dependencies

Wrong

python
# A whole Container class for one dependency
class Container:
    def __init__(self):
        self._factories = {"repository": lambda c: Repository()}
    def resolve(self, name):
        return self._factories[name](self)

repo = Container().resolve("repository")

Better

python
# Plain constructor injection is enough for one dependency
repo = Repository()
service = UserService(repo)

What you see: No error — just a registry, a lookup-by-string layer, and a factory dict standing between the code and a single Repository() call it could have made directly.

Why: A container earns its cost when wiring several dependencies together by hand gets repetitive or deeply nested — one or two dependencies do not have that problem yet. Introducing the indirection before then makes the code harder to trace for no benefit: a plain constructor call is already testable and explicit.

Resolving one name builds its whole dependency chain

resolve("user_service")

build repository

build UserService(repository)

cache and return

  • resolve("user_service")
    • leads to build repository
  • build repository
    • leads to build UserService(repository)
  • build UserService(repository)
    • leads to cache and return
  • cache and return

Remember: A container maps names to factories and resolves a dependency chain automatically — reach for one once wiring by hand gets repetitive, not for a single dependency.

See also: constructor injection · fastapi dependency injection · avoiding hidden global dependencies

FastAPI dependency injection

standardintermediate

FastAPI's Depends(get_db_session) marks a parameter as a dependency to resolve before the endpoint runs. FastAPI calls get_db_session for you and passes its return value in — the endpoint never calls it directly.

Think of it as

A parameter default of Depends(fn) is a request slip, not a value — it tells FastAPI "call fn and put the result here" before the endpoint function ever runs. The endpoint reads a plain function argument; FastAPI did the resolving behind the scenes.

python
def get_db_session():
    return open_connection()

def read_items(session=Depends(get_db_session)):
    return query(session)

What we're doing: Reproduce FastAPI's Depends() shape in plain Python: a marker class holding a dependency function, and a resolver that walks each parameter's default before calling the target function.

fastapi_style_di.pypython
class Depends:
    """Stand-in for fastapi.Depends — marks a parameter as a dependency."""
    def __init__(self, dependency):
        self.dependency = dependency


def get_db_session():
    return "db-session-42"


def get_current_user(session=Depends(get_db_session)):
    return {"user_id": 1, "session": session}


def resolve(fn):
    """Stand-in for FastAPI's internal resolver: for each parameter whose
    default is a Depends marker, resolve it first, then call fn."""
    import inspect
    sig = inspect.signature(fn)
    kwargs = {}
    for name, param in sig.parameters.items():
        if isinstance(param.default, Depends):
            kwargs[name] = resolve(param.default.dependency)
    return fn(**kwargs)


def read_items(user=Depends(get_current_user)):
    return f"items for user {user['user_id']} (session={user['session']})"


print(resolve(read_items))
1
Depends just wraps a function — it is a marker, not the resolved value itself.
10
get_current_user itself has a Depends(get_db_session) parameter — dependencies can depend on other dependencies.
15
resolve() inspects a function's parameters, finds any Depends markers, and calls them first — the same shape FastAPI's real resolver uses.
20
resolve(read_items) resolves get_current_user, which resolves get_db_session, before read_items itself ever runs.
Output
items for user 1 (session=db-session-42)

Why this works: resolve(read_items) sees that user defaults to Depends(get_current_user), so it calls resolve(get_current_user) first — which in turn sees session defaults to Depends(get_db_session) and resolves that too. Only once every Depends in the chain has a real value does read_items itself get called, with plain keyword arguments — exactly the shape FastAPI's real dependency resolution follows, minus request-scoped caching and async support.

Remember: Depends(fn) marks a parameter to be resolved by calling fn first — the endpoint receives a plain value, never touches Depends or fn directly, which is what makes app.dependency_overrides able to swap it for tests.

See also: function injection · dependency containers · testing with injected dependencies

Testing with injected dependencies

coreintermediate

Because OrderService receives its PaymentGateway as a parameter, a test can pass a FakePaymentGateway that records calls instead of making a real charge. No network call happens, and the test can assert exactly what was charged.

Think of it as

An injected dependency is a socket the class exposes; a test plugs a recorder into that socket instead of the real device. The class under test runs its normal logic — only what is on the other end of the socket changed.

python
class FakePaymentGateway:
    def __init__(self):
        self.charged = []

    def charge(self, amount):
        self.charged.append(amount)
        return {"status": "ok", "amount": amount}

service = OrderService(FakePaymentGateway())

What we're doing: Write a test for OrderService that injects a FakePaymentGateway instead of the real one, and asserts against what the fake recorded.

test_order_service.pypython
class PaymentGateway:
    def charge(self, amount):
        raise RuntimeError("real network call — must not run in tests")


class OrderService:
    def __init__(self, payment_gateway):
        self.payment_gateway = payment_gateway

    def checkout(self, amount):
        return self.payment_gateway.charge(amount)


class FakePaymentGateway:
    def __init__(self):
        self.charged = []

    def charge(self, amount):
        self.charged.append(amount)
        return {"status": "ok", "amount": amount}


def test_checkout_charges_full_amount():
    fake_gateway = FakePaymentGateway()
    service = OrderService(fake_gateway)

    result = service.checkout(49.99)

    assert result == {"status": "ok", "amount": 49.99}
    assert fake_gateway.charged == [49.99]
    print("test_checkout_charges_full_amount: PASS")


test_checkout_charges_full_amount()
15
FakePaymentGateway implements the same charge(amount) method PaymentGateway does — that is the whole contract OrderService needs.
17
Instead of a real charge, the fake records the amount in a list the test can inspect afterward.
24
OrderService receives fake_gateway through the constructor — no change to OrderService was needed to make this possible.
27
The assertion checks fake_gateway.charged directly — proof of exactly what OrderService asked the gateway to do.
Output
test_checkout_charges_full_amount: PASS

Why this works: OrderService.checkout only calls self.payment_gateway.charge(amount) — it has no idea whether payment_gateway is the real PaymentGateway or FakePaymentGateway. Because the real PaymentGateway.charge would raise instead of returning a value, this test could not run at all without the fake; injecting it is what makes checkout testable in isolation, without a network call.

Testing against the real dependency because the class does not accept a substitute

Wrong

python
class OrderService:
    def __init__(self):
        self.payment_gateway = PaymentGateway()   # always the real one

def test_checkout():
    service = OrderService()
    service.checkout(49.99)   # RuntimeError: real network call

Better

python
class OrderService:
    def __init__(self, payment_gateway):
        self.payment_gateway = payment_gateway

def test_checkout():
    service = OrderService(FakePaymentGateway())
    service.checkout(49.99)   # no network call — records into fake_gateway.charged

What you see: RuntimeError: real network call — must not run in tests — or, with a real gateway, a test that is slow, flaky, and actually charges money every run.

Why: OrderServiceWrong builds its own PaymentGateway inside __init__, so there is no parameter for a test to intercept — every test runs against the real gateway by construction. Accepting payment_gateway as a parameter is the only change needed to make the class testable in isolation.

Same OrderService, two different PaymentGateways

Production

  • +OrderService(PaymentGateway())
  • +charge() makes a real network call
  • +Used when the app actually runs

Test

  • OrderService(FakePaymentGateway())
  • charge() records the amount, returns a canned result
  • Used only inside the test — no network call
  • Production
    • OrderService(PaymentGateway())
    • charge() makes a real network call
    • Used when the app actually runs
  • Test
    • OrderService(FakePaymentGateway())
    • charge() records the amount, returns a canned result
    • Used only inside the test — no network call

Remember: A class that receives its dependency as a parameter can be tested with a fake that records calls instead of doing the real, slow, or dangerous thing.

See also: constructor injection · dependency inversion · avoiding hidden global dependencies

Avoiding hidden global dependencies

standardintermediate

A hidden global dependency is module-level state a function reads or changes without it appearing in the function signature. fetch_user(user_id) that secretly mutates a global connection pool gives different results for the same input, and cannot be tested in isolation.

Think of it as

A function's parameters are its declared inputs — what you can see just by reading the call. A hidden global is an undeclared input smuggled in through the back door: the function's behavior depends on it, but nothing in the signature says so.

python
# Hidden dependency — reads/mutates a module global
def fetch_user(user_id):
    _connection_pool["queries"] += 1
    return _connection_pool["queries"]

# Explicit dependency — visible in the signature
def fetch_user(pool, user_id):
    return pool.fetch_user(user_id)

What we're doing: Compare a function that silently depends on module-level global state against the same operation with the dependency passed explicitly.

hidden_globals.pypython
_connection_pool = {"active": True, "queries": 0}


def fetch_user_wrong(user_id):
    _connection_pool["queries"] += 1     # hidden dependency on global state
    return {"id": user_id, "queries_so_far": _connection_pool["queries"]}


print(fetch_user_wrong(1))
print(fetch_user_wrong(1))               # same input, different output


class ConnectionPool:
    def __init__(self):
        self.queries = 0

    def fetch_user(self, user_id):
        self.queries += 1
        return {"id": user_id, "queries_so_far": self.queries}


def fetch_user_right(pool, user_id):
    return pool.fetch_user(user_id)


pool_a = ConnectionPool()
pool_b = ConnectionPool()
print(fetch_user_right(pool_a, 1))
print(fetch_user_right(pool_b, 1))       # independent state — no hidden coupling
1
_connection_pool is module-level state, invisible in fetch_user_wrong's signature.
4
fetch_user_wrong(user_id) looks like it depends only on user_id — but it also silently reads and mutates _connection_pool.
20
fetch_user_right takes pool as a parameter — the dependency is now visible in the signature.
24
pool_a and pool_b are independent ConnectionPool instances — passing a different one changes the result with no hidden coupling between calls.
Output
{'id': 1, 'queries_so_far': 1}
{'id': 1, 'queries_so_far': 2}
{'id': 1, 'queries_so_far': 1}
{'id': 1, 'queries_so_far': 1}

Why this works: fetch_user_wrong(1) called twice returns two different results for the identical argument, because it is secretly reading and mutating _connection_pool between calls — a caller has no way to see that from the signature. fetch_user_right(pool_a, 1) and fetch_user_right(pool_b, 1) both return queries_so_far: 1, because each pool object is independent and the dependency is passed explicitly rather than shared through a global.

Remember: If a function's result depends on something not in its parameter list, that dependency is hidden — make it a parameter so it is visible, swappable, and testable.

See also: function injection · testing with injected dependencies · dependency containers

Advertisement