Skip to content

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 CommandHandlerInterface nor QueryHandlerInterface
  • 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:

  1. Phase 1: Orchestrator runs analysis steps, persists the result via a Command
  2. User reviews the analysis result (rendered by a Query + Presenter)
  3. 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