IoC Container¶
The Inversion of Control (IoC) container manages dependency injection, automatically wiring components together.
What is Dependency Injection?¶
Without DI, components create their own dependencies:
# ❌ Without DI - hard to test, tightly coupled
class UserController:
def __init__(self):
self._user_service = UserService() # Creates its own dependency
self._jwt_service = JWTService()
With DI, dependencies are provided externally:
# ✅ With DI - testable, loosely coupled
class UserController:
def __init__(self, user_service: UserService, jwt_service: JWTService):
self._user_service = user_service
self._jwt_service = jwt_service
The IoC container is the "external provider" that creates and connects components.
The punq Container¶
This project uses punq, a lightweight Python DI container.
Basic usage:
from punq import Container
container = Container()
container.register(UserService) # Register service
service = container.resolve(UserService) # Get instance
AutoRegisteringContainer¶
The project extends punq with AutoRegisteringContainer that automatically registers services when resolved:
# No explicit registration needed!
container = AutoRegisteringContainer()
service = container.resolve(UserService) # Auto-registered and returned
How It Works¶
When you resolve a type that isn't registered:
- Inspect
__init__: Check type annotations for dependencies - Resolve dependencies: Recursively resolve each dependency
- Register: Add the type as a singleton
- Return instance: Create and return the instance
container.resolve(UserController)
│
▼
┌─────────────────────────────────────┐
│ UserController not registered │
│ Check __init__ type annotations: │
│ - user_service: UserService │
│ - jwt_service: JWTService │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Resolve UserService (recursively) │
│ Resolve JWTService (recursively) │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Register UserController as │
│ singleton with resolved deps │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Return UserController instance │
└─────────────────────────────────────┘
Pydantic Settings Detection¶
The container detects BaseSettings subclasses and registers them with a factory:
class JWTServiceSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="JWT_")
secret_key: str
algorithm: str = "HS256"
# Auto-registered with factory: lambda: JWTServiceSettings()
settings = container.resolve(JWTServiceSettings)
# settings.secret_key is loaded from JWT_SECRET_KEY env var
Container Factory¶
The ContainerFactory creates configured containers:
# src/ioc/container.py
class ContainerFactory:
def __call__(
self,
*,
configure_django: bool = True,
configure_logging: bool = True,
instrument_libraries: bool = True,
) -> AutoRegisteringContainer:
container = AutoRegisteringContainer()
# It's required to configure Django before any registrations due to model imports
if configure_django:
self._configure_django(container)
if configure_logging:
self._configure_logging(container)
if instrument_libraries:
self._instrument_libraries(container)
self._register(container)
return container
def _configure_django(self, container: AutoRegisteringContainer) -> None:
configurator = container.resolve(DjangoConfigurator)
configurator.configure(django_settings_module="configs.django")
def _configure_logging(self, container: AutoRegisteringContainer) -> None:
configurator = container.resolve(LoggingConfigurator)
configurator.configure()
def _instrument_libraries(self, container: AutoRegisteringContainer) -> None:
instrumentor = container.resolve(OpenTelemetryInstrumentor)
instrumentor.instrument_libraries()
def _register(self, container: AutoRegisteringContainer) -> None:
# Import registry functions here to avoid imports before setting up Django
from ioc.registries import Registry
registry = container.resolve(Registry)
registry.register(container)
Usage:
Auto-resolved configurators
Notice that configurators like DjangoConfigurator and LoggingConfigurator are resolved from the container. This ensures their dependencies (like settings classes) are properly injected.
Explicit Registration¶
Most services don't need explicit registration. However, some cases require it:
String-Based Keys¶
When resolving by string instead of type:
# src/ioc/registries.py
from punq import Container, Scope
from delivery.http.factories import FastAPIFactory
class Registry:
def register(self, container: Container) -> None:
# Using string-based registration to avoid loading django-related code too early
container.register(
"FastAPIFactory",
factory=lambda: container.resolve(FastAPIFactory),
scope=Scope.singleton,
)
Usage:
Protocol Mappings (Example Pattern)¶
When an interface should resolve to a concrete implementation:
# Example - not in current codebase
class Registry:
def register(self, container: Container) -> None:
container.register(
SettingsProtocol,
factory=lambda: container.resolve(ConcreteSettings),
scope=Scope.singleton,
)
Note
The current codebase only uses string-based registration for FastAPIFactory. Protocol mappings shown above are an example pattern you might use when abstracting interfaces.
Scopes¶
The container supports different scopes:
| Scope | Behavior |
|---|---|
singleton |
One instance per container (default) |
transient |
New instance each time |
Auto-registered services use singleton scope by default.
Singleton Behavior¶
With singleton scope, resolving the same type returns the same instance:
service1 = container.resolve(UserService)
service2 = container.resolve(UserService)
assert service1 is service2 # Same instance
This is important for stateful services and performance.
Testing with IoC¶
Per-Test Containers¶
Each test gets a fresh container:
@pytest.fixture(scope="function")
def container() -> AutoRegisteringContainer:
return ContainerFactory()()
Overriding Registrations¶
Register mocks before resolving:
def test_with_mock(container: AutoRegisteringContainer) -> None:
mock_service = MagicMock()
container.register(UserService, instance=mock_service)
controller = container.resolve(UserController)
# controller._user_service is the mock
Test Factories¶
Use container-based factories for test setup:
class TestClientFactory(ContainerBasedFactory):
def __init__(self, container: AutoRegisteringContainer) -> None:
self._container = container
def __call__(self, auth_for_user: User | None = None) -> TestClient:
# Uses container to resolve dependencies
...
Best Practices¶
Do: Use Type Hints¶
Don't: Use Any or Missing Hints¶
Do: Keep Dependencies Explicit¶
# ✅ Clear dependencies in __init__
@dataclass(kw_only=True)
class OrderService:
_user_service: UserService
_payment_service: PaymentService
Don't: Create Dependencies Internally¶
Do: Use Dataclasses¶
The kw_only=True ensures explicit naming when constructing.
Summary¶
The IoC container:
- Auto-registers services when resolved
- Detects Pydantic settings and loads from environment
- Resolves dependency graphs recursively
- Uses singleton scope by default
- Enables easy testing with overrides