Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/serialize-deserialize-deliver && read --section="top" 0%
Architecture

Serialize, Deserialize, Deliver

ModularMonolith routes events between modules by class name and converts them by JSON round-trip - a one-line Message Translator that lets modules share contracts without sharing types, at a price the next part collects.

By Kumar Chandrachooda 09 Nov 2025 4 min read
A shape entering a prism and leaving as a re-angled copy of itself

The hardest question in a modular monolith is not how modules talk — it is what language they talk in. Share a contracts assembly and every module is coupled to it; the monolith grows a shared kernel that follows you into every future extraction. Keep contracts private and you need a way for Conferences' ConferenceCreated to become Tickets' ConferenceCreated when the two are different CLR types that have never met. Part 5 read the channel that carries messages; this part reads the machinery that answers the language question — three small classes in Shared.Infrastructure/Modules/ that route by name and translate by JSON.

A registry built from a scan

At startup, AddModuleRegistry sweeps every loaded assembly for event classes and wraps each in a registration:

var eventTypes = assemblies
    .SelectMany(x => x.GetTypes())
    .Where(x => x.IsClass && typeof(IEvent).IsAssignableFrom(x))
    .ToArray();

services.AddSingleton<IModuleRegistry>(sp =>
{
    var registry = new ModuleRegistry();
    var eventDispatcher = sp.GetRequiredService<IEventDispatcher>();

    foreach (var eventType in eventTypes)
    {
        var targetType = eventType;
        var registration = new ModuleBroadcastRegistration(targetType, Handle);
        registry.AddBroadcastRegistration(registration);

        Task Handle(object @event) =>
            (Task) eventDispatcher.GetType()
                .GetMethod(nameof(IEventDispatcher.PublishAsync))
                ?.MakeGenericMethod(eventType)
                .Invoke(eventDispatcher, new[] {@event});
    }

    return registry;
});

Two things deserve a slow read. First, the local function Handle is a hand-rolled bridge from the untyped world to the typed one — an Adapter that closes over eventType, uses MakeGenericMethod to conjure PublishAsync<ConferenceCreated> at runtime, and casts the reflective result back to Task. It works, and it carries a buried hazard: if GetMethod ever returned null, the null-conditional chain would make Handle return a null Task, and the eventual Task.WhenAll would throw an ArgumentNullException several stack frames from the actual cause. Reflection bridges fail loudly or confusingly; this one chose confusingly.

Second, what the registration is:

public sealed class ModuleBroadcastRegistration
{
    public Type TargetType { get; }
    public Func<object, Task> Handle { get; }
    public string Key => TargetType.Name;
    // ...
}

Key => TargetType.Name is the entire topic system. Not the full name, not an attribute, not a registry file — the simple class name. Two types called ConferenceCreated in different modules are, by definition, the same logical event. The lookup in ModuleRegistry confirms it: _broadcastRegistrations.Where(x => x.Key == key) returns every registration whose type name matches, namespaces be damned. There is even a defensive tell that the author knew how sharp this blade is — AddBroadcastRegistration throws InvalidOperationException if a type arrives without a namespace, a guard against accidentally matching compiler-generated or global types on bare name alone.

The translator

Delivery happens in ModuleClient, and its centrepiece is one expression:

public async Task PublishAsync(object message)
{
    var key = message.GetType().Name;
    var registrations = _moduleRegistry.GetBroadcastRegistration(key);

    var tasks = new List<Task>();
    foreach (var registration in registrations)
    {
        var handle = registration.Handle;
        var translatedMessage = TranslateType(message, registration.TargetType);
        tasks.Add(handle(translatedMessage));
    }
    await Task.WhenAll(tasks);
}

public static object TranslateType(object @object, Type targetType)
    => JsonSerializer.Deserialize(JsonSerializer.Serialize(@object), targetType);

TranslateType is the estate's most important line: serialize the publisher's event to JSON, deserialize into the subscriber's copy. In EIP terms PublishAsync is a Recipient List (find every matching registration) feeding a Message Translator (re-shape the payload per recipient). In DDD terms the subscriber's copied record is an anti-corruption layer built by duplication — Tickets owns its own ConferenceCreated under Events/External/ and depends on nobody's assembly.

The deep idea deserves stating plainly: modules communicate by structural contract — matching property names across a serialisation boundary — exactly as they would over a real wire. The JSON round-trip is not a hack; it is a rehearsal. If Conferences and Tickets were separate services with RabbitMQ between them, their coupling surface would be identical: a topic name and a set of JSON property names. The monolith practises the discipline of distribution while paying in-process prices. This is the same trade DShop makes estate-wide, where eight services copy every contract locally — the modular monolith inherits the pattern from its microservice siblings and shrinks it to one process.

What the trick costs

The prices are real, and each is visible in the source.

Every publish pays double serialisation per recipient. Serialize once per registration, deserialize once per registration — for an in-process call that would otherwise be a reference pass. At one event per conference-created this rounds to zero; the design would feel it at volume.

The publisher subscribes to itself. The startup scan registers every IEvent class — including the publisher's own. When Conferences publishes ConferenceCreated, the registry returns two registrations: Tickets' copy, and Conferences' own type. The client dutifully serialises the event and deserialises it back into its own type, then dispatches it to a handler list that is empty (Conferences handles nothing). One guaranteed no-op translation and dispatch per publish, forever. A Where clause excluding the message's own type would remove it; nothing in the code distinguishes “my event” from “someone's copy”, because nothing in the model records ownership at all.

Structural contracts fail silently, field by field. System.Text.Json, deserialising into a type whose property has no matching name in the payload, does not throw — it leaves the property default. A renamed property does not break delivery; it delivers null. The event still arrives, the handler still runs, the logs stay green. There is no compiler for this contract, no schema check, no version field (part 5's absence table), and so no moment at which anyone is told.

That last cost is not hypothetical in this estate. The final commit of the repository renamed a property in exactly one of the two ConferenceCreated records, and the translator has been quietly delivering null into the other side ever since. That story — the complete lifecycle of a contract-by-copy, from decoupling triumph to silent break — is the centrepiece of the series, and it is next: one rename broke the only contract.