Parent registries and overrides

Registries nest. A child registry answers what it can locally and falls back to its parent for the rest — and local registrations shadow the parent’s. That gives you layered wiring: application services in a long-lived parent, request- or job-specific services in a short-lived child; or production wiring in the parent and test doubles in a child.

from action0.service import Registry, Scope


class Database:
    def __init__(self, dsn: str = "sqlite://"):
        self.dsn = dsn


class Repository:
    def __init__(self, db: Database):
        self.db = db


app = Registry()
app.register(Database)
app.register(Repository, scope=Scope.TRANSIENT)

request = Registry(parent=app)
request.register_instance(Database("request://"), replace=True)

request.get(Repository).db.dsn  # 'request://' — child wiring wins
app.get(Repository).db.dsn  # 'sqlite://' — parent unaffected

Who builds where

Two rules make layering predictable:

  1. Scope state lives on the owner. A singleton registered in the parent is one instance, shared by every child; a child cannot accidentally get its “own copy” of a parent singleton.

  2. Cached instances are built in the owner’s context. When a child triggers the first construction of a parent-owned singleton, its dependencies resolve against the parent’s registrations — never against the child’s. A shared instance must not capture wiring from whichever short-lived child happened to request it first.

Non-caching scopes (transient, and custom scopes with caches = False) are the mirror image: every request builds a fresh, unshared instance, so dependencies resolve through the requesting registry and pick up the child’s shadowing — as Repository does in the example above.

get_all() follows the layering too: parent registrations come first, then local ones, in registration order.

Overrides, for tests

override() temporarily replaces a service — by type or by name — for the duration of a with block. While active, the override wins every lookup, including injections into other services being built:

class FakeDatabase(Database):
    pass


registry = Registry()
registry.register(Database)
registry.register(Repository, scope=Scope.TRANSIENT)

with registry.override(Database, FakeDatabase()):
    repository = registry.get(Repository)
    assert isinstance(repository.db, FakeDatabase)

Type overrides deliberately skip any isinstance check — injecting a Mock is the whole point. Overrides nest; the innermost wins.

Two caveats, both consequences of caching:

  • Instances cached before the override keep their real dependencies — an already-built singleton is not rewritten.

  • Conversely, a singleton first built during the override keeps the replacement after the block ends.

In tests, prefer a fresh registry — or a child registry — per test over overriding in a long-lived one. And override on the registry that owns the service: parent-owned cached services build against the parent’s world, so an override declared on a child cannot reach them (shadow with a child registration instead, as in the example at the top).

The process-wide default registry

Explicit registries compose better and are easier to test — pass them around where you can, especially in library code. But an application or script often has exactly one registry anyway, and for that case default_registry() holds a process-wide instance, created lazily on first access:

from action0.service import default_registry

default_registry().register(Database)
default_registry().get(Database).dsn  # 'sqlite://'

It is never closed automatically; closing it is the application’s responsibility. set_default_registry() installs a replacement and returns the previous instance — pass None to reset, e.g. after closing, so the next access starts fresh:

default_registry().close()
set_default_registry(None)  # next default_registry() creates a new one

Code under test that relies on default_registry() is exercised with using_default_registry(), which swaps in a replacement and restores the previous default afterwards — combine it with a registry context manager so instances are disposed too:

from action0.service import Registry, using_default_registry

with Registry() as registry, using_default_registry(registry):
    registry.register_instance(FakeDatabase(), provides=Database)
    code_under_test()  # sees the fake through default_registry()

Because this is process-global state, such tests cannot run concurrently with other tests touching the default registry.