One Rename Broke the Only Contract
The last commit of ModularMonolith renames Name to ConferenceName in the subscriber's copy of its one integration event - and the JSON translator has delivered null into that field ever since. Anatomy of a silent contract break.
Every messaging architecture has one failure mode it is most naturally blind to, and for structural contracts it is the rename. Not the deleted field, which at least leaves a visible gap; not the type change, which often throws; the rename — where both sides remain valid, both compile, both serialise, and the data simply stops arriving. The DevMentors ModularMonolith repository contains a complete, verifiable specimen of this failure, committed by its own authors in the repo's final change. Part 6 read the translation machinery; this part reads the wound.
The two records
The estate has exactly one integration event. The publisher's side, internal to Conferences at Conferences.Core/Events/ConferenceCreated.cs:
internal record ConferenceCreated(Guid Id, string Name, int? ParticipantsLimit) : IEvent;
And the subscriber's copy, equally internal to Tickets at Tickets.Core/Events/External/ConferenceCreated.cs:
internal record ConferenceCreated(Guid Id, string ConferenceName, int? ParticipantsLimit) : IEvent;
They were identical for four months. Then commit 60840b2 — 2021-08-23, the same commit that shipped the entire messaging machinery the contract rides on — changed one word on the Tickets side:
- internal record ConferenceCreated(Guid Id, string Name, int? ParticipantsLimit) : IEvent;
+ internal record ConferenceCreated(Guid Id, string ConferenceName, int? ParticipantsLimit) : IEvent;
The publisher still says Name. git show 60840b2 confirms no corresponding change on the Conferences side, then or ever. The repository's last act was to sever half of its only cross-module agreement.
What happens on every dispatch
Follow one publish through the machinery from parts 5 and 6. ConferenceService.AddAsync saves the row and publishes new ConferenceCreated(conference.Id, conference.Name, conference.ParticipantsLimit). The broker enqueues it; the pump hands it to ModuleClient; the registry matches both types named ConferenceCreated; and for the Tickets registration, TranslateType runs:
{"Id":"1e7c...","Name":"NDC Oslo","ParticipantsLimit":500}
System.Text.Json deserialises that payload into Tickets' record. Id matches, ParticipantsLimit matches — and ConferenceName has no source property, so it gets default. No exception, no log entry, no dropped message. Tickets receives a perfectly delivered event whose name field is null, on every single dispatch, permanently. Delivery succeeded; the contract failed; the system cannot tell the difference.
Why nothing noticed
The estate is almost architecturally arranged to keep this invisible, and each layer of the blindness is worth naming because each is common in real systems.
The handler never touches the broken field. ConferenceCreatedHandler.HandleAsync logs @event.Id and returns. Had it logged the name, someone running the sample might have seen Received event ... conference (null) — but consuming only the fields you need is normal, even good practice, and it means a contract can rot in the fields nobody currently reads.
The type system has no jurisdiction. Both records are internal to different assemblies; the compiler never compares them. That is by design — part 6 made the case that this decoupling is the pattern's virtue — but it means the only checker that could have flagged the rename was excused from the room.
There are no tests. The solution contains ModularMonolith.Tests.EndToEnd, an xunit project created in the very first skeleton commit with test SDK packages and zero source files. The thinnest imaginable end-to-end test — POST a conference, assert the handler logged its name — fails loudly against this bug. The scaffold has been advertising that test since January 2021 without containing it.
And nobody was watching. This is a teaching repository; its last commit was the finale, not the start of an operational life. The bug shipped into a codebase whose purpose was already served. That softens the blame and sharpens the lesson at once, because production systems reproduce exactly this pattern — the contract break lands in the release where attention has already moved on.
What would have caught it
Ranked by cost, cheapest first — and every row is technology that existed in 2021:
- A consumer-driven contract test. Serialize the publisher's event, deserialise into the subscriber's copy, assert no non-nullable property came back
default. Ten lines, no infrastructure, runs in milliseconds. In this estate it is the single highest-value test nobody wrote. The Pact-shaped version of this idea gets a full treatment in when contract testing pays for itself. - A version field on
IMessage. The empty marker interface from part 5 means a subscriber cannot even express “I understand ConferenceCreated v1”. With a version, the rename becomes a deliberate v2, and the mismatch a detectable error instead of a silent default. - A shared schema, not shared types. Keep contracts-by-copy, but check both copies against one JSON Schema in CI. Ownership stays with the publisher, drift becomes a build failure.
- A registry with ownership. The
ModuleRegistryknows every event type in the process; it is one query away from noticing that two types with the same key disagree on property names. The estate builds the perfect vantage point and never looks through it.
Notice what is not on the list: sharing the record in a common assembly. That fixes the rename by reintroducing the coupling the whole design exists to avoid — one shared kernel, every module rebuilt for any contract change, and the extraction story of part 14 poisoned. DShop's estate demonstrates both answers side by side; the copy is the right call. It just cannot be the only call.
The durable lesson
Contracts-by-copy moves the enforcement burden from the compiler to you, and the transfer is invisible until it fails. The distilled rule: the moment two copies of a contract exist, the diff between them is production state — something must read that diff, because the runtime will not. A structural contract does not break like glass; it breaks like a bearing, quietly, field by field, while every dashboard stays green.
The rename decided what arrives broken. The delivery mode decides when and whether anything arrives at all — and that is a single boolean with two distinct failure modes, next.