Skip to content

Who can dispatch on the CQRS bus?

In a CQRS codebase, every component eventually faces the question: can I dispatch a command or a query from here? The answer is almost never obvious, and most discussions online either say "yes, always" or "never except controllers", without explaining the reasoning behind either position.

The problem

Without clear rules, the command bus becomes a service locator. Any component can inject it, dispatch anything, and the flow becomes impossible to trace. You end up in a codebase where a query handler triggers a command, a listener calls another listener through the bus, and understanding what happens when a user clicks a button requires reading five files across three modules.

The rule of thumb is not about the bus itself. It is about the nature of the components that use it.

The foundation: CQS

The boundary comes from Bertrand Meyer's Command Query Separation principle, formalized in "Object-Oriented Software Construction" (1988): an operation either modifies state or it reads state - never both. Greg Young extended this to the architectural level with CQRS, separating the write and read paths entirely.

Two consequences follow directly:

A query must never dispatch a command. Queries are idempotent by definition - you can call them repeatedly without consequence. Dispatching a command from a query breaks that guarantee. Technical side effects (logs, tracing, cache population) do not count here - they do not alter business state.

A command must never return data. Commands are fire-and-forget. If a caller needs the result of a write operation, they issue a query after the command completes.

These are not framework conventions. They are the load-bearing constraints of the pattern.

Who can dispatch

The most obvious dispatchers are the primary drivers: HTTP controllers, API endpoints, message consumers, scheduled jobs. These components receive an external intent and translate it into a command or a query. That is their only job.

In the demo app, BorrowBookController does both in a single action - dispatches a command on POST, dispatches a query to build the form view on GET or on validation failure:

// write path
$command = new BorrowBookCommand($loanId, $userId, $bookId, $loanPeriod);
$this->commandBus->dispatch($command);

// read path (build the form view)
$query = new BorrowBookQuery($formValues, $errors);
$queryResponse = $this->queryBus->dispatch($query);

The controller dispatches. It does not process.

Beyond primary drivers, Sagas and Process Managers can also dispatch commands. Their purpose is explicit orchestration of multi-step business workflows - dispatching is their core responsibility, not a side effect.

Event listeners are legitimate dispatchers

An event listener can dispatch a command, and this is actually the recommended pattern for chaining operations across aggregates. Vaughn Vernon documents it in "Implementing Domain-Driven Design" (2013): one aggregate publishes a domain event, a listener receives it and dispatches a command targeting a second aggregate.

In the demo, BorrowBookHandler publishes a LoanCreatedEvent after persisting the loan:

$this->eventBus->dispatch(
    new LoanCreatedEvent($this->idGenerator->generate(), $this->clock->now(), $loan)
);

LoanCreatedEventHandler in the Library module receives that event and dispatches a command to update the book status:

final readonly class LoanCreatedEventHandler implements ListenerInterface
{
    public function __construct(
        private CommandBusInterface $commandBus,
        private LoggerInterface $logger,
    ) {}

    public function __invoke(LoanCreatedEvent $event): void
    {
        $loan = $event->getLoan();
        $command = new UpdateBookStatusCommand($loan->getBookId(), BookStatus::Borrowed);
        $this->commandBus->dispatch($command);
    }
}

The Loan module knows nothing about the Library module. The coupling runs through the event, not through a direct dependency. Module isolation stays intact.

Handler dispatching another command

A command handler dispatching another command through the bus is a different case. It is not forbidden - frameworks like Symfony Messenger support it explicitly for orchestration - but it reduces traceability. The second dispatch is buried inside handler code rather than at the boundary of the system, which makes the overall flow harder to follow.

The more traceable pattern is to publish a domain event and let a listener react. Every dispatch then becomes visible at the boundary: controller dispatches, handler publishes, listener dispatches.

When a command handler needs to read data, the instinct is often to reach for the query bus. That instinct is usually misplaced - not because the query bus is forbidden there, but because a command handler works with aggregates, not with read models. The repository is the right abstraction. Exceptions exist (cross-module validation, reading from an external read model), but they are exceptions, not the default.

Trade-offs

Strict application of these rules means more components in the flow: handler publishes an event, listener receives it, listener dispatches a command. That is more files to navigate and more indirection to follow.

The trade-off is explicit boundaries: you can trace any operation module by module without reading implementation code. Relaxing the rules - handler dispatches directly, listener skips the event - is a valid context-specific decision. The cost is coupling that is harder to detect and flows that require reading internals to understand.

What this enables

When every dispatch comes from a well-defined entry point, the flow of any operation becomes traceable from the outside. You can read the route, find the command, find the handler, find the event, find the listener. No surprises buried in service methods.

In Phexium, CommandBusInterface and QueryBusInterface are two distinct plugins. CommandBusInterface::dispatch() returns void. QueryBusInterface::dispatch() returns a QueryResponseInterface. The type system encodes the constraint: you cannot accidentally return data from a command or trigger a side effect from a query at the interface level.

The bus enforces nothing about who calls it. The discipline lives in the architecture.