Container¶
AdapterContainer wires dependencies for the application and infrastructure layers. It manages sessions, handlers (CommandHandler[C], QueryHandler[Q]), caches, and ports. Handlers implement CommandPort[C] / QueryPort[Q] and are injected into UseCase fields automatically.
AdapterContainer¶
AdapterContainer is the dependency injection container. It can be used directly without subclassing. Pass port instances as keyword arguments to the constructor.
Constructor¶
AdapterContainer(**fields)
| Parameter | Type | Description |
|---|---|---|
sessions |
set[type[Session] \| type[AsyncSession]] |
Session classes (not instances) to manage. Default: set(). |
handlers |
list[AnyHandler] |
Handler classes to register. Default: []. |
caches |
list[Cache \| AsyncCache] |
Cache instances whose CacheManager context is activated when adapting operations. Default: []. |
ports |
dict[type[Port], Port] |
Type-based port resolution fallback. When a port field type is not found by name, the container checks this dict. Default: {}. |
**fields |
Port |
Custom ports registered by field name. Any keyword argument that is a Port instance is registered by name. |
Default Fields¶
| Field | Type | Default |
|---|---|---|
sessions |
set[type[Session] \| type[AsyncSession]] |
set() |
handlers |
list[AnyHandler] |
[] |
caches |
list[Cache] |
[] |
ports |
dict[type[Port], Port] |
{} |
Methods¶
__init__(self, ...)¶
The constructor validates that no duplicate handlers are registered.
get_session(session_cls: type[Session] | type[AsyncSession]) -> Session | AsyncSession¶
Retrieve or instantiate a session class.
| Parameter | Type | Description |
|---|---|---|
session_cls |
type[Session] \| type[AsyncSession] |
The session class to retrieve. |
- If the session class has already been instantiated, returns the cached instance.
- Otherwise, finds a matching class in
self.sessions, instantiates it, caches it in_sessions_needed, and returns it. - Raises
SessionNotFoundErrorif no matching session class is registered.
get_handler(contract: type[Command] | type[Query]) -> CommandHandler | AsyncCommandHandler | QueryHandler | AsyncQueryHandler¶
Find and instantiate a handler for a given contract.
| Parameter | Type | Description |
|---|---|---|
contract |
type[Command] \| type[Query] |
The command or query class. |
- Searches registered handlers for one whose
handle()method accepts the given contract. - Iterates the handler's fields and injects a session instance for each field with a concrete session type annotation.
- Applies matching caches to the handler via
add_cache(). - Raises
HandlerNotFoundErrorif no handler matches the contract.
get_port(name: str) -> Port¶
Find a port implementation by field name.
| Parameter | Type | Description |
|---|---|---|
name |
str |
The field name registered on the container. |
- Looks up
_ports_by_namefor the registered field name. - Returns the field value.
- Raises
PortNotFoundErrorif no port with that name is found.
adapt(operation_cls: type[TOperation], **overrides: Any) -> TOperation¶
Create a use case or projection instance with all dependencies wired automatically. This is the single public entry point for dependency injection -- it dispatches to the appropriate internal method based on the class type.
| Parameter | Type | Description |
|---|---|---|
operation_cls |
type[UseCase \| AsyncUseCase \| ProjectionBase] |
The use case or projection class to instantiate. |
**overrides |
Any |
Optional field overrides for the container copy. |
For use cases:
- Injects matching handler ports (CommandPort[C], QueryPort[Q]) by contract type.
- Injects custom ports by field name, with type-based fallback from ports dict.
- Wraps the operation's entry points with CacheManager context if caches are configured.
- The UseCase creates its own Transaction internally -- the container does not inject it.
For projections:
- Injects sessions by type annotation for any field with a Session/AsyncSession annotation.
- Injects custom ports by field name, with type-based fallback from ports dict.
When **overrides are provided, creates a container copy before injection.
with_adapters(**overrides: Any) -> Self¶
Create a copy of the container with overridden fields.
| Parameter | Type | Description |
|---|---|---|
**overrides |
Any |
Field values to override in the copy. |
Port Resolution Order¶
When injecting ports into a use case or projection, the container resolves each port field in this order:
- Named port -- looks up the field name in
_ports_by_name(from container subclass fields or keyword arguments). - Type-based fallback -- looks up the field's type annotation in
self.portsdict. - If neither resolves, raises
PortNotFoundError.
Handler Validation¶
The container enforces:
- No duplicate handler registrations (two handlers for the same contract raise
DuplicateHandlerError). - Each handler is inspected to determine which
CommandorQuerytype itshandle()method accepts.
Cache Activation¶
Cache instances passed via the caches parameter are activated when adapting operations via adapt():
- Each cache instance carries
CacheKeydefinitions that declare whichQueryandCommandtypes they intercept. - When
adapt()creates a use case or projection, it wraps the operation's entry points (run/read/write) with aCacheManagercontext. - Inside the context, handler-level read-through caching and command-level invalidation happen automatically via
get_cache_context(). - Read-through, invalidation, and cache flushing happen inside the
Transaction, which the UseCase creates internally. - The spy container (
spy_adapter_container) overrides_wrap_with_cacheas a no-op — cache context is never activated during tests.
Manual usage without container:
from aod.application.cache import CacheManager
cache = RedisCache(keys=[UserById()])
with CacheManager(cache):
result = use_case.run(user_id=1) # cache context active inside this block
Warning:
AsyncCacheinstances are silently ignored in syncUseCase/Projection— cache reads returnNoneand writes are skipped. UseCache(sync) with sync operations andAsyncCacheonly withAsyncUseCase/AsyncReadProjection/AsyncWriteProjection.
Session Caching¶
Once a session is instantiated via get_session(), the same instance is returned on subsequent calls. This ensures all handlers and projections share the same session within a request.
Multi-Session Support¶
The container supports both sync and async sessions simultaneously. The UseCase's internal Transaction automatically detects session types and handles transactions appropriately.
Auto-Wiring Logic¶
Use Case Wiring¶
When adapting a UseCase or AsyncUseCase:
| Field | Source |
|---|---|
CommandPort[C] / QueryPort[Q] |
container.get_handler(contract_type) |
| Custom ports | Named ports or ports dict (see Port Resolution Order) |
The UseCase's Transaction is created internally -- the container never injects it.
Projection Wiring¶
When adapting a ProjectionBase subclass:
| Field | Source |
|---|---|
| Session fields | container.get_session(session_type) for each field with a Session/AsyncSession type annotation |
| Custom ports | Named ports or ports dict (see Port Resolution Order) |
Projections do not have a default session field. Each session must be declared as a concrete type annotation (e.g., session: PostgresSession). Multiple session fields are supported.
Override Support¶
container = AdapterContainer(sessions={MySession}, handlers=[MyHandler])
use_case = container.adapt(
MyUseCase,
logger=SpyLogger(),
)
When **overrides are provided:
container.with_adapters(**overrides)creates a temporary container with overridden fields.- Injection proceeds using the overridden container.
Async Use Case Injection¶
Async use cases are wired identically to sync use cases:
from aod.application.async_ import UseCase
class MyAsyncUseCase(UseCase):
...
use_case = container.adapt(MyAsyncUseCase)
The UseCase internally creates an AsyncTransaction when async handlers or sessions are present.
Common Patterns¶
Base Container (No Subclassing)¶
container = AdapterContainer(
sessions={MySession},
handlers=[MyHandler],
caches=[RedisCache(keys=[UserById()])],
ports={Logger: SpyLogger()},
user_client=MyUserClient(),
)
use_case = container.adapt(CreateUser)
use_case.run(user_id=42, name="Alice")
Named Ports¶
container = AdapterContainer(
sessions={MySession},
handlers=[MyHandler],
logger=SpyLogger(),
)
use_case = container.adapt(CreateUser)
Testing with Spy Container¶
from aod.testing.doubles import spy_adapter_container
container = spy_adapter_container(AdapterContainer(sessions={MySession}, handlers=[CreateUserHandler]))
container.get_handler_stub(CreateUserHandler).handle.return_value = None
container.stub_use_case(CreateUserUseCase, returns=None)
use_case = container.adapt(CreateUserUseCase)
use_case.run(user_id=42, name="Alice")
assert container.get_handler(CreateUser).handle.called
assert container.get_handler_stub(CreateUserHandler).handle.call_count == 1
container.stub_projection(UserProjection, read_returns=[])
proj = container.adapt(UserProjection)
result = proj.read(model)
Projection Injection¶
class UserProjection(ReadProjection):
session: MySession
def read(self, model: ReadModel) -> list[User]:
return self.session.query("SELECT * FROM users")
container = AdapterContainer(sessions={MySession})
proj = container.adapt(UserProjection)
result = proj.read(ReadModel())