Registering services¶
A registration maps a provider — a class, a factory callable, or a ready-made instance — to the type it provides, optionally under a name. Everything else (scope, parameters, default flag) is configuration on top.
Classes¶
The common case: register a class, and the registry instantiates it on
first request, injecting whatever its __init__ needs.
from action0.service import Registry
class Database:
def __init__(self, dsn: str = "sqlite://"):
self.dsn = dsn
registry = Registry()
registry.register(Database)
registry.get(Database) # a Database, built on first use
register() accepts keyword-only options; see
register() for the full
list:
from action0.service import Scope
registry.register(
Database,
name="replica", # register under a name
scope=Scope.TRANSIENT, # a fresh instance per request
params={"dsn": "postgres://replica/app"}, # constructor parameters
replace=True, # overwrite an existing registration
)
params values are passed to the constructor as-is, with two
exceptions: Ref markers are
replaced with the referenced service at build time, and lists, tuples,
and dicts are walked recursively so a Ref can sit inside a container.
Factories¶
Any callable works as a provider. Its return annotation tells the registry what type it provides:
def make_database() -> Database:
return Database("factory://")
registry.register(make_database, replace=True)
registry.get(Database) # built by calling make_database()
A factory without a return annotation (a lambda, typically) needs an
explicit provides:
registry.register(lambda: Database("lambda://"), provides=Database, replace=True)
Factory parameters are injected exactly like constructor parameters —
a factory is simply a provider whose signature happens not to be an
__init__.
Ready-made instances¶
register_instance() stores
an object you already built. It is served as-is with singleton
semantics, and because the registry did not create it, close() will
not dispose it:
database = Database("instance://")
registry.register_instance(database, name="main")
The decorator form¶
service() is register()
as a class (or factory) decorator. It works bare or with arguments, and
returns the decorated object unchanged:
@registry.service
class Clock:
pass
@registry.service("mailer.bulk", scope=Scope.THREAD)
class BulkMailer:
pass
Names, defaults, and collisions¶
Registrations are indexed twice: by the type they provide and — if named — by name.
An unnamed registration is the default implementation for its type: it wins ambiguous type lookups. Only one unnamed registration per exact type is allowed.
Named registrations let several implementations of one type coexist. A name is unique per registry.
default=Truemarks a named registration as the winner of ambiguous type lookups; see lookup for the exact selection rules.
Colliding registrations raise
DuplicateServiceError unless you
pass replace=True, in which case the existing definition is removed
first.
registry.register(Database, name="primary")
registry.register(Database, name="primary", replace=True) # fine
Registering the provided type explicitly¶
provides registers a provider under a base type. For classes it must
be a superclass; for instances, an isinstance check applies:
class Postgres(Database):
pass
fresh = Registry()
fresh.register(Postgres, provides=Database)
type(fresh.get(Database)) # Postgres
This is rarely needed — type lookups are subclass-aware anyway — but it makes the intent explicit and pins the type used for indexing and validation.
One-off construction: build()¶
build() constructs an
object with full injection without registering or caching it. Explicit
keyword arguments win over injection:
class Report:
def __init__(self, db: Database, title: str = "untitled"):
self.db = db
self.title = title
report = fresh.build(Report, title="weekly")
Use it for request handlers, jobs, or other short-lived objects that want their dependencies wired up without becoming services themselves.
Profiles: dev/prod variants¶
A registration can be limited to profiles. A registry is created with
a set of active profiles; a definition registered with profiles= is
only visible when the two sets intersect — otherwise it is invisible to
every lookup, injection, get_all(), warmup(), and validate():
registry = Registry(profiles=["dev"])
registry.register(SqliteDatabase, name="db", profiles=["dev"])
registry.register(PostgresDatabase, name="db", profiles=["prod", "staging"])
type(registry.get("db")) # SqliteDatabase
A definition without profiles is universal — active everywhere. A
bare string is accepted as a single profile name (profiles="dev" is
{"dev"}, not the iterable of its characters).
Two definitions may share a name (or an unnamed type) only when their
profile sets are both non-empty and disjoint — then at most one of
them can ever be active. Overlapping profile sets (or a universal
definition on either side) collide as usual with
DuplicateServiceError unless
replace=True, which removes the overlapping definitions. If a registry
is created with profiles that make several variants of one name active
at once (profiles=["dev", "prod"] above), looking that name up raises
AmbiguousServiceError naming the
offending profiles.
Profiles are fixed at construction and exposed as the read-only
profiles property.
Flipping profiles on a live registry would leave cached singletons
built for the old profile set behind — to switch, create a fresh
registry (or a child; a child created without profiles= inherits its
parent’s, an explicit value overrides them). Activity is always judged
by the registry layer that owns the definition: a parent’s prod
service stays visible to a dev child as long as it is active in the
parent.
Async factories¶
A factory may be an async def function; it is registered exactly like
a sync one (the return annotation still names the provided type), but it
can only be resolved through the a-prefixed methods —
aget(),
afind(),
aget_all(),
abuild():
async def make_pool(config: Config) -> ConnectionPool:
return await ConnectionPool.connect(config.dsn)
registry.register(make_pool) # scopes, names, params: as usual
pool = await registry.aget(ConnectionPool)
Scopes, names, params, and injection behave exactly as for sync
providers, and the caches are shared: a singleton built by aget() is
the same instance a later sync get() returns. The sync methods refuse
to resolve an async definition — including one buried as a dependency —
with a ServiceError pointing at
aget(). The reverse works, though: aget() happily resolves sync
definitions, so async code can use it throughout, and a sync class
depending on an async service resolves fine via aget().
inject() is async-aware as
well: decorating an async def function yields an async wrapper that
resolves sentinel parameters through the async paths.
See Async lifecycle for eager
construction (awarmup()) and disposal (aclose(), async with).
Plugin discovery¶
load_entry_points() pulls in
services advertised by other installed packages via entry
points.
A plugin declares its services in its own pyproject.toml:
[project.entry-points."myapp.services"]
blob-storage = "myapp_blob.storage:BlobStorage"
extras = "myapp_extras.plugin:setup"
and the application collects everything installed into its registry:
registry.load_entry_points("myapp.services")
Each entry point is applied with one of two conventions, decided by what it resolves to:
a setup hook — a plain function with exactly one required parameter — is called with the registry and may register any number of services itself:
def setup(registry): registry.register(BlobStorage, name="blob") registry.register_instance(load_config(), name="blob-config")
anything else (a class or factory callable) is registered under the entry point’s name, exactly like
register(obj, name="blob-storage").
The method returns every definition that was registered — including
those added by setup hooks — and raises
DefinitionError naming the entry
point and its distribution if one fails to load or register; nothing is
skipped silently. Colliding names follow the usual rules: pass
replace=True to overwrite instead of raising.
One caveat: a factory function with exactly one required parameter looks like a setup hook. Give the parameter a default value, or expose a class or setup hook instead.