What is Dependency Injection in Programming?
Personal Knowledge Sharing: How understanding dependency injection changed the way I structure Python applications
Introduction
I remember the first time I looked at a service class I'd written for my multi-tenant POS backend and thought: "why is this so hard to test?" Every time I tried to write a unit test, the service was already wired up to a live PostgreSQL connection, a real Redis client, and a third-party billing API call inside the constructor. To test a single method I had to either mock the entire world or spin up real infrastructure.
That pain pointed me to one root cause — tight coupling. My classes were building their own dependencies internally. Once I understood Dependency Injection (DI), I stopped constructing dependencies inside classes and started receiving them from outside. It sounds like a small shift, but it changes how you design, test, and maintain every layer of a system.
This article covers what DI is, why it matters, how it works in Python without any framework, and how the pattern shows up naturally in FastAPI through its Depends() system.
Table of Contents
What is a Dependency?
A dependency is any external object, service, or resource that a class or function needs to do its job.
Common examples in backend Python work:
A database session or repository
An HTTP client calling an external API
A cache client (Redis)
A logger
A configuration object
A message queue publisher
When a class creates one of these itself, it owns the dependency. When it receives one from outside, it uses the dependency. Dependency Injection is the practice of providing dependencies from the outside rather than creating them internally.
The Problem: Tight Coupling
Here is a pattern that was common in my early codebase. A ReportService that needs a database connection:
This looks harmless, but hits three problems immediately:
Untestable in isolation — instantiating
ReportServicealways opens a real database connection. There is no way to supply a fake database in a unit test.Hard to swap implementations — if the team moves from
psycopg2toasyncpgor SQLAlchemy, the change is buried inside the service class.Hidden configuration — connection credentials are hardcoded into the class constructor, which leaks environment concerns into business logic.
The red nodes represent tightly coupled code. You cannot take ReportService out of this wiring and use it elsewhere.
What is Dependency Injection?
Dependency Injection means that the dependencies a class needs are provided to it from outside — typically through the constructor, a setter method, or a function parameter — rather than created inside.
The class declares what it needs without caring how it was built.
The creation of DBConnection now belongs to a composition root — a single place responsible for wiring the application together. ReportService is kept clean of that concern.
Types of Dependency Injection
1. Constructor Injection
The most common form. Dependencies are declared as parameters in __init__. The caller is forced to supply them — there is no ambiguity about what the class needs.
The SalesRepository parameter is a Protocol (covered in the next section). ReportService does not know or care whether it receives a real PostgreSQL repository or a test double.
2. Setter Injection
Dependencies are supplied through a dedicated method after the object is constructed. I use this pattern sparingly — only when an optional collaborator can be swapped at runtime, such as plugging in a different logging backend.
The risk with setter injection is that the object can be in an incomplete state between construction and calling the setter. Constructor injection avoids that entirely, which is why I default to it.
3. Interface Injection
The class advertises a setter contract through an interface (Protocol). Any dependency that wants to inject itself must implement the protocol. This is less common in Python because Protocol and duck typing usually handle the contract more cleanly through constructor injection.
I have not found a strong reason to use this form in Python work; constructor injection with Protocol covers the same intent.
Python Protocols as Interfaces
Python's typing.Protocol is the right tool for defining the shape a dependency must have without requiring inheritance. This keeps classes decoupled at the type level while still giving mypy enough information to catch mismatches.
Here is the full pattern I use across service layers:
ReportService now depends on two abstractions — SalesRepository and CacheClient. The concrete implementations (PostgresSalesRepository, RedisClient) are assembled elsewhere and injected in.
DI in a FastAPI Service Layer
FastAPI's Depends() function is a first-class DI system. When I moved the POS reporting endpoints into a FastAPI application, the framework took over the role of the composition root — it builds dependencies and injects them into route handlers automatically.
FastAPI resolves the entire dependency graph in get_report_service before the route handler runs. The route handler receives a fully assembled ReportService and never sees the database connection or cache client.
How the Flow Looks at Runtime
The route handler never calls psycopg2.connect or redis.Redis(...) directly. Those decisions live in dependencies.py, which is the composition root for this application.
Testing Becomes Straightforward
The clearest payoff from DI is in tests. Because ReportService accepts SalesRepository and CacheClient as constructor parameters, I can pass in test doubles — no mocking frameworks required for the core logic.
No patching, no monkeypatching the module, no unittest.mock.patch gymnastics. The test constructs real Python objects, and they work because the interface contract is honoured.
For FastAPI routes, the framework provides app.dependency_overrides to swap dependencies during integration tests:
The application never touches a real database or Redis instance during this test run.
Architectural Overview
The business logic in ReportService never points at a concrete implementation. It points at protocols. Both production and test layers satisfy those protocols independently.
What I Learned
After applying DI consistently across my Python projects — from ansible-inspec's FastAPI service layer to the multi-tenant POS reporting backend — a few things became clear:
Start with constructor injection. It is explicit and honest. The constructor signature is the public contract — it tells every caller what the class needs to function. I avoided setter injection unless there was a genuine runtime-swapping requirement.
typing.Protocol is the right tool in Python. I do not use abstract base classes (abc.ABC) for this anymore. Protocol enables structural subtyping — a class satisfies the protocol if it has the right methods, regardless of inheritance. This keeps the concrete implementations free of framework coupling.
FastAPI's Depends() is DI done right at the framework level. I did not need to reach for dependency-injector or any third-party container. FastAPI's dependency graph handles lifetime management (request-scoped vs. application-scoped), cleanup via yield, and test overrides through dependency_overrides. That covers the vast majority of what a DI container does, with no additional setup.
The real win is at test time. Saying "DI makes code testable" is a cliché — but it is genuinely true. When I stopped constructing dependencies inside classes, test setup dropped from twenty lines of mocking scaffolding to three lines of plain Python object construction.
DI is not magic wiring. A DI container is optional. Python's standard library and FastAPI's built-in system are more than enough for a service-oriented backend. The valuable idea is the pattern itself: declare what you need, don't build what you need.
These patterns come from hands-on experience building FastAPI-based services and the ansible-inspec project. The code examples reflect structures I have actually used rather than simplified toy examples.
Last updated