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:

  1. Configured params. A value given at registration (or in a YAML file) is used as-is — Ref markers and nested containers are resolved first, see below.

  2. A Named qualifier. If the annotation is Annotated[X, Named("some.name")], the service registered under that name is injected (see below).

  3. The annotated type. If the annotation is a class — after unwrapping Annotated[...] layers and X | None — the registry resolves it like get(X) would, including subclass-awareness, default selection, and parent fallback. A runtime-checkable typing.Protocol annotation resolves structurally, see structural lookups. Value-ish builtins are exempt, see below.

  4. The provider’s own default. If nothing was configured and no service matches, a declared default value stands.

  5. None for optional annotations. An X | None parameter without a default becomes None when X cannot be resolved.

  6. Otherwise the parameter is unresolvable and InjectionError is 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 DefinitionError when building.

  • *args is ignored; **kwargs makes the provider accept configured params keys beyond its named parameters. Unknown params keys on a provider without **kwargs raise DefinitionError.

  • Uninspectable providers (some C-implemented callables expose no signature) get their configured params passed 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; params and 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.