A Guard for the Drift, None for the Rule
Inflow throws at startup if a module's local copy of another module's event has changed shape. The rule the whole repository exists to demonstrate - that no module may reference another - is protected by nothing at all.
Part 8 ended on two design changes that slid through a 519-file diff because nothing in the repository could have caught them. That is true, and it is not the whole picture, because Inflow does carry an automated guard. It runs on every boot, it throws hard, and it protects a genuinely subtle invariant.
It just does not protect the one on the front of the box.
The guard that exists
Startup.Configure, line 46, between the module registrations and the endpoint mapping:
app.ValidateContracts(_assemblies);
That call reaches ContractRegistry.Validate, which walks every registered contract, resolves the original type from the module that owns it, and compares property by property:
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}'.");
}
and then, for each required property, checks that the local declaration and the original agree:
throw new ContractException($"Property: '{propertyName}' in contract: '{contractName}' ...
has a different type (actual: '{originalProperty.PropertyType}', expected: '{localProperty.PropertyType}').");
A ContractException during Configure takes the process down. This is a fail-fast startup check, and it is the right shape for the problem.
Why that problem needs a guard at all
Understand what it is defending and the design reads as thoughtful rather than fussy.
Inflow's modules may not reference each other, so when the Customers module wants to handle the Users module's SignedUp event, it cannot use the Users module's type. It declares its own:
[Message("users")]
internal record SignedUp(Guid UserId, string Email, string Role) : IEvent;
internal class SignedUpContract : Contract<SignedUp>
{
public SignedUpContract()
{
RequireAll();
}
}
There are now two SignedUp records in the solution, in two assemblies, with no compiler edge between them. Serialisation matches them by type name, not by identity. So if someone widens Role to an enum in Users, or renames Email, the Customers copy keeps compiling, keeps deserialising, and starts producing wrong or empty values at runtime.
Nothing in the C# type system can see that. It is exactly the class of drift that only a reflective, boot-time check can catch, and building one was the correct instinct. RequireAll() walks the type recursively, including nested class properties, so a contract declared that way covers the full shape.
Its coverage is partial — four of the eighteen locally re-declared message copies in the estate carry a Contract<>, so fourteen are unchecked, and the companion series The Framework Underneath takes that apart in detail.
The comparison itself is also deliberately loose. ValidateProperty returns without complaint when both properties are string, and again when both are non-string classes, so it only ever fails on a value-type mismatch:
if (localProperty.PropertyType.IsClass && localProperty.PropertyType != typeof(string) &&
originalProperty.PropertyType.IsClass &&
originalProperty.PropertyType != typeof(string))
{
return;
}
Swap a nested Address class for a differently shaped one and the guard waves it through; change a Guid to a string and it throws. That is a defensible line to draw — deep structural comparison across two assemblies gets expensive and false-positive-prone fast, and the missing-property check above it already catches renames. But it means the guard is narrower than it looks. Coverage and depth aside, the mechanism is real, it runs on every boot, and it throws.
The rule that has no guard
Now the other invariant. From the README:
there's no reference between the modules at all (such as shared projects for the common data contracts)
Part 5 verified that this is literally true across all thirty-four ProjectReference edges in the twenty project files. So what stops it becoming false?
I looked for every mechanism that could, and recorded the search for each:
| Mechanism | Present |
|---|---|
Directory.Build.props with an MSBuild check |
Absent — no Directory.Build.props anywhere in the estate |
Directory.Packages.props, nuget.config, global.json |
Absent |
| Architecture-test package | Absent — all 20 .csproj read; the only test packages are Microsoft.NET.Test.Sdk, xunit, xunit.runner.visualstudio, coverlet.collector, Microsoft.AspNetCore.Mvc.Testing, NSubstitute, Shouldly. No NetArchTest, no ArchUnitNET, no Mono.Cecil |
| Analyzer or ruleset | Absent — no analyzer package, no <EnableNETAnalyzers>, no <AnalysisMode>, no <TreatWarningsAsErrors>, no <CodeAnalysisRuleSet> |
.editorconfig |
Absent |
| CI | Absent on every branch. git ls-tree -r --name-only <ref> filtered for ^\.github, .yml, .yaml, Dockerfile, .sh and .ps1 returns exactly one line on all six branches, and that line is docker-compose.yml |
The estate's headline invariant is a convention held in one person's head. The only thing standing between Inflow and a <ProjectReference Include="..\..\Users\Inflow.Modules.Users.Core\..."> is that somebody would have to type it, and nothing anywhere would notice if they did. Not a build error, not a failing test, not a warning, not a review gate — the repository has no pull-request template either.
Which of the two is more likely to break
Here is the fair version of the argument, because there is a real one on the other side.
The contract-shape invariant breaks by accident. A developer editing SignedUp in the Users module is not thinking about Customers, cannot see Customers from where they are standing, and has no compiler feedback. Accidental breakage needs a machine to catch it.
The reference invariant breaks only by deliberate act. Adding a ProjectReference requires opening a .csproj or clicking through a dialogue, and you cannot do it absent-mindedly. On that reading, protecting it is lower value, and the author's allocation of effort is defensible.
I do not find that convincing here, for one reason specific to what this repository is. It is a teaching repository. Its entire purpose is to be opened by people who are learning the pattern for the first time — including, on the two workshop branches, rooms full of people explicitly instructed to add cross-module integration code. The single most likely mistake a student makes when told “make Customers react to a Users event” is to add the reference. That is not a hypothetical failure mode; it is the first-order one, and it is the one the estate is silent about.
A deliberate act by someone who does not yet know the rule is indistinguishable, in a diff, from a deliberate act by someone who does.
What it would have cost
Not much, and there is no need to add a dependency. There is even an obvious home for it: Inflow.Shared.Tests.EndToEnd already declares the full xunit, Test.Sdk and Mvc.Testing package set and contains zero [Fact]s, so dotnet test discovers it and finds nothing. One test — fresh code, written for this article — would fill it:
[Fact]
public void No_module_assembly_references_another_module_assembly()
{
const string prefix = "Inflow.Modules.";
var moduleAssemblies = Directory
.EnumerateFiles(AppContext.BaseDirectory, $"{prefix}*.dll")
.Select(Assembly.LoadFrom)
.ToList();
var violations =
from assembly in moduleAssemblies
let owner = ModuleOf(assembly.GetName().Name)
from reference in assembly.GetReferencedAssemblies()
where reference.Name?.StartsWith(prefix) is true
let target = ModuleOf(reference.Name)
where target != owner
select $"{assembly.GetName().Name} -> {reference.Name}";
violations.ShouldBeEmpty();
static string ModuleOf(string name) => name[prefix.Length..].Split('.')[0];
}
Roughly twenty lines, no new package, and it runs wherever dotnet test runs. The one thing it needs that this estate does not have is somewhere to run automatically — which is the second half of the same absence.
An MSBuild version is even smaller. A Directory.Build.props at src/Modules/ with a target that inspects @(ProjectReference) and calls <Error> on any path leaving the current module directory would fail the build, not the test run, and would therefore work for a student who never types dotnet test. For a teaching repository, failing at build time with a message that explains the rule is worth more than any test, because the error message is the lesson.
The rule I would take from it
There is a general form of this and it is uncomfortable, because the failure is not laziness — the author of this estate demonstrably cared about drift, thought hard about it, and built a reflective startup validator to catch it.
Guard the invariant your architecture is named for, first — even if it is the one that feels too obvious to break. Subtle invariants attract guards because they are interesting to defend. Load-bearing ones go unguarded because everyone involved can currently see them, and “everyone involved” is a set that changes.
Inflow has a runtime check for message shapes and nothing at all for the module graph. If exactly one of those two had to exist, it should have been the other one.
The absence of a guard is not the same as an absence of intent. This repository is full of decisions that were clearly made on purpose and left unrecorded, and there is exactly one place where any of them is written down. Next, seven choices and one comment.