Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/two-contracts-drifted-twelve-did-not && read --section="top" 0%
Microservices

Two Contracts Drifted, Twelve Did Not

Trill has no shared contracts package - every consumer re-declares the publisher's event class by hand. Diffing all fourteen duplicated contracts shows exactly two have drifted, and the ratio is what makes copy-paste governance feel safe until it isn't.

By Kumar Chandrachooda 17 Dec 2025 7 min read
Aligned contract bars with two that have slipped out of line

The argument for copying integration-event classes between services instead of sharing a package is a good one, and I have made it myself. A shared contracts assembly couples every consumer's release cadence to the publisher's. It invites people to put behaviour in it. It turns a version bump into a nine-repository coordination exercise. Copy the class, own your copy, evolve independently — that is the consumer-driven position, and it is defensible.

What it is not is free. It trades a compile-time coupling for a governance obligation, and the obligation is invisible until the day it is not met.

Part 12 ended on a saga that would need one field added in three repositories at once. This part is about what that actually costs in Trill, measured rather than assumed: I diffed every duplicated contract in the estate.

The mechanism

There is no shared contracts package. Each consumer re-declares the publisher's event class in its own Events/External/ folder, adding one thing the publisher's copy does not have — a [Message("<exchange>")] attribute:

// Trill.Services.Users/src/.../Core/Events/UserCreated.cs  — the publisher
public class UserCreated : IEvent
{
    public Guid UserId { get; }
    public string Name { get; }
    public string Role { get; }

// Trill.Services.Stories/src/.../Events/External/UserCreated.cs — a consumer
[Message("users")]
public class UserCreated : IEvent
{
    public Guid UserId { get; }
    public string Name { get; }

Every appsettings.json in the estate sets "conventionsCasing": "snakeCase", so Convey turns the class name UserCreated into routing key user_created on both sides. The wire contract is the class name plus its JSON shape, and nothing enforces either.

Two consequences of that arrangement are worth stating before the findings, because both are load-bearing.

Exchange routing is a consumer-side declaration. The publisher's class carries no [Message] attribute; its exchange comes from the publisher's own rabbitMq.exchange.name setting. The consumer names the exchange it wants to bind to, by hand, in an attribute. A consumer that names the wrong exchange binds to nothing and fails silently — no error at startup, no error at runtime, just a queue that never receives. The message map and the wrong exchange is the Convey-side treatment of exactly this hazard.

The class name is the routing key. Rename StorySent to StoryPublished in the publisher and every consumer stops receiving, silently, because their queues are bound to story_sent and the publisher now emits story_published. There is no version field, no schema id, no content-type negotiation and no Format Indicator of any kind on any message in this estate.

The ledger, diffed

Fourteen contract classes exist as more than one hand-written copy across the microservice estate. I diffed every one of them, ignoring only the namespace and using lines:

Contract Copies Result
StorySent 4 drifted
UserCreated 3 drifted
StoryRated 3 identical
UserFollowed 3 identical
UserUnfollowed 3 identical
AdActionRejected 3 identical
StoryActionRejected 3 identical
UserLocked 2 identical
UserUnlocked 2 identical
AdApproved 2 identical
AdPaid 2 identical
AdPublished 2 identical
PayAd 2 identical
PublishAd 2 identical

Twelve clean, two drifted. That is exactly the ratio that makes copy-paste governance feel safe until it isn't — a system where six out of seven manual syncs are perfect does not teach anybody to distrust manual syncs.

Drift one: the field that governs authorisation

UserCreated is published by the Users service with three constructor parameters and consumed by two services with two:

Side Signature
Publisher — Users UserCreated(Guid userId, string name, string role)
Consumer — Analytics UserCreated(Guid userId, string name)
Consumer — Stories UserCreated(Guid userId, string name)

Role is published and silently dropped by both consumers. JSON deserialisation ignores unknown members, so there is no error, no warning, no log line and no test. The value arrives on the wire and evaporates on the way into the object.

The reason this is the worst of the two drifts is what Stories does with its user projection. It has a StoryAuthorPolicy with a CanCreate(user) gate — a policy that decides who is allowed to post. That policy operates on a user record built from an event that threw away the role. The one field that governs authorisation downstream is the one field the copies lost.

Drift two: the default that protects nothing

StorySent has four copies. Two of them differ, and the difference is a single default value:

Side visibility parameter
Publisher — Stories VisibilityModel visibility
Consumer — Analytics VisibilityModel visibility = null
Consumer — Timeline VisibilityModel visibility = null
Consumer — Pusher VisibilityModel visibility

Two consumers independently added a defensive = null; the third did not. The obvious reading — and the one I expected to confirm — is that the Pusher is therefore the fragile one, because part 11 showed it dereferences @event.Visibility.From unguarded.

Opening the other two handlers is what changed my mind. Trill.Services.Timeline/.../Handlers/StorySentHandler.cs:33-38:

Visibility = new Visibility
{
    From = @event.Visibility.From,
    To = @event.Visibility.To,
    Highlighted = @event.Visibility.Highlighted
}

And Trill.Services.Analytics/.../Handlers/StorySentHandler.cs:35-40 is the same four lines. All three consumers dereference Visibility unguarded. The = null default prevents nothing: it makes the constructor parameter optional for a binder that would have passed null for a missing member anyway, and then the handler walks straight into a NullReferenceException one statement later.

So the drift is real and the protection is imaginary. Which makes this a better lesson than the one I went looking for: the drift you can see is not the same thing as the risk you carry. Somebody in two repositories noticed a nullability concern, applied a fix that reads like diligence, and left the actual dereference untouched. A diff-based contract check would have flagged those two files and passed the third; the third is not meaningfully worse than the other two.

Worth adding, in fairness: no message published by this estate can trigger it. SendStoryHandler falls back to Visibility.Default(now) when the caller supplies no window, so Visibility is never null on the wire from Stories. The exposure is to a future publisher change or a hand-crafted message — which, given the gateway from part 5 publishes raw unvalidated JSON onto the stories exchange, is not as hypothetical as it sounds.

The same failure, one filetype up

The .proto files are copies too, and byte-identical ones. pusher.proto has the same MD5 in Trill.Pusher/src/Trill.Pusher/Protos/ and Trill.Web/src/Trill.Web.UI/Protos/. stories.proto has the same MD5 in Trill.Services.Stories/src/.../Infrastructure/Protos/ and Trill.Services.Ads/src/.../Core/Protos/. No .proto package, no buf registry, no build-time fetch — the same manual sync applied to interface definition language.

That they currently match is the strongest argument for the practice and the weakest possible guarantee: nothing would tell you when they stop. And one of them already carries the fingerprint of its copying, which part 14 picks up.

The registry that exists, four times

The estate does have a contract manifest mechanism, and it is genuinely good. Four services call:

.UsePublicContracts<ContractAttribute>()

Ads Extensions.cs:96, plus Analytics, Stories and Users. Convey's public-contracts middleware exposes a runtime JSON manifest of every [Contract]-marked type — a self-describing, machine-readable list of what a service considers its public shape. That is more contract governance than most estates have.

Then each of those four services defines its own private ContractAttribute marker class, in its own namespace, one copy per service. So the registry is four disconnected registries with no aggregator, no diffing tool and no consumer. Timeline, the gateway, the Pusher and the Saga do not expose it at all — and the two units publishing the most cross-cutting messages, the Saga and the Pusher, are precisely the two outside the manifest.

What I would actually do

Not a shared contracts package — I still think the consumer-driven copy is defensible for an estate this size. But the copy is only defensible with a mechanism, and there are three cheap ones, in ascending order of effort:

  1. A schema field on the wire. One int version or a content-type of application/vnd.trill.story-sent.v1+json, and a consumer can at least notice. Trill has none, which means evolving StorySent breaks every consumer simultaneously and silently — and, per the ledger above, already did.
  2. A generated manifest, diffed in CI. The UsePublicContracts machinery already produces exactly the JSON needed. Four services publish it; nothing collects it. A twenty-line script that fetches all four manifests and fails when a consumer's declared shape lacks a publisher's field would have caught the Role drop on the day it happened.
  3. Consumer-driven contract tests. The real answer, and the one this estate could not run anyway — all seven test projects in the microservice estate are empty.

Absent all three, the governance mechanism in Trill is a person remembering. It worked twelve times out of fourteen, which is a very good hit rate for a person and a completely inadequate one for a contract.

Next, the discovery that reading a single repository cannot produce: the second architecture, switched off by comment.