Python is a dynamically typed language, which means type checking happens at runtime—unlike statically typed languages like C# or Java. This flexibility enables powerful features such as monkey patching, hot swapping, and dynamic class creation. Because of this, dependency injection (DI) is not strictly required in Python applications.
However, DI brings a formal structure to your codebase, especially when using architectural patterns like Domain-Driven Design (DDD) or Hexagonal Architecture. It promotes flexibility by allowing large parts of the code to be replaced or modified without impacting the rest of the system. It also reduces coupling, making the system less dependent on specific implementations. This, in turn, simplifies testing, as modules can be easily mocked.
For all these reasons, many developers—including myself—still choose to use DI in Python projects, particularly in web APIs.
In this article, I will share the method I use to implement dependency injection in a typical FastAPI web API. While FastAPI provides a built-in DI system (which you can read about here), I find its syntax somewhat cumbersome. Moreover, injecting dependencies into inner layers of a DDD architecture (like the domain layer) is not as straightforward as it is in the API layer. Defining singleton objects can also be tricky.
That is why I have developed a technique that I find easier to follow, implement, and maintain—and I’m excited to share it with you.
What is dependency injection?
Dependency Injection (DI) is a design pattern used to implement the principle of Inversion of Control (IoC). This principle is the last of the SOLID principles defined by Robert C. Martin, which serve as guidelines for writing scalable, maintainable, and loosely coupled code.
DI is based on two key rules:
- “High-level modules should not depend on low-level modules. Both should depend on abstractions.”
- “Abstractions should not depend on details. Details should depend on abstractions.”
Let’s break these down:
Rule 1: High-level modules should not depend on low-level modules
A low-level module is one that performs basic operations and has few or no dependencies. These modules are typically reusable and self-contained. In contrast, high-level modules encapsulate business logic and often rely on other components (like repositories, services, or external APIs) to function.
Here’s an example of code that violates this rule:
class MyDatabase:
...
def connect(self):
print("Connecting to database")
...
class UserService:
def __init__(self):
self.db = MyDatabase() # <-- dependency
def get_user(self):
self.db.connect()
print("Fetching user data")
...
def main():
service = UserService() # <-- dependency
service.get_user()
...
if __name__ == "__main__":
main()
In this example, MyDatabase is a low-level module, and UserService is a high-level module. The dependency is created directly inside UserService, which means that if we ever need to change the implementation of MyDatabase, we would likely have to update every class that uses it—starting with UserService. This tight coupling makes the system harder to maintain and evolve.
Moreover, it becomes difficult to create or mock instances of UserService without ensuring that MyDatabase is functioning correctly. This dependency chain leads to tightly coupled classes, which complicates testing and reduces flexibility.
A better approach would be to depend on an abstraction, like an interface or base class:
from abc import ABC, abstractmethod
# High-level abstraction
class IDatabase(ABC):
@abstractmethod
def connect(self):
raise NotImplementedError
# Low-level implementation
class MyDatabase(IDatabase):
...
def connect(self):
print("Connecting to MySQL database")
...
# High-level module depends on abstraction
class UserService:
def __init__(self, db: IDatabase): # <-- abstraction dependency
self.db = db
def get_user(self):
self.db.connect()
print("Fetching user data")
...
def main():
db = MySQLDatabase() # <-- dependency
service = UserService(db) # <-- dependency
service.get_user()
...
if __name__ == "__main__":
main()
In the second example, this tight coupling is resolved. An interface IDatabase is introduced, allowing UserService to be completely detached from the concrete implementation of MyDatabase. Now, all dependencies are injected at the highest level—typically in the main function or application setup—while the modules themselves depend only on abstractions. This structure ensures that the modules are loosely coupled, satisfying the first rule of dependency injection.
Rule 2: Abstractions should not depend on details
This rule emphasizes that an abstraction should define what a component does, not how it does it. The implementation details should be encapsulated in concrete classes, not exposed or handled by the abstraction itself.
If an interface or abstract class starts to include logic or assumptions about specific implementations, it becomes tightly coupled to those details. This violates the principle and risks cascading changes across the system whenever a single implementation changes.
Let’s look at an example to illustrate this:
from abc import ABC, abstractmethod
class ITransportationMode(ABC):
@abstractmethod
def get_max_speed(self):
raise NotImplementedError
@abstractmethod
def get_wheels_quantity(self):
raise NotImplementedError
class Car(ITransportationMode):
def get_max_speed(self):
return 200
def get_wheels_quantity(self): # <-- It works here
return 4
class Horse(ITransportationMode):
def get_max_speed(self):
return 70
def get_wheels_quantity(self): # <-- It does not work here anymore
pass
This example highlights a common mistake: the method get_wheels_quantity is defined in the interface ITransportationMode, but it only makes sense for certain implementations like Car. For others, like Horse, it’s irrelevant or undefined, leading to awkward or meaningless implementations.
This violates the second rule of dependency injection: abstractions should not depend on details. The interface should only define behavior that is common and meaningful to all its implementations. Including methods that only apply to some subclasses forces unnecessary coupling and breaks the abstraction.
To fix this, we can split the abstraction into more specific interfaces:
from abc import ABC, abstractmethod
class ITransportationMode(ABC):
@abstractmethod
def get_max_speed(self):
raise NotImplementedError
class IVehicleWithWheels(ITransportationMode):
@abstractmethod
def get_wheels_quantity(self):
raise NotImplementedError
class Car(IVehicleWithWheels):
def get_max_speed(self):
return 200
def get_wheels_quantity(self):
return 4
class Horse(ITransportationMode):
def get_max_speed(self):
return 70
The new code provides much more flexibility because when a modification occurs, it only affects the relevant classes. This improves modularization and reduces coupling. Additionally, the responsibilities of each module are clearly defined.
Dependency injection in Python
Now, I will walk you through a simple web API to demonstrate my methodology for implementing Inversion of Control (IoC) in Python. You can check out the full project on GitHub here.
To achieve this, I use two main libraries for dependency injection, one for managing application settings, and of course, FastAPI itself:
| Type | Library | Version used |
|---|---|---|
| DI | FastAPI Injector | 0.8.0 |
| DI | Injector | 0.22.0 |
| Settings | Pydantic Settings | 2.10.1 |
| API | FastAPI | 0.116.1 |
The application is a simple API that returns properties of various polygons. It is structured following the principles of Domain-Driven Design (DDD):
fastapi-dependency-injection
│
├── polygons/
│ │
│ ├── api/
│ │ ├── controllers/
| │ │ └── polygons.py
| │ ├── app.py
| │ └── dependency_injection.py
| │
| ├── domain/
| │ ├── interfaces/
| │ │ ├── polygon_interface.py
| │ │ └── settings_interface.py
| │ ├── models/
| │ │ ├── square.py
| │ │ └── triangle.py
| │ └── settings/
| |
| ├── __init__.py
| └── __main__.py
|
├── .env
└── requirements.txt
Settings injection
In my experience, one of the key benefits of using dependency injection is the ability to inject application settings. Typically, a settings class is used across multiple modules. Without DI, you would need to instantiate it every time it is required, which leads to repetitive and less maintainable code.
Moreover, settings values—such as configuration parameters—should remain constant throughout the application’s lifetime. This makes them ideal candidates for a singleton pattern.
Fortunately, this is easy to implement using pydantic and injector. Let’s start by creating a settings.py file that reads the polygon’s side length from a .env file.
.env:
SIDE_LENGTH=5
settings.py:
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
side_length: float
Now comes the interesting part. To apply inversion of control, we need an interface that acts as a contract between the modules that require settings and the settings implementation itself. This interface ensures that any class inheriting from it must implement the get_side_length method. That way, any object using ISettings can rely on the presence of this method.
settings_interface.py:
from abc import ABC, abstractmethod
class ISettings(ABC):
@abstractmethod
def get_side_length(self) -> float:
raise NotImplementedError
This is the final declaration of the Settings class. It inherits from ISettings and implements the get_side_length method. Notice the use of the @singleton decorator from the injector library—this ensures that only one instance of the settings class is created and shared throughout the application, following the singleton pattern.
settings.py:
from injector import singleton
from pydantic_settings import BaseSettings, SettingsConfigDict
from polygons.domain.interfaces.settings_interface import ISettings
@singleton
class Settings(BaseSettings, ISettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
side_length: float
def get_side_length(self) -> float:
return self.side_length
Polymorphism
Next, let’s define the interface for polygons. This interface includes three methods that describe the behavior expected from any polygon implementation.
polygons_interface.py:
from abc import ABC, abstractmethod
class IPolygon(ABC):
@abstractmethod
def get_area(self, side_length: float) -> float:
raise NotImplementedError
@abstractmethod
def get_perimeter(self, side_length: float) -> float:
raise NotImplementedError
@abstractmethod
def get_number_of_sides(self) -> int:
raise NotImplementedError
Now, let’s implement a class that inherits from IPolygon. The Square class provides concrete implementations for all three methods. It requires access to the side_length setting, which is injected via the constructor using the ISettings interface. Without dependency injection, we would need to manually instantiate the settings class each time it is needed, leading to repetitive code and making configuration changes harder to manage across the application. For example, just imagine that we decide to change the side_length from 5 to 10; we would have to update every place where Settings is instantiated manually. By injecting ISettings, we delegate the responsibility of providing the dependency to the injector.
It is necessary to use the
@injectdecorator to indicate the libraryinjectorthat the class must be injected.
square.py:
from injector import inject
from polygons.domain.interfaces.polygon_interface import IPolygon
from polygons.domain.interfaces.settings_interface import ISettings
class Square(IPolygon):
@inject
def __init__(self, settings: ISettings):
# If settings were not injected, I would have to create a new instance
# self.settings = Settings(side_length=5)
self.settings = settings
def get_area(self) -> float:
return self.settings.get_side_length() ** 2
def get_perimeter(self) -> float:
return 4 * self.settings.get_side_length()
def get_number_of_sides(self) -> int:
return 4
A similar implementation is provided for the Triangle class:
triangle.py:
from injector import inject
from polygons.domain.interfaces.polygon_interface import IPolygon
from polygons.domain.interfaces.settings_interface import ISettings
class Triangle(IPolygon):
@inject
def __init__(self, settings: ISettings):
self.settings = settings
def get_area(self) -> float:
return 0.5 * self.settings.get_side_length() ** 2
def get_perimeter(self) -> float:
return 3 * self.settings.get_side_length()
def get_number_of_sides(self) -> int:
return 3
Class binding
To complete the setup, we need to define how interfaces are bound to their implementations. This is done in the dependency_injection.py file, located at the top level of the application. Using the injector library, the binding process is straightforward:
- Create the
injector = Injector()instance. - Bind interfaces to their concrete classes.
- Optionally define a scope, such as singleton, either via decorators or directly in the binder.
dependency_injection.py:
import random
from injector import Injector
from polygons.domain.interfaces.polygon_interface import IPolygon
from polygons.domain.interfaces.settings_interface import ISettings
from polygons.domain.models.square import Square
from polygons.domain.models.triangle import Triangle
from polygons.domain.settings.settings import Settings
injector = Injector()
injector.binder.bind(IPolygon, random.choice([Square, Triangle]))
injector.binder.bind(ISettings, Settings)
This configuration binds ISettings to the Settings class, ensuring that any class requiring ISettings receives the singleton instance. For demonstration purposes, IPolygon is randomly bound to either Square or Triangle, showcasing how flexible and dynamic DI can be—even though this random binding is not typical in production environments.
FastAPI injection
I created a simple FastAPI application with three endpoints: /random-polygon, /square and /triangle.
__main__.py:
import uvicorn
def main():
uvicorn.run("polygons.api.app:app")
if __name__ == "__main__":
main()
One of the key steps is integrating the injector with the FastAPI app object. At first glance, app.py looks like a typical FastAPI setup. However, the last line is crucial: the attach_injector function from the fastapi_injector library links the injector to the FastAPI app.
app.py:
from fastapi import FastAPI
from fastapi_injector import attach_injector
from polygons.api.controllers import polygons
from polygons.api.dependency_injection import injector
app = FastAPI(
title="Polygons DI example",
version="EXAMPLE",
description="API conceived to show how to implement dependency injection (DI) in Python using FastAPI.",
swagger_ui_parameters={"defaultModelsExpandDepth": -1},
)
app.include_router(polygons.router)
attach_injector(app, injector)
For the controller file, I now only focus in the injection of the required services in the endpoints. We do not pay attention anymore to the implementation of the other dependencies because they are all handled by the injector (this is the beauty of the dependency inversion principle).
With fastapi_injector, the syntax for injecting dependencies changes slightly compared to the traditional FastAPI approach.
Traditional FastAPI injection:
...
from typing import Annotated
from fastapi import Depends
@router.get("/example")
def my_endpoint(
polygon_service: Annotated[IPolygon, Depends(Square)]
):
...
In this approach, you must explicitly define the binding for each interface at every endpoint.
With fastapi_injector:
...
from fastapi_injector import Injected
@router.get("/example")
def my_endpoint(
polygon_service: IPolygon = Injected(IPolygon)
):
Here, the binding is automatically resolved based on the configuration defined in dependency_injection.py. However, you can still override the default binding by specifying a concrete class, as shown in the controller implementation.
Controller implementation
controllers/polygons.py:
from fastapi import APIRouter
from fastapi_injector import Injected
from polygons.domain.interfaces.polygon_interface import IPolygon
from polygons.domain.models.square import Square
from polygons.domain.models.triangle import Triangle
router = APIRouter(tags=["Polygons"])
@router.get("/random-polygon")
def get_random_polygon_information(polygon_service: IPolygon = Injected(IPolygon)):
return {
"sides": polygon_service.get_number_of_sides(),
"area": polygon_service.get_area(),
"perimeter": polygon_service.get_perimeter(),
}
@router.get("/square")
def get_square_information(polygon_service: IPolygon = Injected(Square)):
return {
"sides": polygon_service.get_number_of_sides(),
"area": polygon_service.get_area(),
"perimeter": polygon_service.get_perimeter(),
}
@router.get("/triangle")
def get_triangle_information(polygon_service: IPolygon = Injected(Triangle)):
return {
"sides": polygon_service.get_number_of_sides(),
"area": polygon_service.get_area(),
"perimeter": polygon_service.get_perimeter(),
}
For the /random-polygon endpoint, the implementation used will be randomly selected between Square and Triangle, based on the binding defined in dependency_injection.py:
injector.binder.bind(IPolygon, random.choice([Square, Triangle]))
For the /square and /triangle endpoints, we explicitly specify which implementation to inject.
And just like that, we’ve built a FastAPI application with clean, maintainable dependency injection using injector and fastapi_injector.
Conclusion
The Dependency Inversion Principle (DIP) offers significant advantages in terms of modularization and reducing coupling. Even though Python is a dynamically typed language, applying DIP can greatly enhance the structure and maintainability of complex applications. It promotes scalability and simplifies testing by allowing components to be easily swapped or mocked.
In this article, I presented a straightforward methodology for implementing dependency injection in a FastAPI application using the injector and fastapi_injector libraries. This approach is well-suited for larger, more complex projects and serves as a solid foundation for clean and scalable Python development.
I encourage you to put the SOLID principles into practice—especially the DIP—to improve the quality, flexibility, and testability of your code.