Skip to content
Kumar Chandrachooda
Microservices

OrderCreated in Three Shapes

The same event is declared three times across DShop, and one copy quietly drops a field - here is the contract drift in the real source and the canonical-model cure that would have caught it. Part 3 of Nine Services and a Message Bus.

By Kumar Chandrachooda 06 Aug 2025 6 min read
One event class traced three times, the third missing a field

Here is a failure mode you cannot reproduce with two services and a happy path. Orders publishes an OrderCreated event carrying the customer, the order id, and the products ordered. Notifications receives it, reads the customer and the id — and never sees the products, because its own copy of the OrderCreated class does not have a products field. Nothing throws. The JSON deserialises cleanly. The email goes out. The products are simply gone, and no compiler, broker, or test noticed, because the two services never share the type they are both calling OrderCreated.

This part is that drift, verified in the source. It is, to my eye, the single best teaching artifact in the whole estate, because the disease and the cure are both present — you can read the wrong answer and the right answer in the same repository.

Three files, one name

OrderCreated is declared three times in three services. Orders owns it. Operations and Notifications each keep a local copy so they can subscribe to it — the copy-the-contract convention this estate runs on, which the next part takes apart. Here is the owner's version, in the Orders service:

public class OrderCreated : IEvent
{
    public Guid Id { get; }
    public Guid CustomerId { get; }
    public IDictionary<Guid, int> Products { get; }

    [JsonConstructor]
    public OrderCreated(Guid id, Guid customerId, IDictionary<Guid, int> products)
    {
        Id = id;
        CustomerId = customerId;
        Products = products;
    }
}

Three properties: id, customer, and a Products dictionary mapping each product id to a quantity. This is the truth — the event the Orders service actually serialises onto the bus when an order is placed. Operations' copy matches it property-for-property; the orchestrator needs the products because it turns around and reserves them.

Now Notifications' copy of the same event:

[MessageNamespace("orders")]
public class OrderCreated : IEvent
{
    public Guid Id { get; }
    public Guid CustomerId { get; }

    [JsonConstructor]
    public OrderCreated(Guid id, Guid customerId)
    {
        Id = id;
        CustomerId = customerId;
    }
}

Two properties. No Products. This class deserialises the exact same wire message as the owner's three-property version, and JSON simply ignores the field it has no constructor parameter for — so Notifications receives every order with its line items silently amputated. The email templating code downstream can never mention what was ordered, not because someone decided emails shouldn't list products, but because the field never survived the copy.

Why nothing catches it

Walk the delivery path and notice how many gates this sails through untouched.

The routing key still matches. Look at the attribute on the copies: [MessageNamespace("orders")]. That is what binds a class declared in the Notifications assembly to the Orders exchange — it overrides the namespace the routing convention would otherwise derive, so Notifications' locally-declared OrderCreated subscribes to the same exchange and routing key the owner publishes on. The mechanism that turns a class name into an exchange, routing key, and queue is the subject of the companion series' Queue Names Are the Whole Topology; the point here is that routing is derived from names, and both classes are named OrderCreated in namespace orders. The wire address is identical. Delivery works perfectly.

The payload still deserialises. Newtonsoft.Json binds the JSON body to the two-argument [JsonConstructor]. A JSON object with an extra products property the constructor doesn't take is not an error — it is the single most common thing in the world of loosely-versioned JSON. The extra field is dropped, quietly, by design.

Nothing type-checks across the boundary. The two classes are in two assemblies with two namespaces. They are not the same type; they are not even related by an interface beyond IEvent. No build spans both. There is no schema the broker validates against. The only thing asserting these two classes describe the same event is that a human named them the same and pointed the same routing attribute at them.

So the contract is a convention held together by string equality of a class name and a routing key — and a convention has no way to notice that one party's copy is a field short.

To be fair to the design: this is exactly the resilience that event-carried contracts are supposed to have. A consumer that only needs two fields shouldn't break when the producer adds a third; forward and backward compatibility in JSON events depends on tolerating unknown fields. Notifications genuinely does not need the products — it sends a generic “your order was received” email. The drift is invisible because the tolerance is correct. The problem is not the tolerance; it is that the same tolerance hides an accidental omission just as smoothly as it enables an intentional one, and there is nothing in the estate that can tell the two apart.

The Format Indicator that isn't there

Enterprise integration has a name for the thing missing here: a Format Indicator — a version stamp on the message that lets a consumer know which shape it is looking at. DShop has none. The routing key is derived from the class name, so it cannot carry a version; there is no v2 suffix, no schema id in a header, no Content-Type beyond “json.” Add a field to OrderCreated tomorrow and every consumer's copy silently diverges by exactly one field until someone hand-copies the change into eight other repositories. The companion series catalogues this and the estate's other messaging absences in What the Framework Doesn't Have — no Format Indicator is on that list, and this is what its absence feels like in practice.

The cure was in the box

Here is what makes the estate a good teacher rather than just a cautionary tale: the fix ships alongside the flaw. There is a package, DShop.Messages, whose entire job was to be the Canonical Data Model — one assembly where OrderCreated is defined once, referenced by every service, so “the contract” is a type the compiler enforces rather than a name three people typed the same. If Notifications referenced DShop.Messages.OrderCreated instead of its own copy, it would either have the Products field or fail to compile the day the field was added. The drift would be a build error, which is the only kind of error you can trust.

Exactly one service in the estate took that route — Storage references DShop.Messages — while the other eight copy. Both answers to “who owns the contract” live in the same estate, and the next part puts them side by side. The short version: the canonical model was built, adopted by one service, and abandoned. DShop.Messages is a tombstone. The drift you just read is what its abandonment cost.

The rule of thumb

Two rules fall out of this, and they generalise past DShop:

  • A copied contract is a contract you have stopped enforcing. The moment two services own two copies of “the same” type, “the same” is a hope, not a fact. Either share the type (and pay the coupling) or version the wire format explicitly (and diff the versions) — but do not pretend a class name is a contract.
  • JSON's tolerance is a feature you must budget for. Unknown-field tolerance is what makes events evolvable; it is also what makes a dropped field invisible. If you rely on tolerance for evolution, you need something else — a shared schema, a contract test, a canonical model — to catch omission, because tolerance will not.

Next: Copy the Contract or Share It — the two answers to the shared-kernel question, and why exactly one service chose the one that would have prevented this whole post.