Injection¶
When the registry builds a service, it decides a value for every
parameter of the provider’s signature — the __init__ of a class, or
the factory callable itself. The rules are few and strictly ordered.
The resolution order¶
For each parameter, the first applicable rule wins:
Configured
params. A value given at registration (or in a YAML file) is used as-is —Refmarkers and nested containers are resolved first, see below.A
Namedqualifier. If the annotation isAnnotated[X, Named("some.name")], the service registered under that name is injected (see below).The annotated type. If the annotation is a class — after unwrapping
Annotated[...]layers andX | None— the registry resolves it likeget(X)would, including subclass-awareness, default selection, and parent fallback. A runtime-checkabletyping.Protocolannotation resolves structurally, see structural lookups. Value-ish builtins are exempt, see below.The provider’s own default. If nothing was configured and no service matches, a declared default value stands.
Nonefor optional annotations. AnX | Noneparameter without a default becomesNonewhenXcannot be resolved.Otherwise the parameter is unresolvable and
InjectionErroris raised, naming the parameter and the missing type.
In short: explicit configuration beats registry wiring beats declared defaults — and a parameter that can fall back never raises.
from action0.service import Registry
class Database:
def __init__(self, dsn: str = "sqlite://"):
self.dsn = dsn
class Cache:
pass
class Repository:
def __init__(self, db: Database, cache: Cache | None = None, timeout: float = 5.0):
self.db = db # rule 3: resolved from the registry
self.cache = cache # rules 3-5: injected if registered, else None
self.timeout = timeout # rule 4: no Service for float — default stands
registry = Registry()
registry.register(Database)
registry.register(Repository)
repository = registry.get(Repository)
What is never injected¶
Bare annotations of value-ish builtin types — str, int, float,
bool, bytes, list, dict, set, tuple, and friends — are
never resolved from the registry: injecting “the registered str”
into every host: str parameter would be a footgun. Such parameters
are filled from params, their defaults, or an explicit
Annotated[str, Named("...")] qualifier, which bypasses the exemption
on purpose.
Multi-type unions (A | B) are not injectable by type either — the
registry will not guess which side you meant. X | None is the one
union form it understands: it means optional X.
Parameters without any annotation follow the same path as unresolvable
types: configured value, then default, then
InjectionError.
The Named qualifier¶
When several services provide one type,
Named picks one by name, inside
typing.Annotated:
from typing import Annotated
from action0.service import Named
class Sync:
def __init__(self, source: Database, target: Annotated[Database, Named("replica")]):
self.source = source
self.target = target
If the named service is missing, an optional annotation yields None,
a declared default stands, and otherwise
InjectionError is raised.
Ref values¶
Inside params, a Ref is a
late-bound reference to another service — by name or by type — resolved
when the depending service is built. Lists, tuples, and dicts in
params are walked recursively, so refs can sit inside containers:
from action0.service import Ref
registry.register(
Sync,
name="nightly",
params={"target": Ref("replica"), "source": Ref(Database)},
)
Use a Ref when the registration should decide the wiring; use
Named when the class should declare it.
Signature details¶
Positional-only parameters are filled positionally. If an earlier positional-only parameter fell back to its default, a later one cannot be filled anymore — that raises
DefinitionErrorwhen building.*argsis ignored;**kwargsmakes the provider accept configuredparamskeys beyond its named parameters. Unknownparamskeys on a provider without**kwargsraiseDefinitionError.Uninspectable providers (some C-implemented callables expose no signature) get their configured
paramspassed verbatim as keyword arguments; injection by annotation is unavailable for them.Unresolvable type hints (dangling forward references and the like) disable annotation-based injection for that provider;
paramsand defaults still work.
Injecting into functions: @registry.inject¶
inject() extends injection
to ordinary functions. Only parameters whose default is the
injected sentinel take part — the
signature stays honest for callers and type checkers:
from action0.service import injected
class Mailer:
def send(self, subject: str) -> None:
pass
registry.register(Mailer)
@registry.inject
def send_report(report: str, mailer: Mailer = injected) -> None:
mailer.send(report)
send_report("weekly") # mailer resolved from the registry
send_report("weekly", mailer=Mailer()) # explicit argument wins
Resolution happens per call, against the registry the decorator
came from — an active override
is honored. Passing injected explicitly also triggers resolution
(useful when the parameter sits before others you want to pass). A
sentinel parameter that cannot be resolved raises
InjectionError at call time.