The Round Trip That Copies Every Message
Inflow isolates modules by serialising every message to JSON bytes and deserialising it into the receiver's own type - once per receiver. It is the Message Translator done well, and the byte array in the signature tells you exactly which transport it was built for.
Part 6 counted eighteen places where one module keeps a private copy of another module's type. Those copies have to be filled somehow, and the mechanism that fills them is two lines long:
private T TranslateType<T>(object value)
=> _moduleSerializer.Deserialize<T>(_moduleSerializer.Serialize(value));
private object TranslateType(object value, Type type)
=> _moduleSerializer.Deserialize(_moduleSerializer.Serialize(value), type);
That is ModuleClient in Inflow.Shared.Infrastructure, and it is the entire isolation boundary of the estate. Serialise the sender's instance, immediately deserialise it into the receiver's type, hand the result to the receiver's handler. Enterprise Integration Patterns calls this a Message Translator; this implementation calls it nothing at all.
Why a round trip is the right answer here
The naive alternative — cast the object and hope — cannot work, because the two types are genuinely different CLR types in different assemblies with no common base beyond IEvent. The next alternative — reflection-based property copying — would work, but you would have to write and maintain the mapper, and it would need rules for nested objects, collections, nullability and name casing. The third alternative — a shared contracts assembly — is the one part 1 showed this repository deliberately refusing.
A serialisation round trip gets all of that for free, and gets three properties that matter:
Structural, not nominal, matching. The receiver's type does not have to inherit from, implement, or reference anything the sender knows about. If the property names line up, the value arrives.
Projection by omission. A receiver declaring three of the sender's four properties simply does not receive the fourth. That is the Content Filter pattern, implemented by not writing code, and it is the cleanest expression of it I have seen. Payments' CustomerCompleted drops Name; nothing anywhere says "drop Name".
A real copy. Every receiver gets its own instance. No two handlers can observe each other's mutations, and a handler that keeps a reference to the message is not holding a reference to the publisher's object graph. In a monolith where everything shares a heap, that is a genuine isolation guarantee bought for the price of an allocation.
The serialiser itself is deliberately forgiving:
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true
};
Case-insensitive matching only, no camelCase policy, no converters. Because both sides are C# records with PascalCase properties, the payload is PascalCase JSON going out and PascalCase JSON coming back, and the case-insensitivity is belt-and-braces. Note that this is not the same serialiser as IJsonSerializer/SystemTextJsonSerializer, which the outbox and the HTTP layer use and which does apply a camelCase policy plus a string enum converter. Two serialisation configurations, two purposes, one codebase — worth knowing before you change either.
Where it is invoked, and how often
ModuleClient.PublishAsync calls TranslateType inside the loop over receivers:
foreach (var registration in registrations)
{
// ... attribute gate ...
var action = registration.Action;
var receiverMessage = TranslateType(message, registration.ReceiverType);
// ... register context, then:
tasks.Add(action(receiverMessage, cancellationToken));
}
Once per receiver, not once per publish. Publishing CustomerVerified from Customers reaches three receivers — Payments, Wallets and the Saga — and performs three complete serialise/deserialise cycles of the same source object. The serialisation is repeated because each cycle targets a different type, which is unavoidable for the deserialise half but entirely avoidable for the serialise half: the bytes are identical every time. Hoisting Serialize(message) out of the loop is a one-line change that removes a third of the work in the common case and more as receivers are added.
SendAsync — the synchronous request/response path — translates twice per call, once for the request and once for the response:
var receiverRequest = TranslateType(request, registration.RequestType);
var result = await registration.Action(receiverRequest, cancellationToken);
return result is null ? null : TranslateType<TResult>(result);
So a cross-module query like customers/get costs two full JSON round trips on the way to a value that never leaves the process.
The byte array that gives the game away
Here is the serialiser interface, in full:
public interface IModuleSerializer
{
byte[] Serialize<T>(T value);
T Deserialize<T>(byte[] value);
object Deserialize(byte[] value, Type type);
}
And the JSON implementation:
public byte[] Serialize<T>(T value)
=> Encoding.UTF8.GetBytes(JsonSerializer.Serialize(value, SerializerOptions));
public T Deserialize<T>(byte[] value)
=> JsonSerializer.Deserialize<T>(Encoding.UTF8.GetString(value), SerializerOptions);
JsonSerializer.Serialize produces a string. It is then encoded to UTF-8 bytes, handed to Deserialize, and immediately decoded back to a string. Nothing in this process needs bytes. The encode and the decode are pure loss — a complete extra pass over the payload in each direction, doubling the allocation, for a value that is consumed by the very next statement.
System.Text.Json even ships the shortcut: JsonSerializer.SerializeToUtf8Bytes and the ReadOnlySpan<byte> overload of Deserialize would keep the byte-oriented signature and skip the string entirely, which is measurably faster than the string path. That is not what happens here.
So why is the signature byte[] at all? Because of what sits three lines below it in Modules/Extensions.cs:
services.AddSingleton<IModuleSerializer, JsonModuleSerializer>();
// services.AddSingleton<IModuleSerializer, MessagePackModuleSerializer>();
MessagePackModuleSerializer is fully implemented, ten lines, using ContractlessStandardResolverAllowPrivate so it can serialise positional records with init-only properties. The MessagePack 2.4.35 package reference is still in the .csproj. The registration is commented out, sitting immediately under the live one.
MessagePack is a binary format. Binary only makes sense across a boundary — you would never choose it over JSON for an in-process copy, where the only thing that matters is round-trip fidelity. A byte[] signature plus a commented-out binary alternative plus a MessageEnvelope record separating message from context plus an OutboxMessage row carrying CorrelationId, TraceId, UserId, Name and Type — that is a broker's header/body split, persisted, for messages that currently travel by object reference. And IModuleClient.SendAsync takes a string path, not a method reference.
This abstraction is shaped for a transport it does not have. The repository has a microservices branch where exactly that swap is made, which is its own series; the point here is that the intent is legible in a method signature on master, four years before you read it. That is a good thing to be able to see in someone else's code, and it is worth building your own abstractions so it can be seen in yours.
What the round trip silently permits
There is a category of change the translator will absorb without complaint, and it is worth knowing which, because the contract verifier and the translator do not agree on what “compatible” means.
A property added upstream is dropped. Harmless, and the whole point.
A property removed upstream leaves the consumer's property at its default — null for a string, Guid.Empty for a Guid, 0 for a decimal. A missing amount and a zero amount are indistinguishable at the receiving handler, which in a payments domain is the difference between “we did not get told” and “the transfer was for nothing”.
A property renamed upstream is the same case, and the JSON serialiser's PropertyNameCaseInsensitive = true widens it slightly: fullname, FullName and FULLNAME all bind, so a casing change is absorbed while a word change is not.
A widening type change — int to long, float to double — round-trips fine through JSON and fails the contract check, because ValidateProperty compares CLR types for equality. The verifier is stricter than the wire here.
A narrowing type change — long to int upstream, with a value that does not fit — throws a JsonException at deserialisation time, inside a handler, on a background thread, where the async dispatcher catches it and logs. The verifier would have caught this one at boot only if a contract existed.
So the two mechanisms disagree in both directions: the verifier rejects some changes the wire tolerates, and tolerates some the wire rejects. Neither is wrong on its own terms — one is checking CLR assignability, the other JSON binding — but they are checking different relations, and the mental model most readers will build (“the contract check tells me whether delivery will work”) is not quite true.
The change that would close most of the gap is small. Contract<T> already knows the required property paths; validating against the serialised shape rather than the reflected type — serialise an uninitialised producer instance and check the JSON has the paths — would test the relation that actually governs delivery.
What the round trip costs, honestly
Let me be concrete rather than hand-wavy. For a four-property record with two Guids and two short strings, one full cycle is: reflection-driven property enumeration (cached by System.Text.Json after first use), a string build of maybe 150 characters, a UTF-8 encode into a 150-byte array, a UTF-8 decode back into a string, a parse, and a constructor invocation. On modern .NET that is low single-digit microseconds. Multiply by receivers, and by messages per second.
For a modular monolith serving a payments demo, this is free. For a system doing 10,000 events a second with five subscribers each, it is 50,000 round trips a second and you will see it. The design is not wrong — the isolation is worth paying for — but it is worth knowing that the price is paid per subscriber and that two of the four allocations in it are removable without changing anything about the model.
The lesson I take from it: a translation boundary is the correct place to spend cycles, and exactly the place to check that you are not spending them twice. The serialise-per-receiver and the UTF-8 detour are both invisible in a code review and both obvious in a flame graph.
Next, the bus that never leaves the process — the broker that calls all of this, the unbounded channel behind it, and what happens to everything still queued when the host shuts down.