CQRS Application Layer on top of API Platform
In Symfony, an HTTP handler often becomes the use-case by default. While an operation lives behind one endpoint, that looks economical. Once the same scenario must run through a CLI command, a Messenger worker, or a webhook, the transport starts dictating the application’s structure and one business operation diverges across several entry points.
What is needed here is not another controller and not more magic around API Platform. The application needs a separate boundary: transport accepts the external request, the Application Layer presents the use-case, and the domain owns rules and invariants. elriseio/application-layer-bundle is an implementation of that boundary, not the subject of the article itself.
Where this comes from
In my practice, the same use-case repeatedly had to be exposed through HTTP, a Messenger worker, a CLI command, and a webhook. The problem was not the number of transports. It was that each one started assembling input, calling domain logic, and shaping the result in its own way. This article captures the architectural principle that keeps those paths aligned.
This is not a comparison of different ways to build an Application Layer. It presents one option for creating a cleaner architecture: separate transport from the use-case and the domain, then make the boundary explicit through command/query contracts. The bundle is a concrete implementation of that approach. The code, wiring, API, and tests live on the bundle project page and in the repository.
The problem: transport becomes the use-case
An inbound request carries three different concerns:
| Concern | Responsibility |
|---|---|
| Transport | HTTP, OpenAPI, content negotiation, rate limits, authentication, response format |
| Application Layer | use-case input, command/query choice, handler invocation, application-level orchestration |
| Domain | aggregates, invariants, domain services, and state-change rules |
In the wrong structure, these concerns collapse into the controller. It reads the payload, decides what to do, calls a repository, knows the response format, and sometimes manages the queue itself. The code looks direct because the whole path is visible in one file. That is false simplicity: the controller becomes the only place where the use-case exists.
The next transport cannot call that use-case directly. The team either repeats it or pulls pieces out of the controller into services without a clear contract. Eventually, identical operations have different validation, authorisation, transaction, and error-handling rules. The same rake shows up again and again: the defect is not in the domain, but at the boundary between input and business rules.
DDD proposes an Application Layer that owns the boundary between transport and domain. It exposes a use-case API as a set of commands and queries. A command or query is a separate testable object with a shaped input and an explicit result; transport becomes an adapter to that API.
CQRS as the shape of the Application Layer
CQRS — Command Query Responsibility Segregation — is often sold as a large-scale separation of read and write paths for high-load. This is not about separate databases, event sourcing, or a complex distributed system. A simpler idea is at work: operations that mutate state are different from operations that read it, and the code should make that visible.
A command handler takes a command and request context, runs the use-case, and returns a result. A query handler takes a query, performs a read, and returns data. The two sides have a different operational profile:
- commands can be queued, retried, deduplicated, and audited;
- queries are read-only, do not change state, and do not inherit the command’s async semantics.
When each side has its own interface and tagged locator, the framework can handle them differently without conditional logic spread across transports and services. The distinction fixes the meaning of the operation, not its delivery mechanism: a command expresses a state change, a query expresses a read.
Analysis: API Platform covers the transport layer
API Platform is good at transport concerns. It describes operations, generates OpenAPI, handles content negotiation, connects resources to state providers and processors, and integrates with security and rate limits.
But Provider and Processor do not form a use-case API by themselves. They are transport adapters. They know where a request came from and where to return the result, but they do not have to define the application’s command/query model. If the use-case is placed directly in a processor, API Platform becomes the place where the business scenario lives again.
The boundary should therefore be drawn like this:
- API Platform remains the transport adapter;
- each operation calls a command or query in the Application Layer;
- the handler knows the use-case and calls domain objects;
- the domain knows neither API Platform nor the HTTP response format.
This is not a ban on using API Platform in the project. It is a ban on mixing its transport role with the role of the application API.
The solution: Application Layer as an architectural seam
The Application Layer is not just another directory between a controller and the domain. It is the application’s public API for external execution paths. It answers: “Which use-case are we running, and what input does it require?” The transport answers a different question: “How do we obtain that input and return a result to this particular client?”
In this model, the Application Layer presents the use-case through a command/query API. It does not require mandatory infrastructure around every operation: additional mechanisms are connected only where a particular scenario needs them.
The model looks like this:
┌─────────────────────────────────────────────────────────────────┐
│ Transport │
│ HTTP · API Platform · CLI · Messenger worker · webhook │
│ OpenAPI · security · content negotiation · response format │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Application Layer │
│ Command / Query · immutable input · use-case handler │
│ application-level orchestration │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Domain │
│ Aggregates · invariants · domain services · state rules │
└─────────────────────────────────────────────────────────────────┘
The diagram does not describe a particular bundle’s call sequence. It describes the dependency of responsibilities: transport does not drop directly into the domain, and the domain does not learn how a request was delivered.
The contract the application needs
The Application Layer has three mandatory properties.
- A shaped input. A command or query should be an object, not an array assembled differently by every transport. This fixes the use-case input and makes it testable without HTTP.
- Separate handler contracts. Command and Query handlers should be distinguishable at the application API level. A wrong path can then be found in wiring or dependency resolution, not after a side effect.
- One entry point. The transport should pass input to the Application Layer instead of orchestrating domain calls itself. This is what keeps the path identical across HTTP, CLI, worker, and webhook.
Everything else is connected only when a particular use-case needs it. Caching, idempotency, transaction boundaries, authorisation, and queues do not automatically become every handler’s responsibility, and they should not appear unnoticed inside a transport adapter.
Where the bundle fits
application-layer-bundle implements this contract for Symfony: it provides separate command/query interfaces, a common entry into the Application Layer, and integration with transport adapters. API Platform can remain API Platform while the use-case remains part of the application API.
The bundle does not replace API Platform, Doctrine, or Messenger. It does not design aggregates, make domain decisions, or turn CQRS into a framework with mandatory infrastructure. Its role is to provide a repeatable implementation of the seam that each team would otherwise assemble by hand.
The detailed implementation is in the project page: installation, contracts, API, examples, error handling, queue integration, and tests. This article needs only the principle: the bundle is the solution for an explicit boundary, not the content of the architecture.
When it is justified
The Application Layer is not needed in every Symfony project. A plain CRUD with one transport can live in a controller if it has no standalone use-case API and the same operation does not need to run elsewhere.
A separate boundary pays off when:
- one use-case must work through several transports;
- write and read operations require different operational rules;
- controllers start mixing transport, orchestration, and domain calls;
- the project needs an explicit application API that can be tested independently.
Without those conditions, a command and a handler can become ceremony for its own sake. An architectural seam is useful where different scenarios actually pass through it, not because CQRS looks good in a README.
Conclusion
The problem is not that a Symfony controller is too large. The problem is that transport starts owning the use-case. API Platform does not solve that: it closes the transport layer and leaves the Application Layer to the project.
The solution is to separate three responsibilities explicitly: transport adapts the external input, the Application Layer presents a command/query API, and the domain holds rules and invariants. application-layer-bundle is a ready implementation of that separation for Symfony. This does not mean every project needs the bundle; the boundary makes sense only where one use-case genuinely lives beyond a single HTTP endpoint.
Useful links:
- Application Layer Bundle: implementation, API, and tests
- DDD, Eric Evans — Part III, the section on isolating the Application Layer from domain and infrastructure
- Implementing DDD, Vaughn Vernon — the section on Application Services and the CQRS Pattern
- API Platform — documentation on state providers and processors
- Symfony Messenger — transport for asynchronous messages
- Reference Symfony service — an example combining Application Layer, API Platform, CQRS, and persistence
- Demo and bundle usage example — a demo and example of bundle implementation or usage can be viewed here, in RU and EN