Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/local-contracts-verified-at-boot && read --section="top" 0%
Architecture

Local Contracts, Verified at Boot

Inflow lets each module own a private copy of every event it consumes, then type-checks those copies against the producer at application start. It is consumer-driven contract testing with no broker, no schema registry and about 280 lines - and the check is weaker than the ceremony suggests.

By Kumar Chandrachooda 20 Jan 2026 7 min read
Two shapes compared property by property before the doors open

Part 4 left the estate in an uncomfortable position: a module subscribes to an event by declaring a class with the same simple name, so a rename upstream is a silent unsubscribe and a property removal upstream is a silently null property downstream. Every consumer-driven contract tool I have used exists to close exactly that gap, and every one of them is a lot of machinery — a Pact broker, a schema registry, a CI job that publishes and verifies, a versioning policy.

Inflow closes it in two files and about 280 lines, with no infrastructure whatsoever. The mechanism is a class the consumer writes, and a reflection pass that runs during Startup.Configure and throws if the shapes disagree.

What a contract looks like

From Inflow.Modules.Wallets.Application/Owners/Events/External/CustomerCompleted.cs — both declarations live in one file, which is itself the design statement:

internal record CustomerCompleted(Guid CustomerId, string Name, string FullName, string Nationality) : IEvent;

[Message("customers")]
internal class CustomerCompletedContract : Contract<CustomerCompleted>
{
    public CustomerCompletedContract()
    {
        RequireAll();
    }
}

Wallets declares its own CustomerCompleted record with the four properties it wants, and next to it a contract saying “all of these must exist upstream, and upstream is the customers module”. The module then registers it during Use:

app.UseContracts()
    .Register<CustomerCompletedContract>()
    .Register<CustomerVerifiedContract>();

That is the entire author-facing API. Contract<T> gives you four protected methods — Require, Ignore, RequireAll, IgnoreAll — and you compose your requirement set in a parameterless constructor.

The expression parsing that is not expression parsing

Require takes a lambda and turns it into a dotted property path. The implementation is one of my favourite small oddities in the repository:

protected string GetName(Expression<Func<T, object>> expression)
{
    if (!(expression.Body is MemberExpression memberExpression))
    {
        memberExpression = ((UnaryExpression) expression.Body).Operand as MemberExpression;
    }

    if (memberExpression is null)
    {
        throw new InvalidOperationException("Invalid member expression.");
    }

    var parts = expression.ToString().Split(",")[0].Split(".").Skip(1);
    var name = string.Join(".", parts);

    return name;
}

The first six lines correctly unwrap the expression tree, handling the UnaryExpression box that appears when a value-typed property is returned as object. Then the result is discarded and the name is derived by calling ToString() on the whole expression and doing string surgery on "x => x.Address.City".

It works — Split(",")[0] handles multi-parameter lambdas, Split(".").Skip(1) drops the parameter name, and the join reassembles Address.City. But memberExpression is computed only to be validated and thrown away, and the string path is exactly what the expression tree was already holding in typed form. memberExpression.Member.Name plus a walk up the Expression property would give the same answer without depending on the compiler's ToString() format for expression trees. It is a small thing, but it is a load-bearing dependency on a debugging API.

RequireAll() is the escape from writing Require(x => x.Foo) five times. It walks the property graph recursively with FormatterServices.GetUninitializedObject, building dotted paths for nested classes. Every contract in the shipped estate uses it, and none of them use Require or Ignore directly.

The verification pass

ContractRegistry.Validate runs from Startup.Configure — after all middleware is wired, before UseEndpoints. The core of it:

var contractName = contract.Type.Name;
var module = messageAttribute.Module;
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}'.");
}

Walk it.

contract.Type.Name is the consumer's simple type name — the same routing key from part 4. The verifier and the router agree on what identity means, which is the property that makes this work at all.

The producer is found by namespace substring plus name. Every loaded type whose full name contains Inflow.Modules.customers is a candidate; exactly one of them must be called CustomerCompleted. SingleOrDefault will throw an InvalidOperationException — not the friendly ContractException — if the producing module has two types with that name in different sub-namespaces, which is a real possibility in a module with several vertical slices.

A missing producer is a boot failure. This is the payoff. Rename CustomerCompleted in the Customers module and the application does not start; it throws Contract: 'CustomerCompleted' was not found in module: 'customers' before serving a single request. The silent unsubscribe from part 4 becomes a loud crash — for contracted copies.

Then, per required property:

