Skip to content

IoC Container (punq)

The Inversion of Control (IoC) container is the heart of the application's architecture. It manages object creation and dependency resolution.

What is punq?

punq is a lightweight dependency injection container for Python. It:

  • Resolves dependencies automatically from type hints
  • Supports singleton and transient scopes
  • Has no external dependencies

Container Configuration

The container is configured in src/ioc/container.py:

from punq import Container, Scope


def get_container() -> Container:
    container = Container()

    _register_services(container)
    _register_http(container)
    _register_controllers(container)
    _register_celery(container)
    _register_bot(container)

    return container

All entry points (HTTP, bot, Celery) use the same container configuration, ensuring consistent behavior.

Registration Methods

Type-Based Registration

For classes whose dependencies can be resolved from __init__ signature:

container.register(JWTService, scope=Scope.singleton)

punq will automatically resolve JWTServiceSettings when creating JWTService:

class JWTService:
    def __init__(self, settings: JWTServiceSettings) -> None:
        self._settings = settings

Factory-Based Registration

For objects that need special construction (e.g., loading from environment):

container.register(
    JWTServiceSettings,
    factory=lambda: JWTServiceSettings(),
)

This is necessary because Pydantic settings classes read from environment variables during instantiation.

Instance Registration

For registering concrete implementations of abstract types:

container.register(
    type[BaseRefreshSession],
    instance=RefreshSession,
)

This maps the abstract BaseRefreshSession to the concrete RefreshSession model.

Scopes

Singleton

One instance for the entire application lifetime:

container.register(JWTService, scope=Scope.singleton)

Use for:

  • Services with expensive initialization
  • Stateless services
  • Configuration objects

Transient (Default)

New instance on every resolution:

container.register(SomeService)  # Default scope is transient

Use for:

  • Objects with request-specific state
  • Objects that should not be shared

Resolving Dependencies

Direct Resolution

container = get_container()
jwt_service = container.resolve(JWTService)

Automatic Resolution

When a registered class depends on another registered type, punq resolves the entire dependency chain:

# JWTAuth depends on JWTService
# JWTService depends on JWTServiceSettings
# punq resolves the full chain automatically

jwt_auth = container.resolve(JWTAuth)

Real Example: HTTP Controllers

def _register_controllers(container: Container) -> None:
    container.register(HealthController, scope=Scope.singleton)
    container.register(UserController, scope=Scope.singleton)
    container.register(UserTokenController, scope=Scope.singleton)

The UserTokenController has these dependencies:

class UserTokenController(Controller):
    def __init__(
        self,
        jwt_service: JWTService,
        refresh_token_service: RefreshSessionService,
        jwt_auth: JWTAuth,
    ) -> None:
        self._jwt_service = jwt_service
        self._refresh_token_service = refresh_token_service
        self._jwt_auth = jwt_auth

punq resolves all three dependencies automatically because they're registered in the container.

Factories with Container Access

For complex creation logic, factories can access the container:

container.register(
    NinjaAPI,
    factory=lambda: container.resolve(NinjaAPIFactory)(),
    scope=Scope.singleton,
)

The NinjaAPIFactory is resolved first, then called to create the NinjaAPI instance.

Testing with IoC

The IoC pattern enables easy testing through dependency override:

@pytest.fixture(scope="function")
def container(django_user_model: type[User]) -> Container:
    container = get_container()

    # Override registrations for testing
    container.register(TestNinjaAPIFactory, scope=Scope.singleton)
    container.register(TestClientFactory, scope=Scope.singleton)

    return container

See Mocking IoC Dependencies for detailed testing patterns.

Best Practices

1. Register at Startup

All registrations should happen during application startup, not during request handling.

2. Use Singletons for Stateless Services

# Good: Stateless service as singleton
container.register(JWTService, scope=Scope.singleton)

3. Explicit Dependencies

Always declare dependencies in __init__:

# Good: Explicit dependency
class UserController:
    def __init__(self, auth: JWTAuth) -> None:
        self._auth = auth

# Avoid: Hidden dependency
class UserController:
    def __init__(self) -> None:
        self._auth = get_container().resolve(JWTAuth)  # Hidden!

4. Interface-Based Registration

For swappable implementations, register against interfaces:

container.register(
    type[BaseRefreshSession],
    instance=RefreshSession,
)

This allows tests to provide mock implementations.