Splitting Dependency Injection Containers to Eliminate Bottlenecks
This article explains how to refactor a monolithic dependency‑injection container into separate repository, service, and application containers, aligning with the Single Responsibility Principle, improving maintainability, testability, and limiting change ripple effects within clear architectural boundaries, with full Python/FastAPI code examples.
Recently I was working on a codebase migration, refactoring the entire repository to use Dependency Injection (DI). This resolved coupling issues between components; they no longer instantiate directly but are connected via containers, making the code more maintainable and testable.
However, the DI container became a single point of bottleneck. Adding a new service, repository, or any external integration becomes painful, and it violates the Single Responsibility Principle (SRP) that DI should enforce.
So why not split the container according to architectural responsibilities?
Split containers by specific business functions.
Repository Container: Only for Data Access
# containers/repository_container.py
from dependency_injector import containers, providers
from repositories.user_repository import UserRepository
class RepositoryContainer(containers.DeclarativeContainer):
"""
Used for the data‑access layer (repository‑level dependencies).
"""
# Dependency placeholder injected from ApplicationContainer
config = providers.Dependency()
user_repository = providers.Factory(UserRepository, config)Service Container: Only for Business Logic
# containers/service_container.py
from dependency_injector import containers, providers
from containers.repository_container import RepositoryContainer
from services.user_service import UserService
class ServiceContainer(containers.DeclarativeContainer):
"""
Used for the business‑logic layer (services).
"""
config = providers.Dependency()
repositories: RepositoryContainer = providers.DependenciesContainer()
user_service = providers.Factory(
UserService,
config=config,
user_repository=repositories.user_repository,
)Application Container: Composer
# containers/application_container.py
from dependency_injector import containers, providers
from containers.repository_container import RepositoryContainer
from containers.service_container import ServiceContainer
from configs.config import get_config
class ApplicationContainer(containers.DeclarativeContainer):
"""
Root application container that composes the entire dependency graph.
- Loads configuration
- Composes repository and service containers
- Serves as the single entry point for DI in the app
"""
config = providers.Singleton(get_config)
repositories: RepositoryContainer = providers.Container(
RepositoryContainer,
config=config,
)
services: ServiceContainer = providers.Container(
ServiceContainer,
config=config,
repositories=repositories,
)Configuration management itself is also a design decision, as shown below.
Using Dependency Injection
# controllers/user_controller.py
from fastapi import APIRouter, Depends
from dependency_injector.wiring import inject, Provide
from containers.application_container import ApplicationContainer
from services.user_service import UserService
router = APIRouter()
@router.post("/")
@inject
async def create_user(
user_data: dict,
user_service: UserService = Depends(
Provide[ApplicationContainer.services.user_service]
),
):
return await user_service.create_user(user_data)
# services/user_service.py
from typing import Dict
from repositories.user_repository import UserRepository
class UserService:
def __init__(self, config: dict, user_repository: UserRepository):
self.config = config
self.user_repository = user_repository
async def create_user(self, user_data: Dict) -> Dict:
return await self.user_repository.save_user(user_data)
# repositories/user_repository.py
from typing import Dict
class UserRepository:
def __init__(self, config: dict):
self.config = config
async def save_user(self, user_data: Dict) -> Dict:
return {"status": "success", "user": user_data}Set up ApplicationContainer in app.py
# app.py
from fastapi import FastAPI
from contextlib import asynccontextmanager
from containers.application_container import ApplicationContainer
from controllers.user_controller import router as user_router
# Application lifecycle context manager
@asynccontextmanager
async def lifespan(app: FastAPI):
container: ApplicationContainer = app.container
# Wire the container to the specified modules for injection
container.wire(modules=["controllers.user_controller"])
# Initialize resources managed by the container (if needed)
container.init_resources()
yield
def create_app() -> FastAPI:
app = FastAPI(lifespan=lifespan)
# Initialize the application container
app.container = ApplicationContainer()
# Add routes
app.include_router(user_router, prefix="/api/users")
return app
app = create_app()
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app:app",
host="0.0.0.0",
port=8000,
reload=True,
access_log=True,
)Finally, our folder structure looks like this:
With this pattern, the ripple effect of changes is now confined within architectural boundaries.
Repository changes → affect only RepositoryContainer Service changes → affect only ServiceContainer Infrastructure changes → affect only ApplicationContainer It is no longer just about wiring dependencies, but about keeping connections clear, modular, and correctly placed.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Code Mala Tang
Read source code together, write articles together, and enjoy spicy hot pot together.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
