Service Container¶
Introduction¶
The Arvel service container is a powerful tool for managing class dependencies and performing dependency injection. Dependency injection is a fancy phrase that essentially means this: class dependencies are "injected" into the class via the constructor instead of being constructed by the class itself.
Consider a UserService that needs a Mailer. Rather than building the mailer inside the service, you type-hint it in the constructor and let the container provide it:
from arvel.container import Container
class Mailer: ...
class UserService:
def __init__(self, mailer: Mailer) -> None:
self.mailer = mailer
container = Container()
svc = container.make(UserService) # Mailer is auto-wired
The Application owns a root container. In practice you interact with it through service providers, the dep() helper in routes, and facades — but understanding the container directly makes all of those clearer.
Binding¶
Binding Basics¶
Almost all of your container bindings will be registered within service providers. Within a provider, you have access to the container via self.container. Register a binding with bind, passing the abstract type and a concrete type or factory:
If the concrete is the same as the abstract, you may omit it:
By default bind registers a transient binding — a new instance is created on every resolution.
Binding Singletons¶
The singleton method binds a type that should be resolved only once; the same instance is returned on every subsequent call:
Binding Scoped¶
The scoped method binds a type that should be resolved once per container scope. Within a single scope the same instance is reused; a new scope gets a fresh instance:
Binding Instances¶
You may bind an existing object instance into the container with instance. The given instance is returned on every resolution and takes priority over other bindings:
Binding a Factory¶
The concrete argument may be a callable factory instead of a class. The factory builds the instance when the type is resolved:
For async factories, register normally but resolve with amake — resolving an async factory through the synchronous make raises AsyncBindingError.
Resolving¶
The make Method¶
Use make to resolve a class instance out of the container. It takes the type you want, plus optional keyword overrides for individual constructor parameters:
svc = container.make(UserService)
svc = container.make(UserService, mailer=test_mailer) # override one dependency
For bindings backed by an async factory, use amake. To resolve a class and call one of its methods with injected parameters, use call / acall:
svc = await container.amake(AsyncService)
result = container.call(ReportBuilder, "build")
result = await container.acall(ReportBuilder, "build_async")
Check container state with bound(abstract) and resolved(abstract).
Note
Application.make(...) delegates to the container's make. Despite its type hint suggesting a string key, pass a type — for example, app.make(Router).
Automatic Injection¶
The container resolves constructor dependencies automatically from their type hints. A concrete class with type-hinted constructor parameters can be resolved without being explicitly bound — the container auto-wires it, recursively resolving each dependency:
class OrderService:
def __init__(self, mailer: Mailer, cache: CacheStore) -> None:
...
# No explicit binding needed; Mailer and CacheStore are resolved recursively.
service = container.make(OrderService)
Warning
Two cases can't be auto-wired and must be bound explicitly: abstract classes (you must bind a concrete implementation), and classes whose __init__ is the default object.__init__ with no declared dependencies the container can introspect. Bind those with bind/singleton.
Resolution Order¶
When resolving a type, the container checks, in order:
- A pre-registered instance.
- A matching contextual binding (see below) for the current consumer.
- A registered binding (
bind/singleton/scoped), honoring its lifetime cache. - Auto-wiring of a concrete class via its
__init__type hints.
Contextual Binding¶
Sometimes you may have two classes that utilize the same interface, but you wish to inject different implementations into each. Use the fluent when/needs/give API to give a specific consumer its own implementation:
container.when(MarketingNotifier).needs(Mailer).give(SesMailer)
container.when(SystemNotifier).needs(Mailer).give(SmtpMailer)
give accepts a concrete type, an instance, or a factory.
Tagging¶
Occasionally you may need to resolve all of a certain "category" of binding. Tag a set of bindings, then resolve them all at once with tagged:
container.tag([SlackReporter, EmailReporter, SmsReporter], "reporters")
reporters = container.tagged("reporters") # list of resolved instances
Extending Bindings¶
The extend method allows the modification of a resolved service. It takes a decorator that receives the instance (and the container) and returns the instance to use. Extending a singleton invalidates its cached instance so the decorator applies:
Container Scopes¶
A scope is a child container that shares the parent's singleton and instance caches but maintains its own cache for scoped bindings. Open one with scope / ascope:
with container.scope() as scoped:
a = scoped.make(RequestContext)
b = scoped.make(RequestContext)
assert a is b # same instance within the scope
async with container.ascope() as scoped:
ctx = await scoped.amake(RequestContext)
When the scope exits, its scoped cache is cleared.
Note
Scoped bindings only deduplicate within an explicit scope()/ascope() block today. Per-request child containers aren't wired into the HTTP path yet — the request scope currently points at the root container (see below).
Resolving in Routes With dep()¶
FastAPI route handlers resolve container bindings through dep(), which adapts a binding into a FastAPI dependency:
from fastapi import Depends
from arvel import Route, dep
from app.services.user_service import UserService
@Route.get("/users")
async def list_users(svc: UserService = Depends(dep(UserService))) -> list[dict]:
return await svc.all()
dep() reads the container from request.state.arvel_scope, which the framework's scope middleware attaches automatically (mounted by into_asgi()).
Note
dep() currently resolves against the root container; true per-request child scopes aren't wired into the HTTP path yet. If dep() is used without the scope middleware mounted, it raises a RuntimeError.
Container Errors¶
| Exception | Meaning |
|---|---|
BindingResolutionError | The container can't build the requested type (unbound abstract, missing dependency, constructor mismatch). |
CircularDependencyError | Dependencies form a cycle. |
AsyncBindingError | Synchronous make() was used on a binding backed by an async factory — use amake(). |