Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/every-in-process-call-pays-for-messagepack && read --section="top" 0%
Architecture

Every In-Process Call Pays for MessagePack

Two lines in Trill's module client serialise and immediately deserialise every cross-module message, buying a future network hop at the cost of a round trip that never leaves the heap - and there is a benchmark project defending the choice.

By Kumar Chandrachooda 03 Jan 2026 7 min read
An object flattened into bytes and reassembled without leaving the box

The cheapest possible way for one module to hand a message to another module inside the same process is to pass the reference. It is free, it is one instruction, and it is the reason people build monoliths.

The Trill monolith refuses to do it. Every cross-module message — every event broadcast, every request, every response — goes through this (Modules/ModuleClient.cs:88-92):

private T TranslateType<T>(object value)
    => _serializer.Deserialize<T>(_serializer.Serialize(value));

private object TranslateType(object value, Type type)
    => _serializer.Deserialize(_serializer.Serialize(value), type);

Serialise to a byte array. Immediately deserialise it back. Nothing goes anywhere. Part 7 covered how modules keep their message shapes apart; this is how they keep their message objects apart, and it is a deliberate, expensive, well-argued decision.

What the round trip actually buys

Three things, and they are worth separating because only one of them is the headline.

Modules cannot share object references. After a TranslateType, the sender's ChargeFunds and the receiver's ChargeFunds are two distinct objects of two distinct types in two distinct assemblies. Mutating one cannot affect the other. Passing a List<string> cannot let a downstream handler modify an upstream aggregate's tags. In a monolith where every module lives in the same heap, that guarantee is otherwise unavailable at any price.

Extraction becomes a transport swap rather than a refactor. This is the stated motivation and it holds up. IModuleClient.SendAsync<TResult>(string path, object request) is HTTP-shaped: a string route, a request object, a typed response, a wire format. Replace ModuleClient with an implementation that puts those bytes on a socket and every call site is unchanged. Nothing in a handler is written against a shared reference, because there has never been one to write against. The code that would have to change during an extraction is exactly one class.

Contract violations surface as deserialisation failures rather than casts. If two modules disagree about a message shape badly enough, the round trip fails where the code is, not three frames later.

The price is paid on every message. A full serialise-plus-deserialise for a call that resolves inside the same await. For a ChargeFunds carrying two GUIDs and a decimal, that is real work — allocation, reflection-driven member access, a byte array that lives long enough to be read once and dropped — in place of a pointer copy.

The commented-out line with receipts behind it

The choice of format sits at Modules/Extensions.cs:98-99, and this is my favourite two lines in the repository:

// services.AddSingleton<IModuleSerializer, JsonModuleSerializer>();
services.AddSingleton<IModuleSerializer, MessagePackModuleSerializer>();

A commented-out registration is usually a smell — dead code somebody could not commit to deleting. Here it is the opposite, because JsonModuleSerializer is a complete, correct, tested implementation kept alive on purpose, and tests/Trill.Tests.Benchmarks/ModuleSerializerBenchmark.cs exists to compare them:

[HtmlExporter]
[MemoryDiagnoser]
[ThreadingDiagnoser]
internal class ModuleSerializerBenchmark
{
    private readonly IModuleSerializer _jsonSerializer = new JsonModuleSerializer();
    private readonly IModuleSerializer _messagePackSerializer = new MessagePackModuleSerializer();

    [Benchmark] public byte[] Json_Serialize() => _jsonSerializer.Serialize(_data);
    [Benchmark] public byte[] MessagePack_Serialize() => _messagePackSerializer.Serialize(_data);
    [Benchmark] public Data Json_Deserialize() => _jsonSerializer.Deserialize<Data>(_serializedJson);
    [Benchmark] public Data MessagePack_Deserialize() => _messagePackSerializer.Deserialize<Data>(_serializedMessagePack);
}
  • [MemoryDiagnoser] is the important attribute, not the timing. On a path that runs on every cross-module message, allocation is the number that matters, and the benchmark asks for it explicitly.
  • The benchmark reaches internal types, which is why Trill.Shared.Infrastructure/Extensions.cs:42 grants InternalsVisibleTo("Trill.Tests.Benchmarks"). The friend list records a measurement need.
  • A second benchmark widens the field. SerializerBenchmark.cs compares Newtonsoft, System.Text.Json and MessagePack, so the decision was taken against three candidates and not two.
  • The whole thing has no counterpart in the nine microservice repositories. Neither a benchmark project nor a performance project exists anywhere in the distributed build.

