Skip to main content

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(): Registers AuthenticationService, 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 up AuthorizationService 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 in app.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 the Settings 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 in app.di is responsible for this. It:
    • Creates a ContainerBuilder.
    • Registers Settings (using get_settings() which respects environment variables).
    • Uses with_* methods from castlecraft-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 a register_dependencies(builder: ContainerBuilder) function:
      1. Infrastructure Layer: app.infrastructure.di
      2. Domain Shared Kernel: app.domain.shared_kernel.di
      3. Bounded Contexts (Domain & Application Layers): It discovers directories within app/domain/ and app/application/ that represent bounded contexts and contain a di.py file. It calls register_dependencies from these files. This ensures that each bounded context can define and register its own specific services, repositories, and handlers.
    • Registers AuthorizationService (typically after infrastructure and domain layers, as implementations might reside there).
    • Registers AuthorizationEngineAdapterRegistryService and PluginManager.
    • Builds the container.
    • Loads plugins via the PluginManager.
  • 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. The provide object (an instance of DI) in app.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 class DI that, when used with Depends, resolves dependencies from request.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 by app.api.deps.get_async_session.
  • Configuration Order: The create_container function in app.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 relevant di.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.