Dependency Injection in Castlecraft Engineer¶
castlecraft-engineer
utilizes the punq
library for dependency injection (DI). DI is a design pattern that allows for loose coupling between components by providing dependencies to objects rather than having them create dependencies themselves. This promotes modularity, testability, and maintainability.
The primary tool for configuring the DI container is the ContainerBuilder
class found in castlecraft_engineer.common.di
.
ContainerBuilder
¶
The ContainerBuilder
provides a fluent interface to progressively register components and services with the punq.Container
.
Initialization¶
When aContainerBuilder
is initialized, it creates an internal punq.Container
and registers the container itself, making it available for injection if needed.
Core Registration Methods¶
register(self, type_or_name: Any, **kwargs) -> "ContainerBuilder"
¶
This is the fundamental method for registering components directly with the underlying punq.Container
.
* type_or_name
: The type (e.g., a class or an abstract base class) or a string name that will be used to request the dependency.
* **kwargs
: Keyword arguments passed directly to punq.Container.register()
. Common punq
arguments include:
* instance
: A pre-existing object to be returned when type_or_name
is resolved.
* factory
: A callable (function or class) that creates an instance of type_or_name
. If factory
is a class, punq
will instantiate it. If it's a function, punq
will call it.
* scope
: Defines the lifecycle of the registered component (e.g., punq.Scope.singleton
, punq.Scope.transient
).
* name
: A string name to register the component under, allowing multiple registrations for the same type.
If only type_or_name
is provided and it's a concrete class, punq
will attempt to instantiate it, resolving its constructor dependencies automatically.
Example:
class MyService:
pass
class MyServiceImpl(MyService):
pass
builder.register(MyService, instance=MyServiceImpl()) # Register a specific instance
builder.register(MyServiceImpl) # Register MyServiceImpl as itself (transient by default)
Helper with_*
Methods¶
The ContainerBuilder
provides several with_*
methods to simplify the registration of common infrastructure components.
with_database(self) -> "ContainerBuilder"
¶
Registers synchronous database components:
* sqlalchemy.Engine
(named "db_sync_engine"
): The synchronous SQLAlchemy engine instance, created via get_engine()
.
* sqlalchemy.orm.sessionmaker[Session]
(named "db_sync_session_factory"
): The SyncSessionFactory
instance.
* sqlalchemy.orm.Session
: A factory for creating new synchronous sessions (lambda: SyncSessionFactory()
).
This method ensures that synchronous database access is configured and ready for use.
with_async_database(self) -> "ContainerBuilder"
¶
Registers asynchronous database components:
* sqlalchemy.ext.asyncio.AsyncEngine
(named "db_async_engine"
): The asynchronous SQLAlchemy engine instance, created via get_async_engine()
.
* sqlalchemy.ext.asyncio.async_sessionmaker[AsyncSession]
(named "db_async_session_factory"
): The AsyncSessionFactory
instance.
* sqlalchemy.ext.asyncio.AsyncSession
: A factory for creating new asynchronous sessions (lambda: AsyncSessionFactory()
).
This method sets up asynchronous database access.
with_cache(self, is_async: bool = False) -> "ContainerBuilder"
¶
Registers Redis cache connection components.
* is_async
(default False
):
* If False
: Registers a synchronous redis.Redis
client instance (named "cache_sync"
) obtained via get_redis_cache_connection()
.
* If True
: Registers a factory for an asynchronous redis.asyncio.Redis
client (named "cache_async"
, singleton scope) obtained via get_redis_cache_async_connection()
.
with_authentication(self) -> "ContainerBuilder"
¶
Registers the AuthenticationService
(castlecraft_engineer.application.auth.AuthenticationService
) as a singleton.
The factory for AuthenticationService
attempts to resolve registered cache clients ("cache_async"
first, then "cache_sync"
) to pass to the AuthenticationService
constructor. This allows the authentication service to utilize caching if cache components have been registered.
with_command_bus(self) -> "ContainerBuilder"
¶
Registers the CommandBus
(castlecraft_engineer.abstractions.command_bus.CommandBus
) as a singleton. The CommandBus
itself is initialized with the DI container to resolve command handlers.
The resolved CommandBus
instance is also made available as builder.command_bus
.
with_query_bus(self) -> "ContainerBuilder"
¶
Registers the QueryBus
(castlecraft_engineer.abstractions.query_bus.QueryBus
) as a singleton. Similar to the CommandBus
, it's initialized with the DI container to resolve query handlers.
The resolved QueryBus
instance is also made available as builder.query_bus
.
with_event_bus(self) -> "ContainerBuilder"
¶
Registers the EventBus
(castlecraft_engineer.abstractions.event_bus.EventBus
) as a singleton. It's initialized with the DI container, which can be used if the EventBus
implementation needs to resolve event handlers or other dependencies.
The resolved EventBus
instance is also made available as builder.event_bus
.
with_authorization(self) -> "ContainerBuilder"
¶
Sets up and registers authorization components using castlecraft_engineer.authorization.setup.setup_authorization(container)
. This typically registers an AuthorizationService
implementation based on environment variables or pre-existing registrations.
Note: with_authentication()
should generally be called before with_authorization()
if your authorization setup depends on authentication components being available.
Building the Container¶
build(self) -> punq.Container
¶
After all desired components have been registered, this method returns the fully configured punq.Container
instance.
container = builder.with_async_database().with_command_bus().build()
# Now you can resolve dependencies
from sqlalchemy.ext.asyncio import AsyncSession
from castlecraft_engineer.abstractions.command_bus import CommandBus
async_session = container.resolve(AsyncSession) # Gets a new session from the factory
command_bus_instance = container.resolve(CommandBus)
create_injector(container: punq.Container)
¶
The di.py
module also provides a create_injector
utility function. This function takes a punq.Container
and returns a decorator factory (inject
). The inject
decorator can be used to automatically inject dependencies into the keyword arguments of a function or method based on their type annotations.
How it Works¶
The inject
decorator:
1. Inspects the signature of the decorated function/method.
2. For each parameter that has a type annotation and was not explicitly passed by the caller:
a. It attempts to resolve an instance of that type from the provided container
.
b. If successful, the resolved dependency is added to the keyword arguments passed to the original function.
3. If a dependency cannot be resolved, an error is logged, but the original function is still called (it might handle the missing dependency or raise an error itself).
Example Usage¶
from castlecraft_engineer.common.di import ContainerBuilder, create_injector
from castlecraft_engineer.abstractions.command_bus import CommandBus
# 1. Configure and build the container
builder = ContainerBuilder()
builder.with_command_bus()
container = builder.build()
# 2. Create the injector decorator
inject = create_injector(container)
# 3. Define a function that needs dependencies
class MyCommand:
pass
@inject
async def handle_some_action(command: MyCommand, bus: CommandBus, explicit_arg: str = "default"):
print(f"Explicit arg: {explicit_arg}")
print(f"Injected CommandBus: {bus}")
# await bus.execute(command) # bus is now available
return f"Handled {command} with {bus}"
async def main():
# CommandBus will be injected automatically
await handle_some_action(MyCommand(), explicit_arg="passed_value")
# If bus was not registered, it would log an error and bus would be None (or raise AttributeError if accessed)
# For example, if CommandBus was not registered:
# await handle_some_action(MyCommand()) # Would log error for missing CommandBus
# import asyncio
# asyncio.run(main())
create_injector
provides a convenient way to perform DI for functions or methods without needing to manually resolve each dependency from the container within the function body. This is particularly useful for application entry points, route handlers in web frameworks, or any function that orchestrates services.
Best Practices¶
- Centralized Configuration: Configure your DI container at the application's entry point (e.g., in
main.py
or a dedicated startup module). - Register Interfaces: Prefer registering interfaces (abstract base classes) and their concrete implementations. This allows for easier swapping of implementations.
- Scope Management: Understand
punq
's scopes (transient
,singleton
) to manage the lifecycle of your services appropriately. - Avoid Service Locator Pattern: Inject dependencies directly into constructors or methods rather than resolving them from a globally accessible container within your components.
The
ContainerBuilder
andcreate_injector
aim to simplify these practices within thecastlecraft-engineer
ecosystem.