ZCore LogoZCore
Core concepts

Dependency Injection (IoC Container)

Explore ZCore's custom Inversion of Control container, constructor auto-wiring, signature caching, and lifecycle management.

ZCore does not rely on FastAPI's native Depends() chains for internal service wiring. Instead, it implements a high-performance IoCContainer designed for constructor auto-wiring and clean architecture separation.


1. The Problem with Nested Depends()

In standard FastAPI, if a route depends on a service, the service depends on a repository, and the repository depends on an AsyncSession, you end up with deeply nested, tightly coupled Depends() chains in your HTTP layer:

# Standard FastAPI (High coupling & boilerplate)
@router.post("/tasks")
def create_task(
    service: TaskService = Depends(get_task_service) # get_task_service must resolve repo & session
):
    pass

This couples web routes directly to internal persistence details, degrades IDE type-hinting, and makes unit testing difficult.


2. The Inject[T] Abstraction

ZCore uses Python's Annotated metadata system to seamlessly bridge FastAPI's route dependency injection with ZCore's IoC container:

# ZCore Approach (Clean & Decoupled)
from zcore import Inject

@router.post("/tasks")
async def create_task(service: Inject[TaskService]):
    await service.create(...)

Compilation Magic: When you annotate a parameter with service: Inject[TaskService], the Inject class intercepts the __class_getitem__ dunder method. It compiles this into: Annotated[TaskService, Depends(Injector(TaskService))].

This preserves 100% compatibility with FastAPI's OpenAPI generation while delegating full dependency graph assembly to ZCore's IoC container.


3. Lifecycle Management

The IoCContainer coordinates three distinct instance lifecycles:

  1. Singleton: Instantiated once for the entire application lifespan and stored in container._singletons (e.g., EventDispatcher, Settings).
  2. Scoped: Instantiated once per ASGI HTTP request and bound to the coroutine context via ContextVar (_scoped_instances). Managed and automatically purged by ScopedDependencyMiddleware after the response is sent (e.g., AsyncSession).
  3. Transient: A fresh, independent instance is created every time container.resolve() is executed (e.g., stateless helper utilities).

4. Auto-Wiring & Signature Caching

Inside your classes and services, you do not need decorators. Declare dependencies in the constructor via standard type hints:

class TaskService:
    # ZCore inspects the type hint and resolves TaskRepo automatically
    def __init__(self, repository: TaskRepo):
        self.repository = repository

When container.resolve(TaskService) is invoked:

  1. The container inspects TaskService.__init__ using inspect.signature and typing.get_type_hints.
  2. Signature Caching: To eliminate the runtime performance overhead of Python reflection, ZCore caches the constructor parameter dependencies in _dependency_signature_cache and constructor references in _constructor_cache.
  3. Subsequent resolutions bypass reflection completely, reading directly from the warm signature cache to construct instances with near-zero latency.

5. Circular Dependency Detection

If ServiceA depends on ServiceB, and ServiceB depends on ServiceA, a naive container would enter infinite recursion and crash the interpreter.

ZCore tracks the active resolution graph using an internal _stack set during auto-wiring. If a class is encountered that already exists in the active stack, it raises a readable CircularDependencyError:

CircularDependencyError: Circular dependency detected: ServiceA -> ServiceB -> ServiceA

Constructor vs Route Usage:

  • In Class Constructors (__init__): Use plain Python type hints (def __init__(self, repo: TaskRepo):).
  • In FastAPI Route Endpoints: Use Inject[T] (async def route(service: Inject[TaskService]):).

6. Background Tasks & Isolated Scope (background_scope, @background_task)

When an HTTP request completes, the ASGI middleware disposes of the request-scoped database session and purges _scoped_instances. Running background routines after the request finishes requires an independent scope.

ZCore provides two utilities for safe background processing:

A. The @background_task Decorator

Decorate any asynchronous coroutine or standard synchronous function. The decorator:

  1. Spawns an isolated background_scope().
  2. Automatically allocates a fresh, dedicated AsyncSession.
  3. Inspects the function's signature and auto-resolves any missing type-annotated dependencies via container.resolve().
  4. Executes synchronous functions in a dedicated worker threadpool via anyio.to_thread.run_sync.
from zcore import background_task
from tasks.services import TaskService

@background_task
async def send_welcome_email(user_id: uuid.UUID, service: TaskService):
    # 'service' and its underlying AsyncSession are automatically resolved in an isolated scope
    await service.notify(user_id)

Avoid Passing Active Request Sessions: Do not pass an existing AsyncSession from an HTTP route directly into a background task. The request session will be closed when the HTTP response completes. Let @background_task inject an isolated session automatically; passing an active session triggers a runtime logger warning.

B. The background_scope Context Manager

For manual execution blocks, background_scope provides explicit boundary control:

from zcore import background_scope

async with background_scope(inherit_context=True, custom_flag="worker"):
    # Clean, isolated IoC and DB session lifecycle
    ...

On this page