Dependency Injection In-Depth
Dependency Injection (DI) is a core design pattern used extensively within Castlecraft Architect and its underlying library, castlecraft-engineer
. While Python's dynamic nature doesn't enforce DI as strictly as some other languages, adopting DI offers significant benefits, especially when building applications following Domain-Driven Design (DDD) principles:
- Loose Coupling: Components receive their dependencies rather than creating them, reducing direct coupling.
- Testability: Dependencies can be easily mocked or stubbed during unit testing.
- Modularity & Maintainability: Changes to one component are less likely to ripple through the system.
- Flexibility & Extensibility: Easier to swap implementations or add new functionalities.
- Lifecycle Management: DI containers can manage the lifecycle of objects (e.g., singletons, transient instances).
Architect uses the punq
library for its DI container implementation, facilitated by castlecraft-engineer
.
castlecraft-engineer
's DI Foundation
The castlecraft-engineer
library provides the ContainerBuilder
class (castlecraft_engineer.common.di.ContainerBuilder
) as the primary tool for configuring the punq
DI container.
ContainerBuilder
The ContainerBuilder
offers a fluent interface to register components and services.
Core Registration (register
):
The fundamental method builder.register(type_or_name, **kwargs)
allows direct registration with punq
. Key kwargs
include:
instance
: A pre-existing object.factory
: A callable that creates an instance.scope
: Lifecycle (e.g.,punq.Scope.singleton
,punq.Scope.transient
).
Helper Methods (with_*
):
ContainerBuilder
provides convenient with_*
methods to register common infrastructure:
with_database()
: Registers synchronous SQLAlchemy components.with_async_database()
: Registers asynchronous SQLAlchemy components.with_cache(is_async=False)
: Registers Redis cache clients (sync or async).with_authentication()
: RegistersAuthenticationService
, attempting to use registered cache clients.with_command_bus()
,with_query_bus()
,with_event_bus()
: Registers respective buses as singletons, initialized with the DI container to resolve handlers.with_authorization()
: Sets upAuthorizationService
based on environment or pre-existing registrations.
Building the Container (build
):
After registrations, builder.build()
returns the configured punq.Container
.
create_injector
castlecraft-engineer
also provides create_injector(container: punq.Container)
. This utility returns a decorator factory (inject
) that can automatically inject dependencies into function/method keyword arguments based on type annotations.
Dependency Injection in Castlecraft Architect
Castlecraft Architect builds upon this foundation to manage dependencies for both its CLI and its FastAPI application.
Container Initialization
1. CLI Container (app.cli.deps
):
- The
get_initialized_container
function inapp.cli.deps
is responsible for creating and configuring the DI container for CLI operations. - It processes global CLI options (like
--env-file
,--components-base-path
,--log-level
) to correctly configure theSettings
instance before the container is built. - This
Settings
instance is then registered as a singleton. - Key Architect-specific services are registered:
ComponentLocatorService
,ComponentRegistryService
,ComponentHandlerProvider
(for code generation and component management).CurrentStateManagerService
,ComponentManagementService
,ToolContextService
,RevisionManagementService
(core domain services for Architect's functionality).AuthorizationEngineAdapterRegistryService
(for managing authorization engine adapters).PluginManager
(for discovering and loading plugins).
- The
PluginManager
loads plugins after the initial container setup. - The
provide(DependencyType)
function is used by CLI commands to resolve dependencies from this container.
2. API Container (app.api.deps
and app.di
):
- For the FastAPI application, the DI container is initialized during the application's lifespan event.
- The
create_container
function inapp.di
is responsible for this. It:- Creates a
ContainerBuilder
. - Registers
Settings
(usingget_settings()
which respects environment variables). - Uses
with_*
methods fromcastlecraft-engineer
to set up database, cache, command/query/event buses, and authentication. - Layered Dependency Registration: It then systematically discovers and registers dependencies from different layers of the application by looking for
di.py
files containing aregister_dependencies(builder: ContainerBuilder)
function:- Infrastructure Layer:
app.infrastructure.di
- Domain Shared Kernel:
app.domain.shared_kernel.di
- Bounded Contexts (Domain & Application Layers): It discovers directories within
app/domain/
andapp/application/
that represent bounded contexts and contain adi.py
file. It callsregister_dependencies
from these files. This ensures that each bounded context can define and register its own specific services, repositories, and handlers.
- Infrastructure Layer:
- Registers
AuthorizationService
(typically after infrastructure and domain layers, as implementations might reside there). - Registers
AuthorizationEngineAdapterRegistryService
andPluginManager
. - Builds the container.
- Loads plugins via the
PluginManager
.
- Creates a
- The fully configured container is stored in
app.state.di_ctr
. - FastAPI dependencies like
Depends(provide(ClassName))
use this container to resolve services for API endpoints. Theprovide
object (an instance ofDI
) inapp.api.deps
facilitates this.
provide
Helper
Both the CLI and API use a provide
helper (though implemented slightly differently in each context) to resolve dependencies.
- CLI (
app.cli.deps.provide
): A simple function that resolves from the global CLI container. - API (
app.api.deps.provide
): A callable classDI
that, when used withDepends
, resolves dependencies fromrequest.app.state.di_ctr
.
# Example of API's provide usage
from castlecraft_architect.api.deps import provide
from fastapi import Depends
# async def my_endpoint(my_service: MyService = Depends(provide(MyService))):
# # my_service is resolved from the request-scoped container
# pass
Key Architect Services Registered
Architect ensures several of its core services are registered and available for injection:
- Settings: Application configuration.
- Component Services:
ComponentLocatorService
,ComponentRegistryService
,ComponentHandlerProvider
for managing and interacting with defined architectural components. - State & Revision Management:
CurrentStateManagerService
,RevisionManagementService
for handling the project's state and revision history. - Tool Context:
ToolContextService
for providing context about the Architect tool itself. - Plugin System:
PluginManager
for extending Architect's functionality. - Authorization Adapters:
AuthorizationEngineAdapterRegistryService
for managing different authorization engine integrations. - Command/Query Handlers: Handlers for various operations (e.g.,
CreateRevisionDraftCommandHandler
) are registered with their respective buses.
Example: CreateRevisionDraftCommandHandler
The CreateRevisionDraftCommandHandler
illustrates how dependencies are injected:
# Excerpt from CreateRevisionDraftCommandHandler
class CreateRevisionDraftCommandHandler(CommandHandler[CreateRevisionDraftCommand]):
def __init__(
self,
revision_draft_repository: RevisionDraftAggregateDomainRepository,
event_bus: EventBus,
auth_service: AuthorizationService,
settings: Settings,
):
self._repository = revision_draft_repository
# ... and so on
When the CommandBus
needs to execute CreateRevisionDraftCommand
, it resolves CreateRevisionDraftCommandHandler
from the DI container. The container, in turn, resolves RevisionDraftAggregateDomainRepository
, EventBus
, AuthorizationService
, and Settings
to instantiate the handler.
Best Practices and Considerations
- Dependency Inversion: Register interfaces (Abstract Base Classes) and their concrete implementations to adhere to the Dependency Inversion Principle.
- Scoped Dependencies: For the API, the container is typically available per request, allowing for request-scoped dependencies if needed, although many core services are singletons. The
AsyncSession
for database operations is a good example of a request-scoped dependency managed byapp.api.deps.get_async_session
. - Configuration Order: The
create_container
function inapp.di
shows a deliberate order of registration (infrastructure, shared kernel, bounded contexts) to ensure that foundational services are available when domain or application-specific services are registered. - Troubleshooting: If you encounter
punq.MissingDependencyError
, it usually means a service was not registered, registered with a different name/type, or there's an issue in the registration order. Check the relevantdi.py
files and the output logs during container initialization.
By understanding this DI setup, developers can effectively extend Architect, add new services, and write testable, loosely coupled components.
It's also worth noting that Castlecraft Architect itself is built using the castlecraft-engineer
library and adheres to the foundational principles that Architect enforces on the applications it scaffolds. Therefore, to see how Dependency Injection, command/query/event buses, handlers, and component management are implemented in action, developers can always refer to Architect's own codebase as a practical example.