Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/the-contract-checker-they-built-after-the-drift && read --section="top" 0%
Architecture

The Contract Checker They Built After the Drift

The Trill monolith ships a startup-time validator that compares independently declared copies of the same message and crashes the app on a mismatch - the most original idea in either build, adopted by four declarations out of twenty-two.

By Kumar Chandrachooda 02 Jan 2026 7 min read
Two outlined shapes being measured against each other before assembly

Copy the event class. That is how every message-based system in this estate distributes contracts: Users publishes UserCreated, and Stories declares its own UserCreated with the fields it cares about, and Analytics declares a third, and Timeline a fourth. Nothing compiles them together, so nothing notices when one of them changes.

The microservices build did exactly this and shipped the consequences. The monolith did exactly this too — and then built a mechanism to catch it, which is the only piece of engineering in either column that has no counterpart anywhere else. Part 6 ended on a convention filling a gap; this is the gap being filled with a checker instead.

Where the contract lives

A consumer declares its local copy and nests the contract inside it (Stories.Application/Events/External/UserCreated.cs):

[Message("users")]
internal class UserCreated : IEvent
{
    public Guid Id { get; set; }
    public Guid CorrelationId { get; set; }
    public Guid UserId { get; }
    public string Name { get; }

    public UserCreated(Guid userId, string name) { UserId = userId; Name = name; }

    private class Contract : Contract<UserCreated>
    {
        public Contract() { RequireAll(); }
    }
}
  • [Message("users")] names the owning module. That attribute is how the validator knows where to go looking for the original — not a namespace convention, an explicit declaration.
  • The contract is a private nested class, which means it cannot be referenced from anywhere. It exists to be found by reflection and nothing else. GetContractType<T> scans every loaded type for one whose base is Contract<T> (ContractRegistry.cs:63-75).
  • RequireAll() walks the type at construction time (Contract.cs:39-52), materialising it with FormatterServices.GetUninitializedObject and recording every property as a dotted path, recursing into non-string class properties. So this contract requires Id, CorrelationId, UserId and Name.
  • The contract sits inside the type it constrains. No separate schema file, no .proto, no registry to keep in sync. If the class moves, its schema moves with it.

For finer control, Require(x => x.Foo) and Ignore(...) take expression trees, and IgnoreAll() clears the set. Nothing in this repository uses them; all five declared contracts call RequireAll().

The moment it runs

Registration happens in the module's middleware hook (StoriesModule.cs:40-42):

app.UseContracts()
    .Register<UserCreated>()
    .RegisterPath<GetUser, UserDto>("users-module/get-user");

And then, crucially, validation runs before routing does (Bootstrapper/Startup.cs:49-50):

app.ValidateContracts();
app.UseRouting();

A mismatch is a startup crash, not a runtime surprise. The application will not serve its first request with a broken cross-module contract. That is the entire value proposition, and it is bought with about 240 lines of reflection.

ValidateContract (ContractRegistry.cs:122-161) does the work: read the [Message] module, search every loaded type for one with the same simple name inside a namespace containing Trill.Modules.{module}, then for each required property compare the local declaration against the original.

Read the comparison rules precisely, because they decide everything

The whole judgement is three early returns and a throw (ContractRegistry.cs:163-186):

if (localProperty.PropertyType == typeof(string) && originalProperty.PropertyType == typeof(string))
    return;

if (localProperty.PropertyType.IsClass && localProperty.PropertyType != typeof(string) &&
    originalProperty.PropertyType.IsClass && originalProperty.PropertyType != typeof(string))
    return;

if (localProperty.PropertyType == originalProperty.PropertyType)
    return;

throw new ContractException(/* ... */);
  • A missing or renamed property is caught, and not here — GetProperty throws first (:197-202) when type.GetProperty(name) returns null, including for nested paths like Visibility.From.
  • A primitive type change is caught. DateTime against DateTime? are different types and neither is a class, so the third comparison fails and the exception fires. long against int, decimal against double, same.
  • Subsetting is permitted by design. The contract only requires what the consumer declared, so Stories legitimately dropping Role from its UserCreated passes. That is correct behaviour for consumer-driven contracts and it is the right default.
  • Nested class shapes are not compared. The second early return means any class-typed property matches any other class-typed property. Visibility of type VisibilityModel matches Visibility of type AuthorModel. The recursion in RequireAll() does reach the nested properties by dotted path, so Visibility.From is checked individually — but the container type itself is waved through.
  • Validation is consumer-driven and opt-in. A producer is never checked against consumers it does not know about, and a consumer that registers nothing is never checked at all.

