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 upAuthorizationServicebased 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_containerfunction inapp.cli.depsis 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 theSettingsinstance before the container is built. - This
Settingsinstance 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
PluginManagerloads 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_containerfunction inapp.diis responsible for this. It:- Creates a
ContainerBuilder. - Registers
Settings(usingget_settings()which respects environment variables). - Uses
with_*methods fromcastlecraft-engineerto 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.pyfiles 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.pyfile. It callsregister_dependenciesfrom 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
AuthorizationEngineAdapterRegistryServiceandPluginManager. - 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. Theprovideobject (an instance ofDI) inapp.api.depsfacilitates 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 classDIthat, 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,ComponentHandlerProviderfor managing and interacting with defined architectural components. - State & Revision Management:
CurrentStateManagerService,RevisionManagementServicefor handling the project's state and revision history. - Tool Context:
ToolContextServicefor providing context about the Architect tool itself. - Plugin System:
PluginManagerfor extending Architect's functionality. - Authorization Adapters:
AuthorizationEngineAdapterRegistryServicefor 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
AsyncSessionfor database operations is a good example of a request-scoped dependency managed byapp.api.deps.get_async_session. - Configuration Order: The
create_containerfunction inapp.dishows 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.pyfiles 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.