Skip to content

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:

container.bind(Abstract, Concrete)

If the concrete is the same as the abstract, you may omit it:

container.bind(UserService)

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:

container.singleton(Mailer)
container.singleton(CacheStore, RedisCacheStore)

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:

container.scoped(RequestContext)

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:

container.instance(Clock, system_clock)

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:

container.singleton(HttpClient, lambda: HttpClient(timeout=30))

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:

  1. A pre-registered instance.
  2. A matching contextual binding (see below) for the current consumer.
  3. A registered binding (bind/singleton/scoped), honoring its lifetime cache.
  4. 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.extend(Mailer, lambda mailer, c: LoggingMailer(mailer))

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().