A benchmark project that exists to justify one line of DI registration is unusually good hygiene, and it appears only in the second build. Whatever else the rewrite did, it added the habit of measuring the decision you are about to bake into every call path.

The resolver choice, and what it quietly implies

One line inside the winner is worth reading slowly (MessagePackModuleSerializer.cs:8-9):

private readonly MessagePackSerializerOptions _options =
    MessagePack.Resolvers.ContractlessStandardResolverAllowPrivate.Options;

Contractless means no [MessagePackObject] or [Key] attributes are required — messages stay clean POCOs with no serialisation vocabulary leaking into module code, which matters a great deal when the whole point is that a message class is also a domain-adjacent type. Good call.

AllowPrivate is the consequential half. Look at any message in this codebase and you will find the same shape:

internal class ChargeFunds : ICommand
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public Guid CorrelationId { get; set; }
    public Guid UserId { get; }
    public decimal Amount { get; }

    public ChargeFunds(Guid userId, decimal amount) { UserId = userId; Amount = amount; }
}

UserId and Amount are { get; } only. There is no setter and no parameterless constructor. Without AllowPrivate, MessagePack cannot populate them. With it, the deserialiser writes directly to the compiler-generated backing fields.

That means every received message in this application is constructed without its constructor ever running. Any validation, normalisation or defaulting a message constructor performs is skipped on the receiving side. In this codebase the message constructors are pure assignment, so nothing is lost — but it is a standing invariant nobody has written down, and the day somebody puts a guard clause in a message constructor it will hold on the publishing side and not on the consuming side. The same property, incidentally, is what lets Guid.Empty arrive by accident.

The workaround is not exotic: keep message DTOs anaemic on purpose, and put the validation in the handler where it can produce a rejection event. That is what this repository does, and stating it as a rule would cost one comment.

Where the cost lands, and where it doesn't

Worth being precise about the blast radius, because “every in-process call” overstates it slightly.

The round trip runs on the IModuleClient path only: SendAsync for request/response, and PublishAsync for broadcast, where TranslateType(message, registration.ReceiverType) runs once per receiving registration. A StorySent with three consumers is serialised three times, not once, because each receiver needs its own local type. Within a module, ICommandDispatcher and IEventDispatcher hand the same object straight to the handler with no serialisation at all.

So the tax is exactly proportional to cross-module chatter, which is the correct place for it to be. A module that talks to nobody pays nothing. A module that fans an event to three others pays three times, which is also what it would pay over a network. The cost model of the in-process design already matches the cost model of the distributed one, which is the property that makes the extraction argument credible rather than aspirational.

The distributed build pays this cost too, of course — plus a connection, a broker hop, an acknowledgement and a network round trip. The comparison is not “serialisation versus free”; it is “serialisation versus serialisation plus RabbitMQ.” Read that way, the monolith is banking the difference and keeping the discipline.

The routing table it feeds

One detail makes the cost concrete, because it explains why a message has to be translated per receiver rather than once. The broadcast table is built by reflection at startup (Modules/Extensions.cs:86-124): every ICommand and IEvent class in every loaded assembly gets a registration whose action closes over the generic dispatcher method, and the routing key is ReceiverType.Namethe unqualified class name.

ModuleClient.PublishAsync looks up every registration whose path equals the publisher's type name, excludes the publisher itself, and filters the rest by the [Message] module attribute. So StorySent published by Stories reaches the StorySent declared inside Analytics and the one declared inside Timeline, each of which is a genuinely different CLR type in a different assembly — which is exactly why each needs its own TranslateType pass.

The consequence worth naming: cross-module routing is by simple class name, and nothing detects a collision. Two modules that both declared a Story or a Visibility in a message position would fan out to each other. The only guard is the [Message] attribute on the receiving type, and it is optional — a consumer that forgets it receives everything of that name from everywhere.

The judgement

This is the clearest example in the estate of a cost paid on purpose for a property that is otherwise unpurchasable, and the repository does the two things that make such a decision defensible: it keeps the road not taken alive as compiling code, and it measures.

If I were adopting the pattern I would change one thing. The serialiser is a singleton chosen at composition time for the whole application, which means the boundary discipline is all-or-nothing. A module that is provably never leaving — Saga, say, which is ten files and exists only to host handlers — pays the same tax as Users, which is the most extractable thing in the repository. Making the round trip a per-registration decision would let the tax follow the intent.

Next, the other thing this framework built from scratch and .NET shipped eighteen months later: minimal APIs, written by hand.