Component Composition
Building higher-level components from lower-level ones using direct injection
PSP follows an atomic design-inspired composition model for server-side components. Lower-level components can be composed into higher-level ones through direct injection—passing component instances as constructor parameters.
Levels of Composition
| Level | Name | Definition | Example |
|---|---|---|---|
| Atom | Use Case | Single operation with dependencies | CreateTask, ScheduleTask |
| Molecule | Component | Group of related use cases | TodosComponent, BudgetComponent |
| Organism | Composite | Wraps multiple components | ScheduledTodosComponent |
Direct Injection Pattern
Composite components receive lower-level components via constructor injection. They don't create their dependencies internally—they receive them from above.
class ScheduledTodosComponent: """Composite component that wraps Todos and Budget.""" def __init__( self, todos: TodosComponent, # Injected molecule budget: BudgetComponent, # Injected molecule clock: Clock, event_bus: EventBus, ) -> None: self._todos = todos self._budget = budget # Wire composite use cases that orchestrate both self.create_scheduled_task = CreateScheduledTask( create_task=todos.create_task, delete_task=todos.delete_task, schedule_task=budget.schedule_task, clock=clock, event_bus=event_bus, )
- Explicit dependencies — Lower-level components are constructor parameters
- No internal creation — Composite doesn't instantiate its dependencies
- Shared services — Clock and EventBus are shared for cross-component communication
- Use case delegation — Composite use cases call lower-level use cases
Shared Platform Services
For components to communicate, they must share platform services. When events published by one component need to be seen by another, both must use the same EventBus.
# Shared services enable cross-component communication clock = SystemClock() event_bus = InMemoryEventBus() # Both components share the same event bus todos = create_todos_component(clock=clock, event_bus=event_bus) budget = create_budget_component(clock=clock, event_bus=event_bus) # Composite wraps them scheduled = ScheduledTodosComponent(todos, budget, clock, event_bus)
Composite Use Case Structure
A composite use case orchestrates multiple lower-level use cases. It may also implement compensating transactions for error recovery.
class CreateScheduledTask: """Create task AND schedule budget atomically.""" def __init__( self, create_task: CreateTask, # From Todos delete_task: DeleteTask, # From Todos (for rollback) schedule_task: ScheduleTask, # From Budget clock: Clock, event_bus: EventBus, ) -> None: ... def execute(self, actor_id: UUID, input: Input) -> Output: # 1. Create task via Todos task = self._create_task.execute(...) try: # 2. Schedule budget via Budget scheduled = self._schedule_task.execute(...) except InsufficientBudgetError: # Compensating transaction self._delete_task.execute(...) raise # 3. Publish composite event self._event_bus.publish(ScheduledTaskCreated(...)) return Output(task=task, scheduled=scheduled)
Factory Functions
Each level provides a factory function with sensible defaults. The organism-level factory creates and wires the molecules automatically.
def create_scheduled_todos_component( storage: Literal["memory", "sqlite"] = "memory", clock: Clock | None = None, event_bus: EventBus | None = None, ) -> ScheduledTodosComponent: shared_clock = clock or SystemClock() shared_event_bus = event_bus or InMemoryEventBus() # Create molecules with shared services todos = create_todos_component(clock=shared_clock, event_bus=shared_event_bus) budget = create_budget_component(clock=shared_clock, event_bus=shared_event_bus) return ScheduledTodosComponent(todos, budget, shared_clock, shared_event_bus)
When to Create a Composite
Create a composite component when:
- Atomic operations span multiple components — e.g., create task AND allocate budget
- Business logic ties components together — e.g., completing a task should spend budget
- You want a unified API — simpler interface than calling both components separately
- Cross-component invariants exist — e.g., every scheduled task must have a todo
See src/psp/components/scheduled_todos/ for a complete example of composite component, composite use cases, composite events, factory function, and catalogue registration.
Directory Structure
A composite component follows the same structure as a molecule:
src/psp/components/scheduled_todos/ ├── __init__.py # Public API exports ├── component.py # Composition root ├── module.py # Catalogue registration ├── domain/ │ ├── events.py # Composite events │ └── errors.py # Composite errors └── application/ ├── dto.py # Input/Output objects └── use_cases/ ├── create_scheduled_task.py └── complete_scheduled_task.py