Skip to content

Factories and resources

Use a factory when construction is not a direct class call, needs injected dependencies, or owns setup and cleanup.

from clean_ioc import ContainerBuilder


def client_factory(config: Config) -> Client:
    return Client(config.endpoint)


builder = ContainerBuilder()
builder.register(Config)
builder.register(Client, factory=client_factory)
container = builder.build()

Factory parameters become compiled dependency edges. The factory itself does not run during build().

Factory return annotations

Build automatically checks a factory's declared result against its registered service. A definite incompatibility fails with factory-return-type-mismatch; no additional validation rule or policy pack is required:

from clean_ioc import ContainerBuilder, ContainerBuildError


class Repository:
    pass


def incorrect_factory() -> str:
    return "not a repository"


builder = ContainerBuilder()
builder.register(Repository, factory=incorrect_factory)
try:
    builder.build()
except ContainerBuildError as error:
    assert any(issue.code == "factory-return-type-mismatch" for issue in error.report.errors)
    print(error.report.to_text())
else:
    raise AssertionError("The incompatible factory annotation should fail build")

The check reads annotations, not function bodies or ASTs, and never activates the factory. It compares an async function's return annotation directly and a generator or context-manager factory's yielded type. It uses the compiler's resolved bindings for generic factories, accepts subclasses and compatible union members, and checks supported closed generic arguments with their declared variance. Transparent aliases are normalized; NewType retains its nominal identity.

Missing annotations, Any, erased generic arguments, and unsupported or unresolved typing forms do not produce a mismatch. Structural protocol compatibility is left to application type checkers when nominal inheritance does not establish it. Existing signature and type-alias errors remain errors independently of this rule. The check cannot prove that an implementation returns what its annotation promises; application type checkers own that responsibility.

Findings include the compiled dependency path and available registration sources in SARIF. Boundaries, overlays, deferred provider targets, and ordinary registered factories use the same automatic check. Constructor and instance registrations are unaffected. Explicit validation reports retain the stored build findings without repeating the check.

Union service types

Register a union as one service key when a factory can return either of two types. For example, a Redis client factory can choose between standalone and cluster clients using configuration:

from redis import Redis
from redis.cluster import RedisCluster

from clean_ioc import ContainerBuilder

RedisClient = Redis | RedisCluster


class RedisConfig:
    def __init__(self, url: str, cluster_mode: bool):
        self.url = url
        self.cluster_mode = cluster_mode


def get_redis_client(config: RedisConfig) -> RedisClient:
    if config.cluster_mode:
        return RedisCluster.from_url(config.url)
    return Redis.from_url(config.url)


class Cache:
    def __init__(self, client: RedisClient):
        self.client = client


builder = ContainerBuilder()
builder.register(
    RedisConfig,
    instance=RedisConfig("redis://localhost:6379", cluster_mode=False),
    lifespan="singleton",
)
builder.register(RedisClient, factory=get_redis_client, lifespan="singleton")
builder.register(Cache)
container = builder.build()
client = container.resolve(RedisClient)  # Inferred as Redis | RedisCluster
cache = container.resolve(Cache)

A | B, Union[A, B], and assignment aliases such as Client = A | B are supported. Equivalent unions, including reversed member order, select the same key. You can also supply instance= or a concrete implementation class. Registering a union without any of these construction choices raises TypeError.

The union is an explicit key: registering it does not register either member, and registrations under A or B do not satisfy a dependency on A | B. Named selection, async resolution, providers, caching and resource cleanup use the normal container rules. A | None does not make injection optional: a Python parameter default is used when present; otherwise the complete union requires a registration or scope slot. Use inject() to override a default.

Public service-key annotations use typing_extensions.TypeForm, so type checkers supporting it retain the requested union as the result type. Native type Client = A | B declarations and typing_extensions.TypeAliasType aliases are transparent spellings of the same complete union key; the individual union members remain separate keys.

Async factories

async def token_factory(config: Config) -> Token:
    return await fetch_token(config)


builder.register(Token, factory=token_factory)
container = builder.build()
token = await container.resolve_async(Token)

A plan containing async activation must use resolve_async().

Generator factories

Yield one value and put cleanup after the yield:

def connection_factory():
    connection = Connection.open()
    try:
        yield connection
    finally:
        connection.close()


builder = ContainerBuilder()
builder.register(Connection, factory=connection_factory, lifespan="scoped")
container = builder.build()

with container.new_scope() as scope:
    connection = scope.resolve(Connection)

The generator finalizer belongs to the same owner as the cached component.

Context managers

Functions decorated with @contextmanager and @asynccontextmanager are supported as factories. Clean IoC enters them on activation and exits them when the owning scope or container closes.

Keep resource acquisition and release together in the generator or context-manager factory. This makes cleanup ownership explicit and works for both synchronous and asynchronous resources. A ScopeBuilder singleton is finalized by its built scope; a root singleton is finalized by the container.

Reusing another compiled component

Factory helpers such as use_component(...) resolve through the current ResolutionContext, preserving per_resolution identity:

from clean_ioc.factories import use_component

builder.register(SenderImpl)
builder.register(Sender, factory=use_component(SenderImpl))
builder.register(BatchSender, factory=use_component(SenderImpl))

use_component() and use_component_async() attach their target and filter as compiler metadata. The referenced root therefore appears as a dependency edge in the compiled graph and participates in missing-component, circular-dependency, captive-lifespan, and sync/async validation. Runtime use still resolves through the current context to preserve per_resolution identity; it does not trigger registration discovery or graph compilation.

Direct calls made through an injected ResolutionContext remain dynamic because the compiler cannot inspect arbitrary function bodies. Use the helpers when the target is known during composition and should appear in the compiled graph.

Argument values and selection

Use arguments= for fixed constructor/factory values, select(...) for a filtered component edge, and derive(...) for a pure build-time composition rule. build_arg(...) and generic_arg(...) project common metadata as frozen values, while inject() forces ordinary unnamed injection over a Python default. Runtime-changing values belong in an ordinary component factory or a declared scope slot. See argument policies.