Contract Checking Left When the Network Arrived
Inflow's monolith failed startup when two modules disagreed about the shape of a shared event. The microservices branch deletes that check from exactly the boundary that became remote - and it turns out the deletion was not optional.
Contract checking is one of those disciplines whose value is inversely proportional to how easy it is to do. Inside one process, where both sides are types in the same AppDomain, verifying that two records agree is trivial and nearly unnecessary — the compiler is already halfway there. The moment the boundary becomes a network and a deploy, verification is the only thing standing between you and a silently dropped field, and it is suddenly hard.
Part 8 left the extracted module switched off but still in the tree. This part is about the mechanism that was checking that module's contracts, what happened to it, and why what happened was not a choice.
What the monolith had
Inflow's modules deliberately do not share a contracts assembly. Each module declares its own copy of every external event it consumes — “local contracts” — which is the whole reason there are no project references between modules. The obvious risk is equally deliberate: two copies can disagree.
So master has a checker. Alongside each consumed record sits a contract class:
// Inflow.Modules.Wallets.Application/Owners/Events/External/CustomerCompleted.cs, on master
internal record CustomerCompleted(Guid CustomerId, string Name, string FullName, string Nationality) : IEvent;
[Message("customers")]
internal class CustomerCompletedContract : Contract<CustomerCompleted>
{
public CustomerCompletedContract()
{
RequireAll();
}
}
RequireAll() walks the record's properties reflectively — recursing into nested classes — and adds each one to a required set. The module registers the contract in its Use method, and the Bootstrapper's Startup.Configure finishes with app.ValidateContracts(_assemblies).
ContractRegistry.ValidateContract then does the interesting part:
var originalType = _types
.Where(x => x.FullName is not null &&
x.FullName.Contains($"Inflow.Modules.{module}", StringComparison.InvariantCultureIgnoreCase))
.SingleOrDefault(x => x.Name == contractName);
if (originalType is null)
{
throw new ContractException($"Contract: '{contractName}' was not found in module: '{module}'.");
}
It takes the module name from [Message("customers")], searches every loaded type for one named CustomerCompleted whose full name lives under Inflow.Modules.Customers, and then compares each required property's type against the consumer's copy. A missing property or a changed type throws, and the throw happens inside Configure — the application does not start.
That is a genuinely strong property for a modular monolith, and it is one of the more original things in the repository. Two modules that disagree about an event cannot both be running.
The deletion was not optional
On origin/microservices, the same file is four lines shorter:
[ExternalMessage("customers", queue: "wallets-module/customers-service.customer_completed")]
internal record CustomerCompleted(Guid CustomerId, string Name, string FullName, string Nationality) : IEvent;
The contract class is gone; a routing attribute has taken its place. My first reading was that this was a regression by inattention — that the author swapped one attribute for another and did not notice the check falling out. Reading ContractRegistry properly, it is not. It could not have been kept.
Follow the lookup. originalType is searched for under Inflow.Modules.Customers, among _types, which comes from the assemblies the module loader chose to load. The Customers module is disabled, so its assemblies are never loaded, so no type named CustomerCompleted under that namespace exists in the process. originalType is null. ContractException is thrown from Configure. The Bootstrapper would not boot.
So the contract mechanism did not merely stop being used at this boundary. It is structurally incapable of following the module out of the process, because its notion of “the other side” is hard-coded to a type loaded in the same AppDomain whose namespace begins Inflow.Modules.. There is no version of Contract<T> that reaches across a network. Deleting those classes was the only way to start the application.
That reframes the criticism, and I think it makes it more useful rather than less: the validation was built on the one property the extraction removes — co-location — and nothing was built to replace it.
The registry that now validates nothing
Which leads to the strangest fact on the branch. services.AddContracts() is still registered in AddModularInfrastructure. app.ValidateContracts(_assemblies) is still the second-to-last statement in the Bootstrapper's Configure. The whole machinery — IContract, Contract<T>, ContractRegistry, RequireAll, the property walker, the type comparer — is still compiled, still wired, still running at every startup.
And on this branch, exactly one module still calls UseContracts():
// CustomersModule.Use, on origin/microservices — unchanged
app.UseContracts()
.Register<SignedUpContract>()
.Register<UserStateUpdatedContract>();
That is the Customers module. The disabled one. Use is only called on modules the loader discovered, and it did not discover this one.
So ValidateContracts runs against a registry containing zero contracts and zero paths, iterates two empty collections, and returns successfully. The startup gate that guaranteed cross-module contract agreement is still in the pipeline, still passing, and covering nothing. If a future module added a contract for a still-loaded module it would light up again — but for every contract that actually crosses a boundary today, it is a no-op that looks like a safeguard.
There is a version of this article that treats that as damning. I would rather treat it as the most instructive thing in the branch, because it is the archetype of how safety mechanisms die: not deleted, not disabled, not argued about — just quietly emptied of subjects while the call site stays exactly where the reviewers expect to see it.
From boot-time-fatal to delivery-time-invisible
Put the two regimes side by side. The failure being detected is the same one in both columns: the producer's copy of an event and the consumer's copy have drifted.
master |
origin/microservices |
|
|---|---|---|
| What is compared | required properties, name and type | nothing |
| When | application startup | never |
| Where | Startup.Configure, main thread |
n/a |
| Consequence | process refuses to start | field silently absent on the consumer |
| Who sees it | whoever ran it, immediately | nobody |
And the neighbouring check went the same way. Part 4 showed the subscriber resolving handlers with GetRequiredService, which throws when no handler is registered — a condition that on master would have been a contract or wiring problem caught at boot, and here surfaces on a background consumer thread, three retries later, into a discard. Both checks moved from boot to runtime and from fatal to invisible, in the same commit that made the boundary remote.
The specific unprotected failure is worth spelling out because it is so ordinary. Add a field to the producer's CustomerCompleted — a string Email, say — deploy the service, do not redeploy the monolith. There is no schema, no content-type naming the contract, no version header, and nothing anywhere on this branch that carries a Format Indicator. The consumer's deserialiser sees an extra property, ignores it, and hands the handler a record with the old shape. Every event now silently loses a field, forever, with no log line on either side. The reverse — removing a field the consumer requires — is quieter still: the property deserialises to null or default, and your business logic runs on it.
What would have worked
The interesting question is not “why did they delete it” but “what could have replaced it”, and there are three real answers, none of them large.
- Move the check to publish time, in the producer. The producer knows its own record. A startup assertion that every published event type carries a version attribute, plus a content-type header naming
<contract>@<version>, gives the consumer something to reject on. That is a Format Indicator, it is a handful of lines, and its absence is the root of everything above. - Make the consumer strict. Deserialising with
JsonSerializerOptionsconfigured to reject unknown members converts “field silently added” into a loud failure — the wrong loudness for production, the right one for a staging environment. - Keep
Contract<T>and give it a remote source. The mechanism's only monolith-shaped assumption is where it findsoriginalType. Point it at a schema file published by the producer's build instead of at a loaded assembly, and the sameRequireAllwalker, the same property comparison and the same startup throw all still work. That is consumer-driven contract testing, arrived at from the other direction, and this repository was about ten lines of indirection away from it.
None of that is a criticism of the teaching artefact's scope — a course branch demonstrating a transport swap is not obliged to ship a schema registry. But the branch's own framing is that extraction is cheap because the seam held, and this is the clearest place where the seam held and something else, unremarked, did not.
Next, four lines from a switch: the Null Object that would have made this entire branch a configuration flag instead of a fork in the road.