CQRS at the Transport Layer
In Pacco the CQRS split is not a diagram - it is wiring you can read. Every command arrives through two front doors, RabbitMQ subscription and controller-less HTTP dispatch, while queries ride HTTP only; commands assign their own ids in the constructor, and a marker attribute turns each service into its own runtime contract registry.
Part 11 followed a message to the end of its journey, into an outbox drawer with the lock unscrewed. This part walks back to the beginning and asks a simpler question: how does a command get into a Pacco service in the first place? The answer is stranger than any individual handler we have read so far. In most codebases, CQRS is an application-layer discipline — separate models, separate handlers, and an architecture slide to prove it. In Pacco you do not need the slide. You can read the command/query split off the network wiring itself, because every command has two front doors and every query has exactly one.
Two doors, one handler
There are no controllers anywhere in this estate — not one [ApiController], not one ControllerBase across ten services. Orders' entire HTTP surface is this block in Program.cs:
.UseDispatcherEndpoints(endpoints => endpoints
.Get("", ctx => ctx.Response.WriteAsync(ctx.RequestServices.GetService<AppOptions>().Name))
.Get<GetOrder, OrderDto>("orders/{orderId}")
.Get<GetOrders, IEnumerable<OrderDto>>("orders")
.Delete<DeleteOrder>("orders/{orderId}")
.Post<CreateOrder>("orders",
afterDispatch: (cmd, ctx) => ctx.Response.Created($"orders/{cmd.OrderId}"))
.Post<AddParcelToOrder>("orders/{orderId}/parcels/{parcelId}")
.Delete<DeleteParcelFromOrder>("orders/{orderId}/parcels/{parcelId}")
.Post<AssignVehicleToOrder>("orders/{orderId}/vehicles/{vehicleId}"))
Each route binds directly to a command or query type; Convey's dispatcher builds the object from the request and hands it to the same ICommandHandler<T> or IQueryHandler<T,R> the rest of the service uses. And a few files away, in Pacco.Services.Orders.Infrastructure/Extensions.cs, the same seven commands are wired to the bus:
.UseRabbitMq()
.SubscribeCommand<ApproveOrder>()
.SubscribeCommand<CreateOrder>()
.SubscribeCommand<CancelOrder>()
.SubscribeCommand<DeleteOrder>()
.SubscribeCommand<AddParcelToOrder>()
.SubscribeCommand<DeleteParcelFromOrder>()
.SubscribeCommand<AssignVehicleToOrder>()
.SubscribeEvent<CustomerCreated>()
// ... six more events
CreateOrder can arrive as a POST /orders or as an AMQP message on the orders exchange, and either way it lands in the identical CreateOrderHandler. Convey's SubscribeCommand<T> is five lines — resolve a scope, resolve the handler, invoke it — so the bus door and the HTTP door genuinely converge on one piece of code. Availability repeats the pattern with its four commands; so does every bus-connected service. Queries, meanwhile, appear only in the HTTP block. There is no SubscribeQuery<T> anywhere in Convey — the abstraction does not exist. The bus carries intentions and facts; it structurally cannot carry questions. That asymmetry is the purest statement of CQRS I have seen in a running system: reads need a reply channel and a caller waiting, so they get request/response; writes need neither, so they get both transports and the caller is told “accepted” either way.
It also quietly explains the estate's choreography. When the OrderMaker saga wants an order created, it does not call an API — it publishes the same CreateOrder contract the gateway would have posted. The command class is the integration point, not the route.
The command is the contract — and it binds itself
Look at how a dispatcher endpoint fills a command in, because the mechanics are half the design. Convey deserialises the request body into the command type, then walks the route values and writes each one into the command via reflection — and not into properties, but into the compiler-generated backing fields of get-only properties, matching fields whose names start with <orderId> and the like. Immutable-looking commands, mutated by the router. It is equal parts elegant and unsettling: the command type declares public Guid OrderId { get; } with no setter, and the framework respects the letter of that while violating its spirit, so that one class can serve as HTTP request DTO, bus message, and handler input without a single mapping line.
The commands themselves participate in the trick. Here is CreateOrder, whole:
[Contract]
public class CreateOrder : ICommand
{
public Guid OrderId { get; }
public Guid CustomerId { get; }
public CreateOrder(Guid orderId, Guid customerId)
{
OrderId = orderId == Guid.Empty ? Guid.NewGuid() : orderId;
CustomerId = customerId;
}
}
Commands assign their own identity. Post an empty body and the constructor mints a fresh OrderId during deserialisation; supply one and it is honoured. Availability's AddResource does the same in a one-line tuple assignment. Client-generated-or-server-generated id, resolved in the message's own constructor — and the afterDispatch callback closes the loop by reading the id back off the command to build the 201 Created Location header. The command is request, message, and receipt in one object.
The cost of the elegance is worth naming. Self-assigned ids mean a retried POST /orders with an empty body is two orders with two ids — there is no idempotency key anywhere on these endpoints, and the acknowledgement is thin: creation routes across the estate use afterDispatch for a proper 201 Created with a Location header, but every other mutation — AddParcelToOrder, AssignVehicleToOrder, the cancellations — falls through to the dispatcher's default, a bare 200 with no body, sent the moment the handler returns. And the two doors do not share a security posture: the HTTP door arrives through the gateway with an identity in the correlation context, while a command published straight to RabbitMQ carries whatever identity the publisher chose, or none — and, as we saw in the ReserveResourceHandler ownership check, none passes every guard in the estate. Two transports, one handler, two very different trust stories.
A contract registry, grown at runtime
The [Contract] attribute on CreateOrder is the other half of the transport story. It is an empty marker class — literally public class ContractAttribute : Attribute { } — copied per service, and it exists for one middleware call in every UseInfrastructure:
.UsePublicContracts<ContractAttribute>()
Convey's PublicContractsMiddleware intercepts GET /_contracts and answers with a JSON document of every command and event type in the service marked [Contract]. The implementation is worth a paragraph of admiration: on first construction it scans the AppDomain, materialises each contract type with FormatterServices.GetUninitializedObject — no constructor runs, so the self-assigning ids stay empty — fills the instances with default values, serialises the lot once into a static string, and serves that cached blob forever after. A machine-readable schema endpoint for a system with no schema registry, in about a hundred lines.
Seven of the ten services expose it — Orders, Parcels, Availability, Customers, Deliveries, Vehicles, Identity. Pricing (no bus, no [Contract] types), OrderMaker and Operations do not. So the estate's contract story at runtime is: ask any core service GET /_contracts and it will tell you, from its live assemblies, exactly what it speaks. Pair that with the Pactify tests and you can see the authors reaching for contract governance without a broker or a registry — each service self-describing, consumers verifying shapes. What /_contracts cannot do is compare: nothing ever fetches two services' contracts and diffs the copied classes against each other, which is how the estate's copied-contract bugs survive — the hand-maintained shadow of this same idea, a messages.json listing all ~70 messages for Operations to re-emit as CLR types, gets its own reading in a shadow registry of every message.
What the wire teaches
Strip the estate down to its transport rules and you get a table worth stealing:
| Message kind | HTTP | RabbitMQ | Reply |
|---|---|---|---|
| Command | yes — dispatcher route | yes — SubscribeCommand<T> |
200/201, or a rejected event |
| Query | yes — dispatcher route | never | JSON body, 404 on null |
| Event | no | yes — SubscribeEvent<T> |
none, ever |
Three message kinds, three different transport signatures — which means you can classify any interaction in the estate from a packet capture, without reading a line of application code. That legibility is the real prize of doing CQRS at the transport layer, and it is what most “CQRS” codebases give up when every message rides the same POST.
The honest ledger for this part is shorter than usual, because the design mostly works: no idempotency on the write endpoints, a silent 200 as the default command response, and two doors with two trust models are the entries. The deeper trouble with the write path is not how commands arrive but what happens after the handler writes — because in this estate, the answer to “what if two writes race?” turns out to be a version check that nobody listens to. Next: optimistic concurrency that never says no.