Skip to content
Kumar Chandrachooda
Microservices

Copy the Contract or Share It

DShop answers the shared-kernel question twice - eight services copy every event contract locally, one references a shared package - and both answers are in the same estate with their real costs on show. Part 4 of Nine Services and a Message Bus.

By Kumar Chandrachooda 13 Aug 2025 6 min read
Eight private copies of a contract beside one shared original

Last part ended on a promise: the estate contains both the disease and the cure for contract drift, and you can read them side by side. This is that comparison. The question underneath it is the oldest one in distributed systems — who owns the shape of a message? — and DShop is unusual in that it answers it two ways at once. Eight services copy every contract they consume into their own repository. One service references a shared package. Both answers are live, and reading them together shows you what each one actually costs.

The copy answer: eight services, private contracts

When Orders publishes OrderCreated, eight of the nine services that care about an order event keep their own declaration of it. Notifications has an OrderCreated class in DShop.Services.Notifications.Messages.Events. Operations has one in DShop.Services.Operations.Messages.Orders.Events. Neither references the Orders project; neither references any shared contract library. Each is a hand-typed copy, kept in sync by whoever remembers to update it.

The mechanics that make this work are the routing convention from Part 3: a locally-declared class with [MessageNamespace("orders")] binds to the Orders exchange regardless of which assembly it lives in. So a copy is a full participant on the bus — RabbitMQ neither knows nor cares that the consumer's OrderCreated is a different CLR type than the producer's. The convention is the contract, and the class is just a local lens onto the wire format.

What does copying buy? Autonomy. Each service compiles, versions, and deploys on its own schedule with no shared dependency to coordinate. Notifications can be on a different framework version, a different release cadence, a different owner's roadmap, and none of that is blocked on a shared contracts package. In a real organisation with a team per service, that independence is the entire reason microservices exist — a shared contract library is a shared build, and a shared build is a coordination point that quietly re-couples the teams you split apart.

What does copying cost? Exactly what Part 3 documented: nothing enforces that the copies agree. Drift is invisible until a field goes missing at runtime, because “the same event” is a name three people typed the same, not a type a compiler checks. Copy trades enforced correctness for team autonomy, and it is a real trade, not a mistake.

The share answer: one service, one package

Then there is Storage. Its .csproj references a package called DShop.Messages, and its handlers open with using DShop.Messages.Events.Orders; — the shared contract, not a local copy:

public sealed class OrderCreatedHandler : IEventHandler<OrderCreated>
{
    // OrderCreated here is DShop.Messages.Events.Orders.OrderCreated
    // — the shared type, referenced, not copied.
    public async Task HandleAsync(OrderCreated @event, ICorrelationContext context)
    {
        var order = await _ordersService.GetByIdAsync(@event.Id);
        await _ordersRepository.CreateAsync(order);
    }
}

DShop.Messages is a Canonical Data Model: one assembly where every command and event is defined once — Commands/Orders/CreateOrder, Events/Products/ProductCreated, and so on — meant to be referenced by every service so that “the contract” is a shared type. This is the answer that would have prevented the whole of Part 3. If Notifications referenced DShop.Messages.OrderCreated, its copy could not silently lose a field, because there would be no copy — there would be one type, and dropping a field from it would break every consumer's build at once, loudly, which is the only way you want to find out.

So the cure exists, it works, and exactly one service out of nine uses it. That imbalance is the real story.

Why the cure didn't take

Two reasons, both visible in the source, and both fair.

The first is coupling, and it is the honest cost of sharing. A referenced contract package is a shared build dependency: change DShop.Messages and every service that references it must rebuild and — if the change is breaking — redeploy in lockstep. That is precisely the coordination point that copying exists to avoid. The estate's authors were teaching microservice autonomy; a mandatory shared contracts library sits in tension with that lesson. Choosing copy for eight services and reserving the shared package for the read side is a defensible reading of “keep services independent.”

The second reason is subtler and it is the twist: the shared package is itself frozen, and drifted anyway. Look at the canonical DShop.Messages version of the very event Part 3 was about:

namespace DShop.Messages.Events.Orders
{
    public class OrderCreated : IEvent
    {
        public Guid Id { get; }
        public Guid CustomerId { get; }
        public long Number { get; }        // the owner never publishes this
        // ...and no Products field, which the owner does publish
    }
}

This is a fourth shape of OrderCreated. It has a Number field the live Orders service does not emit, and it lacks the Products field the live Orders service does emit. The canonical model was written against an older idea of the order event and then abandoned mid-2018, when the estate pivoted to per-service copies. Sharing a contract only prevents drift while the shared thing is maintained. A frozen canonical model drifts from the live producer just as silently as a copy does — it has merely centralised the staleness into one package that looks authoritative.

Storage gets away with it for a reason worth naming, because it is the pattern the next part is about. Its handler reads exactly one field off the event — @event.Id — and then calls back to Orders over HTTP to fetch the real, current order:

var order = await _ordersService.GetByIdAsync(@event.Id);

The stale Number-instead-of-Products shape never bites Storage, because Storage treats the event as a notification — “order 123 exists now, go look it up” — not as the data itself. It only ever trusts the id. That is the notification-then-fetch pattern, and it is one of the two ways this estate moves data between services; the next part puts it against its opposite.

The tombstone

Put the pieces together and DShop.Messages is a tombstone. It was built to be the one true contract library, adopted by a single service, frozen while the producers kept evolving, and left in the repository as an artifact of a direction the estate started down and abandoned. The companion series reads its epitaph as part of the framework's own retrospective — The Rough Draft of Convey — because the successor framework, Convey, eventually took a firm position here that DShop never settled: contracts are messages, messages are versioned, and the shared-vs-copied question gets a real answer instead of two half-adopted ones.

The rule of thumb

The estate is not evidence that copying beats sharing or the reverse. It is evidence that the shared-kernel question has no free answer — only a choice of which failure you would rather have visible.

  • Copy the contract and you buy team autonomy at the price of silent runtime drift. It is the right call when services are owned by independent teams and evolve independently — provided you add back the enforcement you gave up: consumer-driven contract tests, a wire-format version, something that fails a build when the copies disagree.
  • Share the contract and you buy compile-time enforcement at the price of a shared build and lockstep deploys. It is the right call when a few closely-held services must agree exactly — provided the shared package is actually maintained, because a frozen canonical model is the worst of both: it looks authoritative and is quietly wrong.

DShop chose copy-for-most and share-for-one, and then maintained neither the copies nor the share. That is the failure to learn from: not the choice, but the un-maintenance of whichever choice you make.

Next: Two Ways to Get Another Service's Data — notification-then-fetch, which just saved Storage, against its opposite, and the consistency footgun hiding between them.