var originalContract = FormatterServices.GetUninitializedObject(originalType);
var originalContractType = originalContract.GetType();
foreach (var propertyName in contract.Required)
{
    var localProperty = GetProperty(contract.Type, propertyName, ...);
    var originalProperty = GetProperty(originalContractType, propertyName, ...);
    ValidateProperty(localProperty, originalProperty, propertyName, ...);
}

GetProperty walks the dotted path segment by segment and throws ContractException if any segment is missing. So a removed property upstream is also a boot failure. FormatterServices.GetUninitializedObject is used to materialise an instance without running a constructor — necessary because these are positional records whose constructors take arguments — and then only its GetType() is used, which means the instance itself is unnecessary. originalType would have done.

The check is weaker than it looks

ValidateProperty is where the ceremony outruns the guarantee:

private static void ValidateProperty(PropertyInfo localProperty, PropertyInfo originalProperty, ...)
{
    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(/* ... has a different type ... */);
}

Three early returns and one throw. The second early return is the interesting one: if both sides are non-string reference types, the check passes unconditionally. A consumer declaring Address Address { get; } against a producer declaring CustomerName Address { get; } is accepted, because both are classes.

That is not quite as bad as it first reads, because GetProperty recurses through dotted paths, so RequireAll() on a nested class produces entries like Address.City and those leaves get compared. What escapes is the shape of any nested object whose leaves you did not require, and any nested type substitution where the leaf names happen to match. The check is real for primitives and for the existence of every required path; it is a no-op for the identity of composite types.

The bigger gap is that JSON is what actually crosses the boundary, and JSON does not care about CLR types. The translator (part 7) serialises the producer's instance and deserialises into the consumer's type with PropertyNameCaseInsensitive = true. A property that changes from int to long upstream will fail the contract check but would have deserialised fine. A property that changes from string to a nullable value type may pass the check and produce a default at runtime. The verifier is checking CLR assignability where the wire is checking JSON compatibility, and the two are not the same relation.

Where the check runs, and why that placement matters

ValidateContracts is called from Startup.Configure, between the module loop and UseEndpoints:

app.UseModularInfrastructure();
foreach (var module in _modules)
{
    module.Use(app);
}

app.ValidateContracts(_assemblies);
app.UseEndpoints(endpoints => { /* ... */ });

The ordering is exactly right and worth naming, because it is the only place in the repository where a cross-cutting concern is sequenced deliberately rather than by luck of line order. Contracts are registered inside each module's Use, so validation cannot run before the loop. Validation throws, so it must run before anything serves traffic — and UseEndpoints is the last statement. Putting it between the two is the one point in the pipeline where the registry is complete and the application is not yet live.

The consequence is that a shape mismatch is a ContractException thrown from Configure, which the host surfaces as a failure to start. Not a 500 on the first request, not a degraded module, not a background retry: the process exits. For a modular monolith that is the correct severity, because there is no partial-availability story worth having when two modules disagree about a payload.

It also means the check runs on every boot, in every environment, including a developer's laptop. There is no CI job to configure, no pact to publish, no verification step to forget. The cheapest possible place to put a consistency check is the path everybody already runs a hundred times a day, and this is one of the few frameworks I have read that actually puts it there.

The cost of that placement is that Validate reflects over every type in every loaded assembly — assemblies.SelectMany(x => x.GetTypes()) — once per start, and holds the resulting list in a field for the duration of the pass. For twenty projects that is fine. It is also the third full assembly reflection in the boot path, after AddModuleRegistry and DbContextAppInitializer, none of which share their results.

Why it is still the best idea in the repository

I have been unfair for four paragraphs, so let me be precise about what this earns.

It is consumer-driven in the strict sense: the consumer states its requirements, the producer never states anything, and the check runs against whatever the producer currently is. That is the property Pact exists to provide, and Inflow gets it without a broker, a CI handshake, or a published pact file.

It is checked at boot, in the same process, against the actual loaded types. There is no version skew between what was verified and what is running, because they are the same assemblies in the same AppDomain. Every contract-testing tool I have used has an entire subsystem devoted to answering “was this pact verified against the version that is actually deployed”, and here the question cannot be asked because it cannot be false.

It is fail-fast. The application will not start. In a modular monolith that is exactly right: if the modules cannot agree on a shape, there is no partial-availability story worth having.

And it is fifteen lines of code per consumer, at most. The Wallets module protects both of its external event copies with a RequireAll() contract and two Register calls.

The catch is that opt-in means opt-out is the default, and the default is what most of the estate took. Of the eighteen consumer-side copies in the shipped solution, four carry a contract. That census — which four, which fourteen, and what each unprotected copy would do on a rename — is four of eighteen copies are checked.