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, name: str = "default", db_config: Optional[Dict]) -> "ContainerBuilder"
¶
Registers synchronous database components for a named connection. This allows you to register multiple databases, such as a default write database and a read-replica.
name
: A unique name for the connection (e.g.,"default"
,"read_replica"
). Defaults to"default"
.db_config
: An optional dictionary to programmatically configure the connection, overriding environment variables. Keys can includeconnection_string
andenable_sql_log
.
When using the default name, you can resolve the session by its type (Session
or AsyncSession
). For named connections, you must resolve the session using its specific registration name (e.g., "db_sync_session_read_replica"
).
Example: Registering a default database and a read-replica
builder = ContainerBuilder()
# Register the default database using environment variables
builder.with_async_database(name="default")
# Register a read-replica with a specific connection string
read_replica_config = {
"async_connection_string": "postgresql+asyncpg://user:pass@read-replica-host/db"
}
builder.with_async_database(name="read_replica", db_config=read_replica_config)
container = builder.build()
# Resolve the default session by type
default_session = container.resolve(AsyncSession)
# Resolve the read-replica session by its generated name
read_session = container.resolve(AsyncSession, name="db_async_session_read_replica")
with_async_database(self, name: str = "default", db_config: Optional[Dict]) -> "ContainerBuilder"
¶
This is the asynchronous equivalent of with_database
. It registers AsyncEngine
and AsyncSession
components. The db_config
can include keys like async_connection_string
.
with_cache(self, is_async: bool = False, cache_config: Optional[Dict]) -> "ContainerBuilder"
¶
Registers Redis cache components.
is_async
: IfTrue
, registers an async client; otherwise, a sync client.cache_config
: An optional dictionary to programmatically configure the Redis connection, overriding environment variables.
with_authentication(self, auth_config: Optional[Dict]) -> "ContainerBuilder"
¶
Registers the AuthenticationService
as a singleton.
auth_config
: An optional dictionary to programmatically configure theAuthenticationService
, overriding environment variables.
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, engine_name: Optional[str] = None) -> "ContainerBuilder"
¶
Sets up and registers authorization components.
engine_name
: Optionally specify the authorization engine name (e.g.,"allow_all"
,"deny_all"
), overriding theENV_AUTHORIZATION_ENGINE
environment variable.
Note: with_authentication()
should generally be called before this method.
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.