Process Orchestration
An Application Service Orchestrator coordinates sequential multi-step business processes where the result of one step feeds the next. The orchestrator lives in the Application layer and delegates each step to existing Commands and Queries via their buses, preserving CQRS boundaries while enabling complex workflows.
This pattern is an advanced usage of existing Phexium concepts (Command Bus, Query Bus, Transactions), not a framework-provided abstraction. The orchestrator is a plain PHP class with no special interface.
The Problem
When a business process requires multiple sequential steps, several intuitive approaches lead to anti-patterns:
| Approach | Why It Fails |
|---|---|
| Monolithic handler | Violates SRP; the handler accumulates dependencies and becomes untestable |
| Chained handlers | Hidden dependencies; CQRS anti-patterns identify "handlers calling handlers" as problematic |
| Cascading events | Implicit flow distributed across configuration and listeners; impossible to trace or debug the sequence |
| Controller orchestration | Business logic in the Presentation layer; violates Clean Architecture dependency rules |
The orchestrator pattern addresses all four issues: explicit flow, clear dependencies, traceable sequence, and correct layer placement.
Core Concept
The orchestrator receives bus interfaces via constructor injection and coordinates a sequence of steps. Each step goes through the bus, preserving middleware, transactions, and event dispatch.
final readonly class ImportBooksOrchestrator
{
public function __construct(
private QueryBusInterface $queryBus,
private CommandBusInterface $commandBus,
) {}
public function execute(ImportBooksRequest $request): ImportBooksResult
{
// Step 1: Analyze file structure
$analysis = $this->queryBus->dispatch(
new AnalyzeImportFileQuery($request->filePath)
);
// Step 2: Persist the detected schema
$this->commandBus->dispatch(
new PersistImportSchemaCommand($analysis->schema)
);
// Step 3: Import the data
$this->commandBus->dispatch(
new ImportBooksDataCommand($request->filePath, $analysis->schema)
);
return new ImportBooksResult($analysis->recordCount);
}
}
Key characteristics:
- The orchestrator is not a handler, it implements neither
CommandHandlerInterfacenorQueryHandlerInterface - Its
execute()method takes a domain-specific request DTO and returns a result DTO - Each
dispatch()goes through the bus, so middleware (logging, authorization) applies to every step - Each Command and Query remains independently testable via its own handler
When to Use
Decision Tree
Is this a single atomic operation?
├── YES → Single handler
└── NO → Are the steps independent (no data flows between them)?
├── YES → Domain events (each listener reacts independently)
└── NO → Are there 3+ sequential steps with data dependencies?
├── YES → Orchestrator
└── NO → Consider a single handler with a domain service
Comparison
| Criterion | Single Handler | Domain Events | Orchestrator |
|---|---|---|---|
| Number of steps | 1 | 1-N (independent) | 3+ (sequential) |
| Data flow between steps | N/A | None | Output of step N feeds step N+1 |
| Execution order guaranteed | N/A | No | Yes |
| Traceability | High (single method) | Low (distributed across listeners) | High (single method) |
| Transaction scope | Single command | Per-listener command | Configurable |
| Example use case | Create a book | Notify librarian after loan | Import books from file |
Orchestrator Placement
The orchestrator belongs to the Application layer, alongside Commands, Queries, and Handlers. The controller calls the orchestrator; the orchestrator calls the buses.
Presentation Controller
│
Application Orchestrator
┌──┼──────────────┐
│ │ │
▼ ▼ ▼
QueryBus CommandBus (×N)
│ │
Handler Handlers
│ │
Domain Entities Entities + Events
Placing orchestration in controllers violates Clean Architecture: the controller handles HTTP concerns, process coordination belongs in the Application layer.
Transaction Behavior
By default, each commandBus->dispatch() is wrapped in its own transaction by TransactionalCommandBus. Each step commits independently.
When all steps must succeed or fail together, the orchestrator can wrap the entire sequence in a single transaction:
public function execute(ImportBooksRequest $request): ImportBooksResult
{
$analysis = $this->queryBus->dispatch(
new AnalyzeImportFileQuery($request->filePath)
);
$this->transaction->begin();
try {
$this->commandBus->dispatch(new PersistImportSchemaCommand($analysis->schema));
$this->commandBus->dispatch(new ImportBooksDataCommand($request->filePath, $analysis->schema));
$this->transaction->commit();
} catch (Throwable $e) {
$this->transaction->rollback();
throw $e;
}
return new ImportBooksResult($analysis->recordCount);
}
When TransactionalCommandBus detects an active transaction, it dispatches without wrapping a new one, so nested transactions are handled correctly.
Interrupted Processes
Some processes span multiple HTTP requests, the user reviews intermediate results before proceeding.
In this variant, the orchestrator handles each phase separately. The intermediate state is persisted as a normal entity between phases:
- Phase 1: Orchestrator runs analysis steps, persists the result via a Command
- User reviews the analysis result (rendered by a Query + Presenter)
- Phase 2: Orchestrator resumes from persisted state, runs remaining steps
The orchestrator remains stateless; the persistence boundary between phases is a standard Command/Query cycle.
Testing
The orchestrator is tested with mocked buses. Each test verifies that the correct messages are dispatched in the expected order. Individual handlers are tested separately.
it('dispatches analysis then import commands in sequence', function (): void {
$queryBus = Mockery::mock(QueryBusInterface::class);
$commandBus = Mockery::mock(CommandBusInterface::class);
$queryBus->expects('dispatch')
->once()
->andReturn(new AnalyzeImportFileResponse(recordCount: 42, schema: $schema));
$commandBus->expects('dispatch')->twice();
$orchestrator = new ImportBooksOrchestrator($queryBus, $commandBus);
$result = $orchestrator->execute(new ImportBooksRequest('/path/to/file.csv'));
expect($result->recordCount)->toBe(42);
});
Best Practices
- Keep the orchestrator focused on coordination, business rules remain in domain entities and handlers
- Each step should be a standalone Command or Query that works independently of the orchestrator
- Avoid nested orchestrators (one orchestrator calling another); if the process grows that complex, reconsider the boundaries
- Name orchestrators after the process they coordinate, not after technical concerns
Naming Conventions
| Element | Pattern | Example |
|---|---|---|
| Orchestrator | {Process}Orchestrator | ImportBooksOrchestrator |
| Request DTO | {Process}Request | ImportBooksRequest |
| Result DTO | {Process}Result | ImportBooksResult |
See Also
- CQRS - Command and Query buses used by the orchestrator
- Event-Driven Architecture - Complementary pattern for independent side effects
- Commands & Handlers - Individual steps coordinated by the orchestrator
- Queries & Handlers - Read steps in the orchestration sequence
- Transaction - Transaction management across orchestrated steps
- Clean Architecture - Layer rules governing orchestrator placement