From 2329385801b4e77bf88900af6c985932939a1df8 Mon Sep 17 00:00:00 2001 From: Chris Gough Date: Wed, 7 Jan 2026 23:06:05 +1100 Subject: [PATCH 1/3] Add ADR 003: Workflow Orchestration via Handler Services Cherry-picked from super-branch (PR #56). Documents the Handler pattern for decoupled use case orchestration: - Handlers have domain interfaces (accept domain objects, not requests) - Use cases hand off to handlers without knowing what happens next - Acknowledgement semantics (wilco/roger) - Fine-grained vs coarse-grained handlers Closes #62 --- .../003-workflow-orchestration-handlers.md | 371 ++++++++++++++++++ docs/ADRs/index.md | 1 + 2 files changed, 372 insertions(+) create mode 100644 docs/ADRs/003-workflow-orchestration-handlers.md diff --git a/docs/ADRs/003-workflow-orchestration-handlers.md b/docs/ADRs/003-workflow-orchestration-handlers.md new file mode 100644 index 00000000..50156cb0 --- /dev/null +++ b/docs/ADRs/003-workflow-orchestration-handlers.md @@ -0,0 +1,371 @@ +# ADR 003: Workflow Orchestration via Handler Services + +## Status + +Draft + +## Date + +2025-12-28 + +## Context + +Use cases in the Julee framework currently have a `next_action()` method pattern that suggests follow-up operations after a use case completes. For example, after creating a story without an epic, the use case might suggest "assign to epic" as a next action. + +This approach has problems: + +1. **Mixed concerns**: Use cases know too much about workflow orchestration - which other use cases exist, how to construct their requests, and the business rules for when they're appropriate. + +2. **Wrong interface**: The `next_action()` pattern returns request/response objects (use case DTOs), but workflow decisions should be expressed in terms of domain objects. + +3. **Inverted responsibility**: Services translate between domain objects and use case requests. Use cases translate requests into domain operations. If "what comes next" is a domain-level decision, it belongs where domain context is understood. + +The current pattern: + +```python +class CreateStoryUseCase: + def next_actions(self, response) -> list[SuggestedRequest]: + # Use case knows about other use cases and their requests + if not response.story.epic_slug: + return [AssignToEpicRequest(story_slug=response.story.slug)] + return [] +``` + +This couples the use case to knowledge it shouldn't have. + +## Decision + +Use cases SHALL hand off domain conditions to **handler services** rather than computing next actions themselves. + +A handler is a service with a **domain interface** - it accepts domain objects, not requests. What the handler does internally (call other use cases, queue work, send notifications, dispatch to Temporal) is the handler's business. + +### The Pattern + +```python +class CreateStoryUseCase: + def __init__( + self, + repo: StoryRepository, + orphan_story_handler: OrphanStoryHandler | None = None, # optional for gradual adoption + ): + self.repo = repo + self._orphan_story_handler = orphan_story_handler + + async def execute(self, request: CreateStoryRequest) -> CreateStoryResponse: + story = Story(...) + await self.repo.save(story) + + # Recognize domain condition, hand off to handler if configured + if not story.epic_slug and self._orphan_story_handler is not None: + await self._orphan_story_handler.handle(story) + + return CreateStoryResponse(story=story) +``` + +The use case's responsibility is: +1. Do its job (create the story) +2. Recognize domain conditions ("this story has no epic") +3. Hand off to the appropriate handler (if configured) +4. Done + +The handler's responsibility is: +- Accept domain objects (or domain-relevant arguments) +- Do whatever it needs to do +- Return acknowledgement + +### Principles + +#### 1. Handlers Are Services + +Handlers follow the same pattern as repositories - they have a domain-typed interface and are injected via dependency injection at construction time. + +```python +class OrphanStoryHandler(Protocol): + """Handler for stories created without an epic assignment.""" + + async def handle(self, story: Story) -> Acknowledgement: + """Handle an orphan story. Returns acknowledgement.""" + ... +``` + +The use case declares its handler dependencies. The DI container wires them up at composition time. + +#### 2. Optional Handlers Enable Gradual Adoption + +Handlers MAY be optional (`Handler | None = None`) to enable: +- Use cases that work standalone without orchestration +- Gradual migration from `next_action()` patterns +- Testing without handler configuration + +```python +def __init__( + self, + repo: StoryRepository, + orphan_handler: OrphanStoryHandler | None = None, # optional +): + self._orphan_handler = orphan_handler + +async def execute(self, request): + ... + if condition and self._orphan_handler is not None: + await self._orphan_handler.handle(entity) +``` + +When orchestration is required (not optional), omit the `| None`. + +#### 3. Acknowledgement Semantics + +Handlers return `Acknowledgement` using radio communication semantics: + +- **Wilco** ("will comply"): Handler accepts and will process +- **Roger** ("received"): Handler received but won't comply (with reason) + +```python +class Acknowledgement(BaseModel): + will_comply: bool = True + errors: list[str] = [] + warnings: list[str] = [] + info: list[str] = [] + debug: list[str] = [] + + @classmethod + def wilco(cls, **messages) -> Acknowledgement: + """Will comply - handler accepts the handoff.""" + return cls(will_comply=True, **messages) + + @classmethod + def roger(cls, reason: str, **messages) -> Acknowledgement: + """Received but won't comply - with explanation.""" + return cls(will_comply=False, errors=[reason, *messages.get("errors", [])]) +``` + +Usage: +```python +# Handler accepts +return Acknowledgement.wilco() + +# Handler accepts with warnings +return Acknowledgement.wilco(warnings=["Deprecated field used"]) + +# Handler declines with reason +return Acknowledgement.roger("Queue full, try again later") +``` + +What happens after acknowledgement is the handler's business. It might: +- Process immediately +- Queue for later processing +- Dispatch to Temporal +- Send to a message broker +- Do nothing (null handler for testing) + +The use case doesn't know or care. + +#### 4. Handler Signatures Vary + +Handler protocols are not limited to single-entity signatures. The signature should match the domain context: + +```python +# Single entity +class OrphanStoryHandler(Protocol): + async def handle(self, story: Story) -> Acknowledgement: ... + +# Entity plus context +class UnknownPersonaHandler(Protocol): + async def handle(self, story: Story, persona_name: str) -> Acknowledgement: ... + +# Cross-BC with primitives (no shared domain types) +class NewDataHandler(Protocol): + async def handle( + self, + endpoint_id: str, + content: bytes, + content_hash: str, + ) -> Acknowledgement: ... +``` + +Cross-BC handlers use primitives because bounded contexts don't share domain types. + +#### 5. Fine-Grained vs Coarse-Grained Handlers + +Handlers come in two architectural patterns: + +**Fine-grained handlers** have no internal use case. They interact directly with technology (logging, notifications, queues) without business logic: + +```python +class LoggingOrphanStoryHandler: + """Fine-grained: no internal use case, direct technology interaction.""" + + async def handle(self, story: Story) -> Acknowledgement: + logger.warning("Orphan story", extra={"slug": story.slug}) + return Acknowledgement.wilco(warnings=["Story not in any epic"]) +``` + +**Coarse-grained handlers** wrap exactly ONE internal use case. They translate domain objects to requests, execute business logic via the use case, and process the response: + +```python +class StoryOrchestrationHandler: + """Coarse-grained: wraps one internal use case.""" + + def __init__( + self, + orchestration_use_case: StoryOrchestrationUseCase, + orphan_handler: OrphanStoryHandler, # fine-grained delegate + ): + self._use_case = orchestration_use_case + self._orphan_handler = orphan_handler + + async def handle(self, story: Story) -> Acknowledgement: + # Translate domain object to request + request = StoryOrchestrationRequest(story=story) + + # Execute internal use case (contains business logic) + response = await self._use_case.execute(request) + + # Process response - delegate to fine-grained handlers + for condition in response.conditions: + if condition.type == "orphan": + await self._orphan_handler.handle(story) + + return Acknowledgement.wilco() +``` + +The internal use case contains the business logic (checking conditions, validating state). The handler is a thin translation layer that coordinates the use case with fine-grained delegates. + +Which to use is a domain modelling decision: +- Simple actions (log, notify) → fine-grained handler +- Complex orchestration with business logic → coarse-grained handler with internal use case + +#### 6. Cross-BC Coordination Is Composition + +When work in one bounded context should trigger work in another (e.g., Polling detects new data that should trigger CEAP document capture), this is wired at composition time by the solution provider. + +The Polling module doesn't know CEAP exists. It's injected with a handler: + +```python +class NewDataDetectionUseCase: + def __init__( + self, + poller_service: PollerService, + new_data_handler: NewDataHandler | None = None, # provided by solution + ): + ... +``` + +The solution provider creates a handler implementation that calls CEAP: + +```python +class CeapDocumentCaptureHandler(NewDataHandler): + """Solution-specific handler that bridges Polling to CEAP.""" + + def __init__(self, capture_use_case: CaptureDocumentUseCase): + self.capture_use_case = capture_use_case + + async def handle(self, endpoint_id, content, content_hash) -> Acknowledgement: + request = CaptureDocumentRequest(...) + await self.capture_use_case.execute(request) + return Acknowledgement.wilco() +``` + +Cross-BC coordination is explicit and visible in the solution's composition root. + +#### 7. Use Case Responsibility Is Limited + +The use case knows: +- "If the egg has a green dot, I give it to the green-dotted-egg-handler" +- "My job is done after the handoff" + +The use case does NOT know: +- What the handler does with the egg +- Which other use cases might be involved +- How to construct requests for those use cases +- The business rules for complex workflows + +This is the "green-dotted-egg-handler" principle. + +### Directory Conventions + +Handler protocols and implementations follow consistent placement: + +``` +{bounded_context}/ +├── services/ +│ └── {entity}_handlers.py # Handler protocols (e.g., story_handlers.py) +└── infrastructure/ + └── handlers/ + ├── __init__.py + └── null_handlers.py # Null implementations for testing +``` + +Example from HCD: +``` +hcd/ +├── services/ +│ └── story_handlers.py # OrphanStoryHandler, UnknownPersonaHandler protocols +└── infrastructure/ + └── handlers/ + └── null_handlers.py # NullOrphanStoryHandler, etc. +``` + +## Consequences + +### Positive + +1. **Single responsibility**: Use cases do one thing - their primary job plus condition recognition +2. **Domain-level interfaces**: Handlers speak domain language, not use case DTOs +3. **Testable in isolation**: Use cases can be tested with null handlers +4. **Explicit composition**: Cross-BC workflows are visible in the composition root +5. **Flexible orchestration**: Handlers can implement any pattern (immediate, queued, Temporal, etc.) +6. **Reusable modules**: Contrib modules like Polling don't need to know about specific solutions +7. **Gradual adoption**: Optional handlers allow incremental migration + +### Negative + +1. **More interfaces**: Each orchestration point needs a handler protocol +2. **Migration effort**: Existing `next_action()` patterns need refactoring +3. **Composition complexity**: Solution providers must wire up handlers + +### Neutral + +1. **Handler implementations vary**: Some handlers are trivial, others complex - this is expected + +## Alternatives Considered + +### 1. Keep next_action() Pattern + +Continue having use cases return suggested next actions. + +**Rejected**: Mixes concerns. Use cases shouldn't know about other use cases or how to construct their requests. + +### 2. Event-Based Orchestration + +Use cases emit domain events, subscribers react. + +**Rejected for now**: Adds infrastructure complexity (event bus). The handler pattern achieves the same decoupling with simpler mechanics. Events can be introduced later if needed. + +### 3. Orchestration Service Layer + +A dedicated orchestration layer that wraps use cases and decides what comes next. + +**Rejected**: Creates a parallel hierarchy. The handler pattern achieves orchestration without a separate layer - handlers ARE the orchestration, injected where needed. + +### 4. Saga Pattern + +Implement distributed sagas for cross-BC workflows. + +**Rejected for now**: Overkill for current needs. Handlers can implement saga-like patterns internally if needed. The framework doesn't need to mandate saga infrastructure. + +## Implementation + +Reference implementation exists in: + +- `julee/core/entities/acknowledgement.py` - Acknowledgement entity +- `julee/core/services/handler.py` - Generic Handler protocol and documentation +- `julee/hcd/services/story_handlers.py` - HCD handler protocols +- `julee/hcd/infrastructure/handlers/null_handlers.py` - Null implementations +- `julee/contrib/polling/services/new_data_handler.py` - Cross-BC handler protocol +- `julee/contrib/polling/use_cases/new_data_detection.py` - Use case with optional handler + +## References + +- [ADR 001: Contrib Module Layout](./001-contrib-layout.md) +- Analysis of use cases across core, HCD, CEAP, and Polling bounded contexts diff --git a/docs/ADRs/index.md b/docs/ADRs/index.md index 7e394388..ca2e1e1f 100644 --- a/docs/ADRs/index.md +++ b/docs/ADRs/index.md @@ -12,6 +12,7 @@ An ADR is a document that captures an important architectural decision made alon |----|-------|--------|------| | [001](001-contrib-layout.md) | Contrib Module Layout | Draft | 2025-12-09 | | [002](002-doctrine-test-architecture.md) | Doctrine Test Architecture | Draft | 2025-12-24 | +| [003](003-workflow-orchestration-handlers.md) | Workflow Orchestration via Handler Services | Draft | 2025-12-28 | | [007](007-semantic-relations.md) | Semantic Relations Decorator Pattern | Draft | 2026-01-07 | | [008](008-generic-crud-use-cases.md) | Generic CRUD Use Case Generators | Draft | 2026-01-07 | | [009](009-repository-service-distinction.md) | Repository vs Service Protocol Distinction | Draft | 2026-01-07 | From f56d7fe7fe1c9a14598f46bdb13234d4ac107d04 Mon Sep 17 00:00:00 2001 From: Chris Gough Date: Thu, 8 Jan 2026 09:29:01 +1100 Subject: [PATCH 2/3] Clarify handler protocol placement rule for cross-BC entities Handler protocols live with the entity's BC, not the use case's BC. This keeps the dependency graph clean since the use case BC already depends on the entity BC. --- docs/ADRs/003-workflow-orchestration-handlers.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/ADRs/003-workflow-orchestration-handlers.md b/docs/ADRs/003-workflow-orchestration-handlers.md index 50156cb0..2eaf6bdc 100644 --- a/docs/ADRs/003-workflow-orchestration-handlers.md +++ b/docs/ADRs/003-workflow-orchestration-handlers.md @@ -235,7 +235,18 @@ Which to use is a domain modelling decision: - Simple actions (log, notify) → fine-grained handler - Complex orchestration with business logic → coarse-grained handler with internal use case -#### 6. Cross-BC Coordination Is Composition +#### 6. Handler Protocol Placement: With the Entity + +When a handler protocol accepts an entity defined in a different BC than the use case that hands off to it, the protocol lives **with the entity's BC**, not the use case's BC. + +For example, if `CreateStoryUseCase` lives in BC-X but `Story` is defined in BC-Y, `OrphanStoryHandler` lives in BC-Y (with `Story`). + +Rationale: +- Handler signatures like `handle(story: Story)` are semantically bound to `Story` +- Multiple use cases may hand off to the same handler (e.g., `CreateStory` and `ImportStory` both produce orphan stories) +- The dependency graph stays clean: the use case BC already depends on the entity BC, so no new edges are introduced + +#### 7. Cross-BC Coordination Is Composition When work in one bounded context should trigger work in another (e.g., Polling detects new data that should trigger CEAP document capture), this is wired at composition time by the solution provider. @@ -268,7 +279,7 @@ class CeapDocumentCaptureHandler(NewDataHandler): Cross-BC coordination is explicit and visible in the solution's composition root. -#### 7. Use Case Responsibility Is Limited +#### 8. Use Case Responsibility Is Limited The use case knows: - "If the egg has a green dot, I give it to the green-dotted-egg-handler" From efc84207825d28685343c02651d02a9b02852b7c Mon Sep 17 00:00:00 2001 From: Chris Gough Date: Thu, 8 Jan 2026 09:52:36 +1100 Subject: [PATCH 3/3] Add HandlerDispatcher pattern for coarse-grained handlers Use factories instead of instances to solve circular dependencies and bootstrapping order constraints. Lazy instantiation at handle() time eliminates DI ordering complexity. --- .../003-workflow-orchestration-handlers.md | 67 +++++++++++++------ 1 file changed, 47 insertions(+), 20 deletions(-) diff --git a/docs/ADRs/003-workflow-orchestration-handlers.md b/docs/ADRs/003-workflow-orchestration-handlers.md index 2eaf6bdc..49fdff27 100644 --- a/docs/ADRs/003-workflow-orchestration-handlers.md +++ b/docs/ADRs/003-workflow-orchestration-handlers.md @@ -200,40 +200,67 @@ class LoggingOrphanStoryHandler: return Acknowledgement.wilco(warnings=["Story not in any epic"]) ``` -**Coarse-grained handlers** wrap exactly ONE internal use case. They translate domain objects to requests, execute business logic via the use case, and process the response: +**Coarse-grained handlers** trigger use cases. To avoid circular dependencies (handlers need use cases, use cases need handlers), use a **HandlerDispatcher** pattern with factories: ```python -class StoryOrchestrationHandler: - """Coarse-grained: wraps one internal use case.""" +# Core infrastructure - generic OrchestrationHandler +class OrchestrationHandler: + """Routes handler invocations to use cases via factories.""" def __init__( self, - orchestration_use_case: StoryOrchestrationUseCase, - orphan_handler: OrphanStoryHandler, # fine-grained delegate + routes: list[tuple[ + Callable[[], UseCase], # use case factory (lazy) + Callable[..., Request], # request builder + ]], ): - self._use_case = orchestration_use_case - self._orphan_handler = orphan_handler + self._routes = routes - async def handle(self, story: Story) -> Acknowledgement: - # Translate domain object to request - request = StoryOrchestrationRequest(story=story) - - # Execute internal use case (contains business logic) - response = await self._use_case.execute(request) + async def handle(self, *args, **kwargs) -> Acknowledgement: + for get_use_case, build_request in self._routes: + use_case = get_use_case() # lazy instantiation + request = build_request(*args, **kwargs) + await use_case.execute(request) + return Acknowledgement.wilco() +``` - # Process response - delegate to fine-grained handlers - for condition in response.conditions: - if condition.type == "orphan": - await self._orphan_handler.handle(story) +The composition root wires factories, not instances: - return Acknowledgement.wilco() +```python +# dependencies.py - composition root +def get_handler_dispatcher() -> HandlerDispatcher: + dispatcher = HandlerDispatcher() + + # Fine-grained handler (no use case dependency) + dispatcher.register(OrphanStoryHandler, get_logging_orphan_handler()) + + # Coarse-grained handler (routes to use cases via factories) + dispatcher.register( + OrphanStoryHandler, + OrchestrationHandler( + routes=[ + (get_assign_to_epic_use_case, lambda story: AssignToEpicRequest(story_slug=story.slug)), + (get_notify_team_use_case, lambda story: NotifyRequest(message=f"Orphan: {story.slug}")), + ] + ), + ) + return dispatcher + +# Use case receives proxy from dispatcher +def get_create_story_use_case() -> CreateStoryUseCase: + return CreateStoryUseCase( + get_story_repository(), + post_create_handler=get_handler_dispatcher().proxy_for(OrphanStoryHandler), + ) ``` -The internal use case contains the business logic (checking conditions, validating state). The handler is a thin translation layer that coordinates the use case with fine-grained delegates. +This solves two problems: +1. **No circular dependencies** — factories are callables, not instances +2. **Lazy instantiation** — use cases are created at `handle()` time, eliminating bootstrapping order constraints Which to use is a domain modelling decision: - Simple actions (log, notify) → fine-grained handler -- Complex orchestration with business logic → coarse-grained handler with internal use case +- Orchestration that triggers use cases → coarse-grained handler via dispatcher #### 6. Handler Protocol Placement: With the Entity