Navigating the Abyss of Dependency Injection in Python
Lukas Schneider
DevOps Engineer · Leapcell

Introduction
In the world of modern software development, maintainability, testability, and modularity are paramount. Python, with its dynamic nature and rich ecosystem, offers numerous tools and patterns to achieve these goals. Among them, Dependency Injection (DI) stands out as a powerful technique for decoupling components and improving code quality. However, like any powerful tool, DI can be misused. When dependencies are injected without careful consideration, what begins as an elegant solution can quickly devolve into an "untestable dependency hell." This article aims to explore the potential pitfalls of over-reliance on explicit Depends (or similar DI constructs) in Python, and more importantly, to guide developers on how to harness the benefits of DI without sacrificing agility or creating a debugging nightmare.
The Problem with Excessive Dependency Injection
Before diving into the "how to avoid" part, let's clarify some core terms that are crucial for understanding this discussion.
- Dependency Injection (DI): A software design pattern that implements inversion of control for resolving dependencies. Instead of a component creating its own dependencies, an external entity (the injector) provides them. This promotes loose coupling.
- Dependency: An object or service that another object needs to function correctly. For example, a
UserServicemight depend on aUserRepositoryto interact with a database. - "Dependency Hell": A state where the sheer number and complexity of interconnected dependencies make a system difficult to understand, maintain, test, and even deploy.
The problem arises when developers, in an attempt to be "correct" or "pure" in their DI implementation, start injecting everything. Consider a simple User object that might have a name and email property. Does it truly need to have its name or email injected as a dependency? Likely not. These are intrinsic properties. When every single piece of data, every helper function, and every minor component becomes an explicit injectable dependency, the following issues emerge:
- Boilerplate Overload: The constructor signatures become excessively long, filled with
Dependsdirectives. This makes the code harder to read and write. - Test Setup Complexity: For unit tests, setting up all these injected dependencies, even simple ones, can become a Herculean task. Mocking becomes intricate, and tests end up testing the DI configuration more than the actual logic.
- Fragile Architecture: A small change in a deeply nested dependency can ripple through the entire application, requiring changes in numerous
Dependsdeclarations. - Reduced Readability: The core business logic gets obscured by the noise of dependency declarations, making it harder to discern the actual purpose of a class or function.
- Performance Overhead (Minor but Present): While often negligible, resolving a vast number of dependencies can introduce a slight performance overhead.
Consider a simplified FastAPI example using Depends:
from fastapi import Depends, FastAPI, HTTPException, status from typing import Annotated # --- Problematic Example --- class DatabaseConnection: def __init__(self, host: str, port: int): self.host = host self.port = port print(f"Database connected to {host}:{port}") class UserRepository: def __init__(self, db_conn: DatabaseConnection): self.db_conn = db_conn print("UserRepository initialized") def get_user_by_id(self, user_id: int): # Imagine actual DB interaction if user_id == 1: return {"id": 1, "name": "Alice"} return None class AuthService: def __init__(self, user_repo: UserRepository, secret_key: str): # Even secret_key could be injected self.user_repo = user_repo self.secret_key = secret_key print("AuthService initialized") def authenticate_user(self, user_id: int): user = self.user_repo.get_user_by_id(user_id) if user: return f"Authenticated: {user['name']}" raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") # Dependency providers def get_db_connection() -> DatabaseConnection: return DatabaseConnection(host="localhost", port=5432) def get_user_repository( db_conn: Annotated[DatabaseConnection, Depends(get_db_connection)] ) -> UserRepository: return UserRepository(db_conn=db_conn) def get_secret_key() -> str: return "super-secret-key-123" app = FastAPI() @app.get("/users/{user_id}") async def read_user( user_id: int, auth_service: Annotated[AuthService, Depends(AuthService)] # Here AuthService is also injected, implicitly using its dependencies ): # This is where it gets tricky. AuthService itself needs dependencies. # While FastAPI can auto-resolve if types match, it's pushing the boundary. # What if AuthService had 10 dependencies? return auth_service.authenticate_user(user_id)
In the example above, AuthService itself becomes an injectable dependency. While FastAPI cleverly resolves its dependencies (UserRepository and secret_key), imagine if AuthService required many more dependencies, each with its own chain. The /users/{user_id} endpoint's signature would become unmanageable if we explicitly defined all of AuthService's dependencies, and even with implicit resolution, the mental model of the dependency graph becomes complex. Testing AuthService directly would also require providing a UserRepository and a secret_key, which itself needs a DatabaseConnection.
Strategies to Avoid Dependency Hell
The key is to apply DI judiciously, focusing on actual dependencies that vary or are complex, rather than every single component.
-
Distinguish Between "Dependencies" and "Properties":
- Dependencies: External services, complex objects, configurable resources, or components that might need to be swapped (e.g.,
UserRepository,EmailService,Logger). These are prime candidates for DI. - Properties/Value Objects: Simple data types, configuration values (unless they are a service), or self-contained objects that don't depend on external state (e.g.,
Userobject,API_KEYstring,PAGE_SIZEinteger). These should typically be passed as direct arguments or accessed via global configurations if truly global and immutable.
# -- Improved approach for properties -- class User: def __init__(self, user_id: int, name: str, email: str): self.user_id = user_id self.name = name self.email = email # No need to inject user_id, name, email as dependencies if they are simple data. # The application *provides* these values to the User constructor. def create_user_handler(user_id: int, name: str, email: str): user = User(user_id=user_id, name=name, email=email) # ... logic - Dependencies: External services, complex objects, configurable resources, or components that might need to be swapped (e.g.,
-
Use Configuration Objects for Related Settings: Instead of injecting
DB_HOST,DB_PORT,DB_USER,DB_PASSWORDindividually, group them into aDatabaseConfigobject and inject that single object.from pydantic import BaseSettings # Or any config management library class AppSettings(BaseSettings): database_host: str = "localhost" database_port: int = 5432 # ... other settings class Config: env_file = ".env" def get_app_settings() -> AppSettings: return AppSettings() class DatabaseConnection: def __init__(self, settings: AppSettings): # Inject the config object self.host = settings.database_host self.port = settings.database_port print(f"Database connected to {self.host}:{self.port}") # Now get_db_connection only depends on AppSettings def get_db_connection(settings: Annotated[AppSettings, Depends(get_app_settings)]) -> DatabaseConnection: return DatabaseConnection(settings=settings)This significantly reduces the number of constructor arguments and
Dependscalls. -
Leverage Context Managers for Lifecycle Management: For resources that need setup and teardown (like database connections, file handles), FastAPI's
yieldpattern inDependsfunctions is excellent. This keeps the resource management encapsulated.from contextlib import contextmanager class ManagedDatabaseConnection: def __init__(self, host: str): self.host = host print(f"Opening connection to {host}") def close(self): print(f"Closing connection to {self.host}") @contextmanager def create_managed_db_connection_context(): db = ManagedDatabaseConnection(host="my_db_server") try: yield db # Provide the dependency finally: db.close() # Teardown logic def get_managed_db_connection(): with create_managed_db_connection_context() as db_conn: yield db_conn # Use it like: db_conn: Annotated[ManagedDatabaseConnection, Depends(get_managed_db_connection)]This is less about reducing "Depends" itself, but more about managing the complexity within a dependency, preventing the calling code from needing to know connection details or teardown logic.
-
Use Composition over Deep Inheritance and Flat Structures: If a service has many dependencies, consider if it's doing too much. Break it down into smaller, more focused services. Each smaller service will have fewer direct dependencies. This is the Single Responsibility Principle in action.
# -- Refactored Approach (Composition) -- class EmailService: def send_email(self, recipient: str, subject: str, body: str): print(f"Sending email to {recipient} with subject '{subject}'") class NotificationService: # Focuses on notifications (could use email, SMS, etc.) def __init__(self, email_service: EmailService): self.email_service = email_service def notify_user_registration(self, user_email: str): self.email_service.send_email(user_email, "Welcome!", "Thanks for registering!") class UserService: # Focuses on user data management def __init__(self, user_repo: UserRepository, notification_service: NotificationService): self.user_repo = user_repo self.notification_service = notification_service def register_user(self, name: str, email: str): # ... create user in repo ... self.notification_service.notify_user_registration(email) return {"message": "User registered and notified"} # Now, UserService depends on NotificationService, which *internally* depends on EmailService. # The dependency graph is still there, but composition keeps constructor signatures cleaner at each level.This approach keeps
UserService's direct dependencies manageable (UserRepository, NotificationService), whileNotificationServicehandles its own (EmailService). -
Be Mindful of Testability: Before adding a
Depends, ask yourself: "How will I test this component in isolation?" If injecting a new dependency makes your test setup significantly more complex, re-evaluate if it's truly a dependency in the DI sense, or if it's just a simple value or a helper function that might be better as a static method or a direct import (if it's truly stateless and universally available).
Conclusion
Dependency Injection is a cornerstone of building robust, maintainable, and testable Python applications. However, indiscriminate use of Depends or similar constructs can lead to an unmanageable "dependency hell." By thoughtfully distinguishing between true dependencies and simple properties, employing configuration objects, leveraging context managers for resource lifecycle, practicing composition, and always keeping testability in mind, developers can harness the power of DI without drowning in boilerplate or creating fragile, hard-to-understand systems. The goal is elegant decoupling, not an endless chain of explicit injections.

