Skip to content

The third mode: when bus and direct coexist

Most architecture discussions treat the bus question as global. Either the whole application routes commands and queries through a CQRS bus, or none of it does. That framing forces a bad trade: pay CQRS boilerplate on a homepage that displays a greeting, or drop the bus on a module that genuinely needs events and transactions. The real unit of decision is not the application. It is the module.

The problem

A single application rarely has uniform complexity. One module writes books with validation, domain events, and permission checks. Another module renders a welcome message and the current date. Applying CQRS to both means the second module grows a command, a handler, and an event dispatch to produce three values that never change state. Applying neither means the first module loses the transaction boundary, the middleware hook for authorization, and the automatic event publication it depends on.

Picking one mode for the entire application optimizes for the wrong average. You end up either over-structuring the simple modules or under-structuring the complex ones.

The mechanism

Phexium supports two modes and lets each module pick independently. Bus Mode routes through a command bus and a query bus, where handlers manage transactions, publish domain events, and middleware intercepts for logging or authorization. Direct Mode calls a use case directly and returns a response, with no bus and no automatic events.

The Homepage module has no events, no business rules, and no multi-entity transaction. It stays in Direct Mode. The controller invokes the use case and presents the result:

final readonly class HomeController extends AbstractHttpController implements ControllerInterface
{
    public function __construct(
        private HomeHtmlPresenter $presenter,
        private HomeUseCase $useCase,
        private Environment $twig,
        private ResponseBuilderInterface $responseBuilder,
    ) {}

    public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        $userContext = $this->getUserContext($request);
        $userEmail = $userContext->getUser()?->getEmail()?->getValue();

        $useCaseResponse = ($this->useCase)(new HomeRequest($userEmail));
        $viewModel = $this->presenter->present($useCaseResponse)->getViewModel();

        $html = $this->twig->render('Home.html.twig', (array) $viewModel);

        return $this->responseBuilder->withResponse($response)->withStatus(200)->withHtml($html)->build();
    }
}

The use case itself is a single __invoke. No handler registration, no dispatch indirection.

The Library module is different. Creating a book validates input, generates an ID, publishes a domain event, and runs under a transaction. It uses Bus Mode, and the controller dispatches a command instead of calling a use case:

$command = new CreateBookCommand($bookId, $requestDto->title, $requestDto->author, $requestDto->isbn);

$this->commandBus->dispatch($command);

Both controllers live in the same application, share the same container, and reuse the same plugins. The mode is a property of the module, decided once, not a property of the framework.

The decision matrix

The choice is not a matter of taste. It follows from what the operation actually needs:

Need Mode
Domain events Bus Mode
Multi-entity transactions Bus Mode
Authorization or logging middleware Bus Mode
Simple read, no side effects Direct Mode
Prototyping with unclear requirements Direct Mode

The rule collapses to one question asked per module: does this operation need events, a transaction boundary, or middleware? If yes, Bus Mode earns its boilerplate. If no, Direct Mode keeps the module readable. When requirements are unclear, start with Direct Mode and migrate later.

Trade-offs

Mixing modes has a cost: a new contributor reads two flows instead of one. A controller calling ($this->useCase)(...) and a controller calling $this->commandBus->dispatch(...) are not interchangeable at a glance. The convention has to be documented, and the boundary has to stay at the module level. Mixing both modes inside a single module is the failure case, because then no one can predict how a given operation behaves.

Direct Mode also gives up things you might need later: there is no middleware seam, events are manual, and adding async processing is harder. That is acceptable precisely because the modules chosen for Direct Mode do not need those features.

What this enables

Migration stops being a big-bang decision. A module starts in Direct Mode while its requirements are still fluid, and moves to Bus Mode when the first domain event or transaction appears. The controller change is small:

// Before (Direct Mode)
($this->useCase)($request);

// After (Bus Mode)
$this->commandBus->dispatch($command);

The result is an application where simple modules stay simple and complex modules get the structure they need, under one Clean Architecture, without paying the CQRS cost where it does not buy anything.