Factories¶
A Factory calls its creator, a class or function, and fills the creator's parameters by type
from the container. Without cache, every resolve calls the creator again:
import dataclasses
from modern_di import Container, Group, Scope, providers
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
class Settings:
api_url: str = "https://api.example.com"
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
class ApiClient:
settings: Settings
class Dependencies(Group):
settings = providers.Factory(Settings, cache=True)
api_client = providers.Factory(ApiClient)
container = Container(groups=[Dependencies])
client = container.resolve(ApiClient)
assert client.settings is container.resolve(Settings) # cached: one instance
assert container.resolve(ApiClient) is not client # uncached: a new one per resolve
api_url keeps its default because no provider is registered for str.
Resolving dependencies covers how parameters are matched.
Cached factories¶
With cache=True, a factory builds its instance once per container at its scope and returns that
instance on every later resolve. This is the singleton idiom, and modern-di has no separate
Singleton provider: Scope.APP gives one instance per process, Scope.REQUEST one per request.
Where is Singleton?
maps the names other DI frameworks use.
The cache is thread-safe. When several threads resolve the same cached factory at once, one instance is created and its dependencies are resolved once for it. The other threads wait for that instance, and resolves of other cached factories do not wait.
To clean the instance up when its container closes, pass
cache=providers.CacheSettings(finalizer=...); see
Caching and finalizers.
Parameters¶
Factory(creator, *, scope, bound_type, kwargs=None, cache=False, skip_creator_parsing=False).
Everything after creator is keyword-only, and creator can be passed as creator= too. The
defaults for scope and bound_type are described below.
creator¶
The callable, a function or a class, that builds the instance. modern-di reads its signature to:
- Find the return type, which becomes the
bound_typeunless you set one. - Find the parameter names and types to wire by type.
The types come from the creator's annotations. When a parameter or the return has no annotation
there, the one on inspect.signature(creator) is used, if it is a type rather than a string.
Pydantic models and msgspec Structs keep their field types only on the signature, so this is
what wires their fields.
A parameterized generic class such as Repo[int] works as a creator. Its parameters come from
Repo's constructor, calling Repo[int] builds the instance, and the bound type is Repo. Two
parameterizations of one class therefore share a bound type. To register both, set bound_type= on
them, for example bound_type=None to resolve each by reference. Before modern-di 4.2.1, such a creator was wired with no parameters and no bound type.
scope¶
The provider's lifetime. Without scope=, the provider takes its group's default scope, or
Scope.APP when the group sets none; see
Group-level default scope. A provider written inline in
another factory's kwargs takes the scope of the providers that use it; see
Inline providers.
Scopes covers the five scopes and the dependency rule.
bound_type¶
The type the provider is registered under. container.resolve(SomeType) looks it up by this type,
and a parameter annotated with it is wired to this provider. It defaults to the creator's return
annotation.
bound_type=None makes the provider resolvable only by reference: through
container.resolve_provider(Dependencies.some_provider) or as a value in another factory's
kwargs. If it is registered through a group or add_providers,
container.validate() checks it the same way as a provider registered by type
(#656).
A NewType or a type X = ... alias is a bound type of its own. A provider declared with
bound_type=UserId (or a creator returning UserId) is what a user_id: UserId parameter
resolves to; a provider bound to the underlying int is not. Pass the same object to
container.resolve() or container.find_provider() to look the provider up by it.
container.resolve(UserId) is typed Any, so annotate the variable you assign it to. mypy, which
treats a NewType as a class, types it UserId.
A return annotation that is a union of several types (-> A | B) gives no bound type, and
Factory(...) emits a UserWarning. Pass bound_type= with the type to register under, or
bound_type=None if the provider is only resolved by reference.
kwargs¶
Values for creator parameters, by name. A parameter named in kwargs is not wired by type.
- A plain value is passed as is.
- A provider is resolved when the factory resolves, and its result is passed. Use it to pick a
specific provider for one parameter, or to wire arguments under
skip_creator_parsing=True. To pass a piece of another provider's value, such as one settings field, usederive. - A name the creator does not have raises
UnknownFactoryKwargErrorwhen theFactoryis declared.
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
class Backend:
name: str
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
class Worker:
backend: Backend
retries: int
class WorkerDependencies(Group):
primary = providers.Factory(Backend, kwargs={"name": "primary"}, cache=True)
replica = providers.Factory(Backend, kwargs={"name": "replica"}, bound_type=None, cache=True)
worker = providers.Factory(Worker, kwargs={"backend": replica, "retries": 3})
container = Container(groups=[WorkerDependencies])
assert container.resolve(Worker).backend.name == "replica"
replica has bound_type=None because two providers cannot both register under Backend.
cache¶
cache=True caches with the default settings: no finalizer, and the instance is dropped when the
container closes. cache=providers.CacheSettings(...) sets a finalizer or clear_cache; see
Lifecycle. The default, cache=False, creates a new instance on every resolve. Any
other value, None included, raises TypeError.
skip_creator_parsing¶
Turns off wiring. With skip_creator_parsing=True:
- No parameter is wired by type, so every required argument must come from
kwargs. - The return annotation is not read, so
bound_typeisNoneunless you pass one.
Without an explicit bound_type, Factory(...) emits a UserWarning saying the provider cannot be
resolved by type. Pass bound_type=SomeType to register it, or bound_type=None to confirm it is
resolved by reference only.
Derived values¶
provider.derive(fn) returns a provider of fn(value), where value is what provider resolves
to. Use it in kwargs to pass a field of a settings object without writing a creator function for
the component:
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
class Settings:
db_dsn: str = "postgresql://localhost/app"
db_pool_size: int = 5
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
class Database:
dsn: str
pool_size: int
class AppDependencies(Group):
settings = providers.Factory(Settings, cache=True)
database = providers.Factory(
Database,
kwargs={
"dsn": settings.derive(lambda s: s.db_dsn),
"pool_size": settings.derive(lambda s: s.db_pool_size),
},
cache=True,
)
container = Container(groups=[AppDependencies])
container.override(AppDependencies.settings, Settings(db_dsn="postgresql://test/app"))
assert container.resolve(Database).dsn == "postgresql://test/app"
Type checkers infer s from settings, so mypy, pyright and ty all flag s.db_dns. fn can read
an attribute or an item, or call a method, and derive calls chain.
A derived provider:
- Has no scope of its own. It resolves at its source's scope, and a group's default scope does not
apply to it, so it is valid in
kwargsin any group.validate()checks the consuming factory against the source's scope. - Is never cached:
fnruns on every resolve, so an override of the source always reaches it. If the value is worth caching, give it aFactory. - Is not registered under any type. Use it in
kwargs, or resolve it withcontainer.resolve_provider. - Can be overridden itself, like any provider.
An exception raised by fn propagates unchanged, with a note naming the derived provider and the
line fn is defined on.
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])
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. A member that is itself a parameterized generic (e.g. int | list[X]) degrades to its bare origin (list) for matching purposes; see the note below. |
ArgumentResolutionError at resolve if neither A nor B has a registered provider. |
param: UserId (a NewType) or param: Alias (a type Alias = ... statement, Python 3.12+) |
Resolved from the provider whose bound_type is that NewType or alias. The underlying type is not looked up. |
ArgumentResolutionError at resolve if no provider is bound to it and there is no default. |
param: list[X] / any parameterized generic, outside a union |
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 functools.partial on Python < 3.14) |
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. |
A parameterized generic used inside a union (param: int | list[X]) is the one exception to
the "parameterized generic raises at declaration" row above: the member degrades to its bare
origin type like any other union member, so it can match a provider registered for list. The
element type X is not checked, so don't rely on it to route only correctly typed collections.
If a parameter shape would raise at declaration, there are three ways around it, 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).
Routes 2 and 3 pass the value by keyword, so they only work for a parameterized generic. A
positional-only parameter needs route 1: with route 2 it still raises
UnsupportedCreatorParameterError, and with route 3 it raises CreatorCallError at resolve.
Creator-failure semantics¶
If a creator raises during resolution:
- Nothing is cached, even with
cacheset. - The next resolve calls the creator again, so a creator that fails transiently succeeds once the underlying condition is fixed.
- Dependencies resolved before the creator raised are not rolled back. They stay cached in their containers and are finalized when those containers close.
class Connection:
attempts = 0
def __init__(self) -> None:
Connection.attempts += 1
if Connection.attempts == 1:
raise ConnectionError("transient failure")
class ConnectionDependencies(Group):
connection = providers.Factory(Connection, cache=True)
container = Container(groups=[ConnectionDependencies])
try:
container.resolve(Connection)
except ConnectionError:
pass # the first call fails and nothing is cached
connection = container.resolve(Connection) # the retry succeeds
assert connection is container.resolve(Connection) # and is cached now
See also¶
- Resolving dependencies: how parameters are matched to providers.
- Lifecycle: finalizers, closing, and
validate(). - Scopes: which container caches an instance.
- Alias: making one type resolve to another type's provider, such as a
Protocolto its implementation. - Testing with overrides: replacing a settings object in tests.