Four Products, One Truth
A single product identity is represented four ways across four DShop services - owner, replica, cart snapshot, order snapshot - each on its own refresh discipline, and nothing masters the set. Part 12 of Nine Services and a Message Bus.
Ask the DShop estate a simple question — what is product X's price? — and you get up to four answers, from four services, each true as of a different moment. Products has the authoritative price. Customers has a replica of it, and a frozen copy inside every cart. Orders has another frozen copy inside every order line. Storage has a read copy. None of them is wrong; they are all “product X,” and they disagree by design. This part is about what happens when an entity's identity is replicated instead of mastered, and it is the clearest master-data lesson in the estate.
Four representations of one product
Here are the four, read from their domain classes. The owner, in Products, is the source of truth:
// Products — the master
public class Product { Guid Id; string Name; decimal Price; int Quantity; }
Customers keeps a full replica, rebuilt from product events by event-carried state transfer — same four fields, a copy that Customers maintains for itself:
// Customers — an ECST replica, maintained on every Product* event
public class Product { Guid Id; string Name; decimal Price; int Quantity; }
And then it gets more interesting, because Customers also holds a CartItem — a copy taken when the product went into a cart, with its own update rule:
// Customers — a cart snapshot, refreshed when the product changes
public class CartItem
{
public Guid ProductId { get; }
public string ProductName { get; }
public decimal UnitPrice { get; }
public int Quantity { get; }
public void Update(Product product) { ProductName = product.Name; UnitPrice = product.Price; }
}
Orders holds an OrderItem — a copy taken when the order was placed, with no update method at all:
// Orders — an order snapshot, frozen forever
public class OrderItem
{
public Guid Id { get; }
public string Name { get; }
public int Quantity { get; }
public decimal UnitPrice { get; }
// no Update — this copy never changes
}
Plus Storage's read copy, the notification-then-fetch projection from Part 10. Four-and-a-half representations of one product identity, spread across the estate.
Four refresh disciplines, and why they're all correct
The tempting reaction is “this is duplication, deduplicate it.” Resist it, because the four copies are on four deliberately different clocks, and at least two of the differences are exactly right.
| Where | What | Refresh discipline | Should it track the master? |
|---|---|---|---|
| Products | the master | it is the truth | — |
Customers Product |
full replica | updated on every Product* event |
yes — wants to be current |
Customers CartItem |
cart snapshot | Update() on product change |
arguable |
Orders OrderItem |
order snapshot | never | no — frozen on purpose |
| Storage | read copy | re-fetched on event | yes — wants to be current |
OrderItem having no Update method is not an oversight — it is the single most correct decision in this whole picture. An order line must record the price the customer actually paid, frozen at purchase time. If the product's price rises next week, the order must not rise with it; a snapshot that never refreshes is the right model for a completed transaction. Freezing is the feature.
CartItem sits in the interesting middle. It has an Update method that Customers calls when a product changes, so a cart reflects the current price until checkout — reasonable, since a cart is a live intention, not a completed sale. But it is a second copy of the same fields the Customers Product replica already holds, maintained by the same event on a slightly different path, which means the cart's price and the replica's price can momentarily disagree with each other, let alone with the master.
And the two “wants to be current” copies — Customers' replica and Storage's read copy — are the ones that quietly drift, because “current” is a promise about a distributed system that no single service can keep. Each updates when its event arrives; the events arrive at different times over different paths; and there is no reconciliation pass, no version vector, no “as of” stamp to compare them. Which copy a screen shows depends on which service rendered it and how far behind its event stream happened to be.
Un-mastered master data
The discipline this belongs to is master data management, and DShop is a clean specimen of doing it by replication rather than mastering. Mastering an entity means there is one authoritative record and everyone else holds a reference to it, resolved on read, or a copy with an explicit version and a defined staleness contract. DShop instead lets each service copy the fields it needs and refresh them on its own schedule. There is no master-data layer — no canonical product id space with owned attributes, no golden record, no “these four copies are the same product and here is the reconciliation rule.” There is just a GUID that four services agree to call the product, and four independently-maintained bags of attributes hanging off it.
This is the same disease as the contract drift of Part 3, one level up. There, a message was represented multiple ways with nothing enforcing agreement. Here, an entity is. And the same footgun from Part 5 lives here too: stock Quantity is one of these replicated attributes, decremented authoritatively in Products and replicated into Customers, so “how many in stock” is itself an un-mastered number with two writers.
There is a fifth un-mastered attribute hiding in plain sight: the currency. Every OrderItem carries a UnitPrice, every CartItem a UnitPrice, and none of them carries a currency — the order does, and it is the hardcoded "USD" from Part 2's rejected-event tour. So “price” is a bare decimal replicated four ways with an implicit, estate-wide assumption that it is dollars. The day the shop sells in a second currency, four representations of a price with no currency field become four ways to be wrong, and there is no single place to fix it because there is no single place that owns “what a product costs.”
To be fair, replication-not-mastering is a normal and often correct microservices posture — the alternative, a synchronous read to the owning service on every access, reintroduces exactly the runtime coupling that database-per-service exists to remove. The snapshots especially are good design. The gap is not that DShop replicates; it is that it replicates without a discipline — no version on the copies, no staleness budget, no reconciliation — so “the same product” quietly means “four products that started equal.”
The rule of thumb
- Decide per attribute whether a copy is a snapshot or a replica, and never confuse them. A snapshot (order price) must freeze; a replica (cart price, catalogue read copy) must track. They look identical in the database and are opposite in intent — the presence or absence of an
Updatemethod is the whole contract. - Replicated master data needs a version and a staleness budget, or it isn't managed — it's just copied. Four copies with no reconciliation rule are not “eventually consistent”; they are coincidentally equal until an event lags, and load is what reveals the coincidence.
Next: Identity Done Mostly Right, Secrets Done Wrong — the one service that gets its domain shape genuinely right, and commits its signing key to source control.