Two Ways to Get Another Service's Data
DShop moves data between services two opposite ways - one rebuilds a local replica from the event payload, the other treats the event as a doorbell and fetches over HTTP - and the estate runs both at once. Part 5 of Nine Services and a Message Bus.
A microservice that owns its own database eventually needs data it does not own. Customers needs product names to show a cart. Storage needs the full order to build a read model. Discounts needs to know an order completed. None of them can query another service's Mongo, because that would be the distributed-monolith anti-pattern the whole architecture exists to avoid. So each has to answer the same question: when another service's event arrives, how do I get the data I need? DShop answers it two opposite ways, and — being a teaching estate — runs both at once, in services sitting next to each other on the same bus.
Way one: rebuild it from the payload
Customers keeps a local replica of every product. When Products publishes ProductCreated, Customers' handler builds a Product from the event's own fields and stores it:
public async Task HandleAsync(ProductCreated @event, ICorrelationContext context)
{
var product = new Product(@event.Id, @event.Name, @event.Price, @event.Quantity);
await _productsRepository.AddAsync(product);
}
This is event-carried state transfer: the event carries enough state that the consumer can reconstruct what it needs without ever calling back. Customers now has its own copy of the product in its own database, and it can render a cart with product names and prices while Products is offline, mid-deploy, or on fire. No synchronous dependency, no shared read.
The commitment shows on the update path, and this is where ECST earns both its keep and its reputation. When a product changes, Customers does not just refresh its replica — it fans the change into every cart that holds that product:
public async Task HandleAsync(ProductUpdated @event, ICorrelationContext context)
{
var product = new Product(@event.Id, @event.Name, @event.Price, @event.Quantity);
await _productsRepository.UpdateAsync(product);
var carts = await _cartsRepository.GetAllWithProduct(product.Id)
.ContinueWith(t => t.Result.ToList());
foreach (var cart in carts)
{
cart.UpdateProduct(product);
}
await _cartsRepository.UpdateManyAsync(carts);
}
ProductDeleted does the mirror image — delete the replica, then strip the product out of every cart. ECST does not just cache data; it takes on the job of keeping every derived copy of that data consistent, forever, in response to events. That is the real cost. The replica is cheap; the fan-out is the tax you pay for the rest of the product's life. (The .ContinueWith(t => t.Result.ToList()) is an awkward way to write .ToList() on an already-awaited result — a small idiom smell worth noting and not repeating; it blocks nothing here but reads like someone reaching for ContinueWith where await was already in hand.)
Way two: treat the event as a doorbell
Storage does the opposite. Its OrderCreatedHandler reads exactly one field off the event — the id — and then calls Orders back over HTTP to fetch the real, current order:
public async Task HandleAsync(OrderCreated @event, ICorrelationContext context)
{
var order = await _ordersService.GetByIdAsync(@event.Id); // RestEase → Orders over HTTP
await _ordersRepository.CreateAsync(order);
}
This is notification-then-fetch: the event is a doorbell, not a parcel. It says “order 123 exists now,” and Storage goes and gets the authoritative copy. _ordersService is a RestEase typed client — an interface Storage declares and the framework implements as an HTTP forwarder pointed at the Orders service, resolved through service discovery. (That discovery-and-forwarding machinery is the companion series' Three Load Balancers Behind One Config String; here it is just "call Orders and get the order.")
Notification-then-fetch has the exact opposite trade to ECST. Storage always gets the current order, never a stale snapshot, and — as the last part noted — it does not even care that its shared OrderCreated contract is missing fields, because it only trusts the id and re-fetches everything else. But it has bought a synchronous runtime dependency: if Orders is down when the event arrives, the fetch fails, and the read model does not get built. The event told Storage that something happened; getting what happened requires Orders to be alive right now.
The two trades, side by side
Put them in a table, because the choice is a genuine fork and the estate lets you see both tines.
| Event-carried state transfer (Customers) | Notification-then-fetch (Storage) | |
|---|---|---|
| Event carries | the whole payload | just an id (a doorbell) |
| Consumer at read time | reads its own replica | already has the data |
| Runtime coupling to producer | none | synchronous HTTP on receipt |
| Freshness | eventually consistent, can be stale | authoritative at fetch time |
| Ongoing cost | must keep every derived copy in sync | must keep the producer available |
| Fails when | the event shape drifts silently | the producer is down |
Neither is wrong. ECST is the right call when you must survive the producer being unavailable and can tolerate a little staleness — a shopping cart that shows a price a few seconds old is fine. Notification-then-fetch is the right call when you must be current and correct and the producer is reliable enough to call — a read model that must match the source of truth. The mistake is not picking one; it is picking one without knowing which failure you just signed up for.
The footgun between them
Now the part the estate teaches by accident. Stock quantity is tracked in two places by two disciplines. Products owns it — the reservation handler decrements it directly:
foreach ((Guid productId, int quantity) in command.Products)
{
var product = await _productsRepository.GetAsync(productId);
product.SetQuantity(product.Quantity - quantity); // Products' authoritative decrement
await _productsRepository.UpdateAsync(product);
}
But Customers also holds a Quantity on its product replica, populated by ECST off the same product events. So “how many are in stock” has two answers: the authoritative one in Products, updated by reservation commands, and the replicated one in Customers, updated by product-changed events on a different path with different timing. The same number is maintained by two mechanisms that were never designed to agree, and nothing reconciles them. Under load — a burst of orders reserving stock while product-update events lag — the two quantities diverge, and which one a screen shows depends on which service rendered it.
This is not a bug in either handler; each is correct on its own terms. It is an emergent property of mixing the two data-movement strategies over the same fact without deciding which one is the source of truth. Stock quantity wanted to be owned by exactly one service and read from there; instead it became a replicated value with two writers. That the same product identity ends up represented four different ways across the estate is a whole problem of its own — Four Products, One Truth — and the reservation decrement above has a second issue we have not touched yet: it is not idempotent, so a redelivered ReserveProducts decrements twice. That is Idempotency Aspired, Not Enforced.
The rule of thumb
- Choose your data-movement pattern per fact, and write down which you chose. ECST trades freshness for autonomy; notification-then-fetch trades autonomy for freshness. Both are correct; mixing them silently over the same fact is how you get two numbers that should be one.
- Every replicated value needs a single writer. The moment two services maintain the same quantity by two mechanisms, “consistent” becomes “coincidentally equal,” and load is the thing that reveals the coincidence.
Next: The Saga That Orchestrates a Checkout — where all these events converge, and one service takes on the job of driving an order from created to approved and rolling it back when a step fails.