Four of Eighteen Copies Are Checked
Inflow's boot-time contract verifier is excellent and almost unused. A census of every consumer-side type copy in the estate shows four protected and fourteen not - and the two failure modes are a startup crash and a silently null property.
Part 5 described a boot-time contract verifier that turns a silent unsubscribe into a startup exception. Whether that is worth anything depends entirely on how much of the estate it covers, so I counted.
On master, across the four business modules and the Saga, there are eighteen types that are a module's local copy of a type owned by another module. Four of them carry a Contract<T>. Here is the whole census, gathered by finding every type declared in an External/ folder plus the Saga module's Messages/ folder, and matching each against the module that actually publishes it.
| Consuming module | Local copy | Owned by | Contract |
|---|---|---|---|
| Customers | SignedUp |
Users | yes |
| Customers | UserStateUpdated |
Users | yes |
| Wallets | CustomerCompleted |
Customers | yes |
| Wallets | CustomerVerified |
Customers | yes |
| Wallets | DepositAccountAdded |
Payments | no |
| Wallets | DepositCompleted |
Payments | no |
| Wallets | WithdrawalStarted |
Payments | no |
| Payments | CustomerCompleted |
Customers | no |
| Payments | CustomerLocked |
Customers | no |
| Payments | CustomerUnlocked |
Customers | no |
| Payments | CustomerVerified |
Customers | no |
| Payments | FundsDeducted |
Wallets | no |
| Payments | DeductFundsRejected |
Wallets | no |
| Saga | CustomerVerified |
Customers | no |
| Saga | DepositCompleted |
Payments | no |
| Saga | WalletAdded |
Wallets | no |
| Saga | FundsAdded |
Wallets | no |
| Saga | AddFunds |
Wallets | no |
Two consuming modules protect everything they consume. Three protect nothing. The verifier's coverage is not a per-type decision anybody made; it is a per-module habit.
What a protected copy does on a rename
Take the Wallets copy of CustomerCompleted. It sits in one file with its contract:
internal record CustomerCompleted(Guid CustomerId, string Name, string FullName, string Nationality) : IEvent;
[Message("customers")]
internal class CustomerCompletedContract : Contract<CustomerCompleted>
{
public CustomerCompletedContract() => RequireAll();
}
Now suppose someone in the Customers module renames FullName to LegalName. The next start-up runs ContractRegistry.Validate from Startup.Configure, finds CustomerCompleted in the Inflow.Modules.Customers namespace, walks the four required property names, and GetProperty fails on the third:
Property: 'FullName' was not found in contract: 'CustomerCompleted'
(module: 'wallets') from module: 'customers'.
The process does not start. Somebody notices within seconds, on their own machine, before the change reaches a branch. That is the outcome the whole mechanism exists to produce, and it works.
What an unprotected copy does
The Payments copy of the same event is a single line with no contract, no attribute, and no companion class:
internal record CustomerCompleted(Guid CustomerId, string FullName, string Nationality) : IEvent;
Same rename, different day. Nothing at boot notices, because nothing registered a contract for this type. At runtime, Customers publishes its CustomerCompleted, ModuleClient matches on the simple type name, and the translator does its JSON round trip. LegalName in the payload has no matching property in the Payments record, so it is discarded; FullName in the Payments record has no matching property in the payload, so it stays at its default — null for a string.
The handler runs. It receives a CustomerCompleted with a real CustomerId, a real Nationality, and a FullName of null. Whatever it writes to the Payments database is now missing a field, permanently, with no exception, no warning and no log line that looks different from a successful delivery. The only trace is the data.
That asymmetry is the finding. The same upstream change produces a startup crash in one module and silent data corruption in another, and the difference is two lines in a file nobody was required to write.
Why the fourteen have no contracts
I want to resist the easy reading that this is neglect, because the pattern in the table is too clean for that.
The two modules with contracts are Customers and Wallets. Both are the modules the Building Modular Monolith course spends the most time on — Wallets is the only module with unit, integration and end-to-end test projects, and Customers is the module the README's walkthrough starts from. The three without are Payments, which is the largest module by file count and reads like it was written fastest, and the Saga, which is a five-file demonstration of one pattern.
Read that way, the four contracts are not a coverage failure; they are the two worked examples. The mechanism is demonstrated twice, in two directions, on two different producing modules, with one of them (SignedUpContract) showing a cross-module event and the other (CustomerCompletedContract) showing a copy that deliberately keeps a property Payments drops. If you are teaching local contracts, that is the right number of examples to write out and the wrong number to ship.
There is also a genuine argument for not contracting everything, which the estate makes accidentally. RequireAll() requires every property on the local copy, which means a consumer that deliberately trims its copy — Payments dropping Name — pays no price, but a consumer that keeps a property “just in case” has now made the producer unable to remove it. A contract is a coupling you are choosing to add, and choosing it per property rather than per type is what Require exists for. Nobody in this estate uses Require; all four contracts call RequireAll().
The gap that has no fix in this design
There is one thing the verifier structurally cannot catch, and it is worth naming because it applies to the four protected copies too.
ContractRegistry.Validate compares the consumer's required properties against the producer's type. It never asks whether a producer's type has a consumer at all. So the reverse failure — a producing module renames an event type, and the consumer's copy is now an orphan nobody publishes to — is caught only when a contract exists, and then only because the lookup by name fails. Delete the producing type entirely and the contracted consumers crash at boot while the uncontracted ones simply stop receiving anything, forever, with Task.WhenAll over an empty list returning successfully every time.
The information to detect this is sitting right there. ModuleRegistry holds every broadcast registration keyed by simple type name, and ContractRegistry.Validate already receives the full assembly list. A key with exactly one registration is a message type nobody else in the estate consumes — sometimes correct, often a rename that lost its other half. Logging that set at Warning on every boot is perhaps fifteen lines:
// Fresh illustrative code, not from the repository.
var byName = assemblies
.SelectMany(a => a.GetTypes())
.Where(t => t.IsClass && typeof(IMessage).IsAssignableFrom(t))
.GroupBy(t => t.Name)
.Where(g => g.Count() == 1)
.Select(g => g.Single().FullName)
.ToArray();
if (byName.Any())
{
logger.LogWarning("Message types with no counterpart in any other module: {Types}", byName);
}
Not an exception — plenty of message types are legitimately module-internal, and a domain event never crosses a boundary at all. A warning is the right severity for “this looks like half a pair”.
The lesson worth taking
Opt-in verification produces exactly the coverage its authors had time for, and the coverage map is not the one anyone would have designed. Four of eighteen is not a scandal in a teaching repository with no CI and no deployment; it would be a serious problem in a system where each of those fourteen copies is a data-loss path.
The distilled version, which I now apply to every consumer-driven-contract setup I meet: if a contract is optional, count the copies before you trust the mechanism. A verifier that covers 22% of the boundary is not a safety net, it is a sample. Inflow's is well built, fast, free of infrastructure, and applied to less than a quarter of the surface it could protect — and the estate would be measurably safer if Register<T>() were replaced by a boot-time scan that found every Contract<T> in every assembly automatically, exactly the way it already finds every handler.
Next, the round trip that copies every message — the JSON serialise-then-deserialise that performs the projection these contracts are checking, and the byte[] signature that gives away where this design was heading.