CQRS Across a Service Boundary
Storage subscribes to almost every event in DShop, serves read-only queries, and owns nothing it cannot rebuild - the read side of a CQRS split that runs across a service boundary instead of inside one. Part 10 of Nine Services and a Message Bus.
Command Query Responsibility Segregation usually lives inside a service: the write model and the read model are two shapes of the same data behind one boundary. DShop does something less common — it splits the read side out into its own service. Storage subscribes to almost every event in the estate, builds denormalised read models, and serves them over read-only controllers. The write models stay in Customers, Products, and Orders; the query side is a whole separate deployable. This part reads the read side, and what it teaches about putting the CQRS seam on a network hop.
A service that only listens and answers
Storage's Startup is the giveaway. Its subscription list is eleven events long and its publish list is empty:
app.UseRabbitMq()
.SubscribeEvent<SignedUp>()
.SubscribeEvent<CustomerCreated>()
.SubscribeEvent<ProductCreated>()
.SubscribeEvent<ProductUpdated>()
.SubscribeEvent<ProductDeleted>()
.SubscribeEvent<ProductAddedToCart>()
.SubscribeEvent<ProductDeletedFromCart>()
.SubscribeEvent<CartCleared>()
.SubscribeEvent<OrderCreated>()
.SubscribeEvent<OrderCanceled>()
.SubscribeEvent<OrderCompleted>();
Eleven events in, nothing out. Storage never publishes, never issues a command, never owns a write. It is the sink Part 1 named — the place events go to become queryable. And what it exposes is exactly the other half of CQRS: controllers that only read.
public class CustomersController : BaseController
{
private readonly ICustomersRepository _repository;
[HttpGet]
public async Task<IActionResult> Get([FromQuery] BrowseCustomers query)
=> Collection(await _repository.BrowseAsync(query));
[HttpGet("{id}")]
public async Task<IActionResult> Get(Guid id)
=> Single(await _repository.GetAsync(id));
}
No [HttpPost], no [HttpPut], no command dispatch. Every Storage controller is Get and only Get. Storage is a pure query surface backed by a pure event subscriber — the reads and the writes of the whole shop have been split so hard that they landed in different services. The command side lives where the domain lives; the query side lives here, denormalised for reading.
The read model is rebuilt, not owned
Because Storage owns nothing, everything it serves is a projection it can throw away and rebuild from the events. The build strategy is the notification-then-fetch from Part 5: an event arrives as a doorbell, Storage fetches the authoritative record over HTTP, and stores the result denormalised for querying. OrderCreated fires, Storage calls Orders, writes the order into its own Mongo. The read model is a cache of other services' truth, shaped for the queries the front end actually makes, kept warm by the event stream.
This is the honest appeal of splitting the read side out. The query load — browsing, searching, listing — hits Storage and never touches the services that own the domain, so a traffic spike on the catalogue page cannot slow down order creation. The read model can be denormalised however reads want without contorting the write model. And it can be rebuilt: delete Storage's database, replay the events, and it repopulates. Those are real CQRS-at-scale benefits, and Storage demonstrates them cleanly.
The read shapes live in a separate project, too — DShop.Services.Storage.Models — so the query DTOs (Customer, Product, Order, and the Browse* query objects) are physically divorced from any write model. That is the CQRS discipline made structural: the read side does not borrow the write side's entities and strip them down; it declares its own, shaped for reading. The controllers are correspondingly thin — a Get that browses and a Get(id) that fetches a single record, each one line, delegating straight to a repository. There is no business logic on the read path because there is no business on the read path: reads do not enforce invariants, they serve projections. When a read controller is more than a repository call, the read side has usually grown a write it shouldn't have; Storage's stay honest.
The costs the split makes visible
It also makes the costs of CQRS unusually literal, because the seam is a network boundary you can point at.
The data is triple-stored. A product now lives in Products (the owner), in Customers (an ECST replica), and in Storage (a read copy) — with an order snapshot on top. Splitting the read side did not reduce duplication; it added another copy, on another consistency schedule. That every product ends up represented several ways with several refresh disciplines is a problem in its own right — Four Products, One Truth.
The staleness is now cross-service. Storage's read model lags the write side by however long the event takes to arrive and the fetch takes to complete. A user who places an order and immediately queries Storage may not see it yet. That eventual-consistency window is inherent to CQRS, but here it also depends on Orders being up for the fetch to land — the read model's freshness is coupled to the write service's availability.
A subscribed event with an empty handler is a read model that was planned and never built. Storage subscribes to SignedUp, and its handler is a no-op:
public class SignedUpHandler : IEventHandler<SignedUp>
{
public async Task HandleAsync(SignedUp @event, ICorrelationContext context)
{
await Task.CompletedTask;
}
}
The subscription is live — the queue is bound, the message is delivered, the handler runs — and does nothing. There was going to be a user read model here; there isn't. It is an honest stub rather than a bug (it fails nothing), but it is the kind of thing only reading the source reveals: from the outside, Storage looks like it tracks signups, and it does not.
And small inefficiencies hide in a service nobody watches. Storage's own RedisCache reads Redis twice for one lookup:
public async Task<T> GetAsync<T>(string key)
{
var value = await _cache.GetStringAsync(key);
return string.IsNullOrWhiteSpace(value) ? default(T)
: JsonConvert.DeserializeObject<T>(await _cache.GetStringAsync(key));
}
It fetches the string to null-check it, then fetches the same key again to deserialise — two round trips where one would do. On a read-side service whose whole job is fast queries, doubling every cache read is exactly the kind of paper cut that survives because the read side is the part nobody profiles.
To be fair, none of these is a design failure of CQRS — they are its bill, itemised. Storage is a legitimate and clean read side; putting it in its own service is defensible when read load genuinely needs to scale independently. The estate just lets you see the bill, because the seam is a service you can open.
The rule of thumb
- Splitting the read side into its own service buys independent read scaling and costs another copy of the data. It does not reduce duplication; it relocates and multiplies it. Choose it when read load must scale apart from writes, not to “clean up” the write model.
- A subscribed event with an empty handler is a promise the read model doesn't keep. From outside, a live subscription looks like coverage. Only the handler body tells you whether the projection was actually built.
Storage is also the strangest-built service in the estate for a reason that has nothing to do with CQRS: it is a version-skew time capsule that only compiles in one configuration. Next: The Version-Skew Time Capsule.