Factories¶
Factories are providers that create instances of dependencies.
Types of factories¶
There are two types of factories: regular and cached.
Regular Factories¶
Regular factories create a new instance on every call — nothing is cached.
import dataclasses
from modern_di import Group, Container, Scope, providers
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
class IndependentFactory:
dep1: str
dep2: int
class Dependencies(Group):
independent_factory = providers.Factory(
IndependentFactory,
scope=Scope.APP,
kwargs={"dep1": "text", "dep2": 123}
)
container = Container(groups=[Dependencies], validate=True)
container.open()
# Resolve by provider reference
instance = container.resolve_provider(Dependencies.independent_factory)
assert isinstance(instance, IndependentFactory)
# Resolve by type (uses the return type of the creator function/class)
instance2 = container.resolve(IndependentFactory)
assert isinstance(instance2, IndependentFactory)
Cached Factories¶
Cached factories resolve the dependency only once and cache the resolved instance for future injections.
This is modern-di's Singleton. There is no separate Singleton provider class — Factory(cache=True)
is the singleton idiom, at whatever scope you declare it (Scope.APP for one-per-process,
Scope.REQUEST for one-per-request, etc.). Other DI frameworks name this concept Singleton,
provide(..., scope=...), @injectable(lifetime="singleton"), or @lru_cache; see
Where is Singleton?
for the full cross-framework mapping.
The caching mechanism is thread-safe by default, ensuring that even when multiple threads attempt to resolve the same cached factory simultaneously, only one instance will be created.
If your application is single-threaded, you can disable the lock for a small performance gain:
Do not set use_lock=False in multi-threaded applications — it removes the guarantee that only one instance is created per cached factory.
import random
from modern_di import Group, Container, Scope, providers
def generate_random_number() -> float:
return random.random()
class Dependencies(Group):
singleton = providers.Factory(
generate_random_number,
scope=Scope.APP,
cache=True
)
container = Container(groups=[Dependencies], validate=True)
container.open()
singleton_instance1 = container.resolve_provider(Dependencies.singleton)
singleton_instance2 = container.resolve_provider(Dependencies.singleton)
# If resolved in the same container, the instance will be the same
assert singleton_instance1 is singleton_instance2
Tuning the cache¶
You can customize caching behavior by passing a CacheSettings to cache=:
import contextlib
from modern_di import Group, Scope, providers
class SomeResource:
def close(self) -> None: ...
def create_resource() -> SomeResource:
# Create and return resource
return SomeResource()
class Dependencies(Group):
# Cache with cleanup — clear_cache=True (the default) ensures the closed
# resource is evicted from cache so it cannot be returned again after close
resource = providers.Factory(
create_resource,
scope=Scope.APP,
cache=providers.CacheSettings(
finalizer=lambda res: res.close(), # Cleanup function
)
)
Parameters¶
Factory(creator, *, scope=Scope.APP, bound_type=UNSET, kwargs=None, cache=None, skip_creator_parsing=False)
— creator may also be passed as a keyword (creator=).
When creating a Factory provider, you can configure several parameters:
scope¶
Defines the lifetime (scope) of the dependency. Defaults to Scope.APP. The available scopes are APP → SESSION → REQUEST → ACTION → STEP; see Scopes for the full mental model and the dependency rule.
Groups can declare a default scope for all their members — see Group-level default scope.
creator¶
The callable (function or class) that will be invoked to create instances of the dependency. Modern-DI analyzes the creator's signature to:
- Determine the return type (used for
bound_typeif not explicitly set) - Identify parameter names and types for automatic dependency resolution
bound_type¶
Explicitly sets the type for resolving by type. By default, this is automatically inferred from the creator's return type annotation.
Set to None to make the provider unresolvable by type.
kwargs¶
Manual values for creator parameters that override automatic dependency resolution. Use this to provide specific values for parameters or override automatically resolved dependencies.
cache¶
Enables caching for the provider. Pass cache=True to cache with default settings (no finalizer, cache cleared on close), or cache=providers.CacheSettings(...) to tune the finalizer and/or clear_cache behavior. Absent, None, or False means a fresh instance is created on every resolve. See Lifecycle for how caching, finalizers, and close_async() fit together.
skip_creator_parsing¶
Disables automatic dependency resolution. When True:
- No automatic dependency resolution occurs
- All parameters must be provided via the
kwargsparameter - The
bound_typewill not be automatically inferred from the creator's return type; unlessbound_typeis explicitly provided, it defaults toNone
Resolution behavior¶
Union type parameters¶
When a parameter is annotated with a union type (e.g. dep: A | B), Modern-DI resolves the first registered type that matches. The order is determined by how types appear in the union left-to-right. If you rely on a specific type being injected, prefer a concrete type annotation over a union.
Optional parameters¶
When a parameter is annotated as X | None (or Optional[X]), the parameter is treated as optional:
- If a provider for
Xis registered, that provider is resolved and injected as usual. - If no provider for
Xis registered and the parameter has no default,Noneis injected — no error is raised, andcontainer.validate()will not flag the parameter.
This also applies to multi-member optional unions (A | B | None): the first registered member is injected, otherwise None.
Trade-off
This is a convenience, but it removes a safety net: if you intended to register a provider for an optional dependency and forgot, neither resolve() nor validate() will report it — the parameter silently receives None. For dependencies that must always be present, prefer a non-optional annotation (dep: X), which raises ArgumentResolutionError when unregistered and is flagged by validate().
import dataclasses
from modern_di import Group, Container, Scope, providers
class Cache: ...
@dataclasses.dataclass
class Service:
cache: Cache | None # injected if a Cache provider exists, else None
class Dependencies(Group):
service = providers.Factory(Service, scope=Scope.APP)
container = Container(groups=[Dependencies], validate=True)
container.open()
service = container.resolve(Service)
assert service.cache is None # no Cache provider registered -> None injected
Creator-signature support matrix¶
The table below summarises how Modern-DI handles each parameter shape during declaration (when the Factory object is constructed) and resolution (when container.resolve is called). "Escapes" means the parameter is silently excluded from automatic wiring and must be covered by kwargs or a default.
| Parameter shape | Behaviour | When it fails |
|---|---|---|
param: SomeClass — plain type annotation with a registered provider |
Resolved and injected automatically. | ArgumentResolutionError at resolve if no provider is registered and there is no default. |
param: X | None / Optional[X] |
Provider injected if one is registered; otherwise None. |
Never fails — see Optional parameters. |
param: A | B — union without None |
First registered type from the union is injected. | ArgumentResolutionError at resolve if neither A nor B has a registered provider. |
param: list[X] / any parameterized generic |
UnsupportedCreatorParameterError at declaration unless the parameter has a default value or is covered by kwargs. |
Raised at Factory(...) call time. |
Positional-only param (def f(x: T, /)) |
UnsupportedCreatorParameterError at declaration unless the parameter has a default (in which case it is silently skipped). |
Raised at Factory(...) call time. |
Unannotated param (def f(x)) |
Parsed but unresolvable by type. | ArgumentResolutionError at resolve unless covered by kwargs. |
Signature whose hints get_type_hints cannot resolve (e.g. a forward reference to an undefined name, or — on Python < 3.14 — functools.partial) |
UserWarning is emitted and type-based wiring is skipped; parameters are still parsed (as unannotated). Silence by passing skip_creator_parsing=True and an explicit bound_type. |
A required unannotated param with no provider/default raises ArgumentResolutionError at resolve unless covered by kwargs (a parameterized-generic or positional-only param still raises UnsupportedCreatorParameterError at declaration). |
skip_creator_parsing=True |
No wiring at all — every required argument must be supplied via kwargs. |
CreatorCallError at resolve for any missing required argument. |
Escaping problem shapes — if a parameter shape would raise at declaration, there are three escape routes, in order of preference:
- Give the parameter a default value (
def f(items: list[X] | None = None)). - Supply the value via
kwargs={"items": []}atFactorydeclaration time. - Pass
skip_creator_parsing=True(and supply all required args viakwargs).
Provider passed as a kwargs value¶
Passing an AbstractProvider instance directly as a value in the kwargs dict is treated as explicit wiring: Modern-DI resolves the provider and injects the resolved value — the provider object itself is never seen by the creator.
from modern_di import Container, Group, Scope, providers
class Backend:
pass
def make_service(dep: object) -> object:
...
class Dependencies(Group):
backend = providers.Factory(Backend, scope=Scope.APP)
service = providers.Factory(
make_service,
scope=Scope.APP,
skip_creator_parsing=True,
bound_type=None,
kwargs={"dep": backend}, # provider object — resolved at resolve-time
)
container = Container(groups=[Dependencies], validate=True)
# make_service receives a Backend instance, not the Factory provider
This is useful when skip_creator_parsing=True is in effect but you still want dependency injection for some arguments rather than hard-coding concrete values.
Creator-failure semantics¶
If a creator raises an exception during resolution:
- Nothing is cached. The failed instance is never stored in the cache registry, even if
cacheis set. - The next
resolvecall retries. Subsequent resolves call the creator again from scratch, so a transiently-failing creator will eventually succeed once the underlying condition is fixed. - Already-resolved dependencies are not rolled back. Dependencies that were successfully resolved before the creator raised are still held in their respective containers and will be finalized normally when those containers are closed.
import dataclasses
from modern_di import Container, Group, Scope, providers
attempt = 0
def flaky_creator() -> object:
global attempt
attempt += 1
if attempt == 1:
raise RuntimeError("transient failure")
return object()
class Dependencies(Group):
svc = providers.Factory(
flaky_creator,
scope=Scope.APP,
cache=True,
)
container = Container(groups=[Dependencies], validate=True)
container.open()
try:
container.resolve(object)
except RuntimeError:
pass # first call fails — nothing is cached
result = container.resolve(object) # retry succeeds
assert result is container.resolve(object) # now cached