The honest test: would it have caught the real drift?

The report I worked from claimed this checker would have caught the nullability drift the microservices build shipped in StorySent. I opened all four copies to confirm it, and it would not have. The claim is worth correcting carefully, because the correction is more interesting than the claim.

In the distributed build, StorySent is declared four times — by Stories (the producer), Analytics, Timeline and Pusher. The drift is real, but it is in the constructor, not the property:

// Analytics and Timeline
public StorySent(long storyId, AuthorModel author, string title,
    IEnumerable<string> tags, DateTime createdAt, VisibilityModel visibility = null)

// Stories and Pusher
public StorySent(long storyId, AuthorModel author, string title,
    IEnumerable<string> tags, DateTime createdAt, VisibilityModel visibility)

Two consumers made the parameter optional; the producer and the push service did not. And the one that did not is the one that dereferences it — Pusher/Events/External/Handlers/StorySentHandler.cs:19 opens with if (@event.Visibility.From <= DateTime.UtcNow).

Contract<T> compares property types. In all four copies the property is public VisibilityModel Visibility { get; } — identical, class-typed, straight through the second early return. The recursion checks Visibility.From as DateTime against DateTime, which passes. The checker would have validated this contract clean, because the divergence is in a constructor default argument and the checker never looks at constructors.

That is not a failure of the mechanism so much as a precise statement of its scope. It answers “does the shape I read match the shape you send?” It does not answer “do we agree about what may be absent?” — and the whole StorySent drift is a disagreement about absence, expressed in the one place C# lets you disagree about it silently.

The other documented drift fares no better. UserCreated publishes role and both consumers drop it; that is exactly the subsetting the checker permits on purpose. Neither of the two contract failures the microservices build actually shipped would have been caught by the mechanism built in response to them.

What it would catch is real all the same: a producer renaming UserId to AuthorId, a producer changing StoryId from long to Guid, a producer deleting a field a consumer still reads. Those are the common failures, they are the ones that produce silent nulls and zeros at runtime, and turning them into a startup crash is worth having.

The adoption gap is the finding

Count the declarations. Twenty-two types across the six modules carry a [Message(...)] attribute — five in Analytics, four in Timeline, six in Saga, four in Stories, and three in Ads counting the nested response types. Five of them declare a nested Contract. Four are actually registered:

Registration File
.Register<UserCreated>() StoriesModule.cs:41
.RegisterPath<GetUser, UserDto>("users-module/get-user") StoriesModule.cs:42
.RegisterPath<ChargeFunds, ChargeFunds.Response>("users-module/charge-funds") AdsModule.cs:28
.RegisterPath<SendStory, SendStory.Response>("stories-module/send-story") AdsModule.cs:29

Timeline registers nothing. Analytics registers nothing. Saga registers nothing. The three modules that consume the most cross-module events — and Timeline is the module with the fatal defect in part 12 — are entirely outside the safety net.

Worse, two of them cannot opt in without a code change. Stories.Application/Events/External/UserLocked.cs and UserUnlocked.cs both carry [Message("users")] and neither has a nested Contract class. Call .Register<UserLocked>() and GetContractType<T> throws ContractException("Contract was not found for: 'UserLocked'...") at startup (ContractRegistry.cs:74). The mechanism's own opt-in path fails loudly for two of the types it was built to protect, which is at least an honest failure, but it means the adoption gap is a code gap and not just a registration gap.

Three of the five public registration overloads — RegisterPath(string), RegisterPathWithRequest<T>, RegisterPathWithResponse<T> — are never called anywhere. The API is wider than its use.

What to take from it

I have not seen this pattern in another sample codebase, and it directly answers a documented failure by the same authors in the same product — the estate's own contract-drift post-mortem is the input, and this is the output. The idea of putting the schema inside the type as a private nested class is genuinely nice: it cannot go stale independently, it cannot be forgotten in a separate folder, and it costs six lines.

But the mechanism has the shape every optional safety net has. It is well built, it is switched on where somebody remembered, and it covers roughly a fifth of the surface it was designed for. A checker that must be registered per message is a checker that documents which messages somebody was worried about, not which messages are at risk. The version of this that would have earned its keep is the one that runs by default: validate every [Message]-attributed type at startup, require an explicit [NoContract] to opt out, and let the twenty-two declarations defend themselves.

Next, the tax every one of those cross-module messages pays on the way through: every in-process call pays for MessagePack.