Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/the-interface-that-did-not-move && read --section="top" 0%
Architecture

The Interface That Did Not Move

Inflow's IMessageBroker is byte-identical on the monolith branch and the microservices branch, and not one handler, saga or controller changed to accommodate a real broker. That is a genuine result - and the reason it worked is also the reason for everything that goes wrong later.

By Kumar Chandrachooda 29 Jan 2026 6 min read
One interface, unchanged, with a different world on each side of it

“We can swap the transport later” is the most-made and least-tested claim in application architecture. Everybody puts an IMessageBus in front of their in-process dispatcher, everybody says the words at the design review, and almost nobody ever finds out — because the swap either never happens, or happens as a rewrite big enough that nobody goes back to check whether the abstraction earned its keep.

Part 2 got us to an honest 108-file transition commit. This part is about the thirteen of those files that touch Inflow's shared framework, and about the one file that did not.

Three new files, zero changed files

Start with the abstractions project. Inflow.Shared.Abstractions is 53 files: commands, events, queries, the domain kernel, module contracts, messaging. It is the assembly that every module compiles against and the only thing they have in common. If a real broker were going to leak into the application, this is where you would see it.

$ git diff master origin/microservices --stat -- src/Shared/Inflow.Shared.Abstractions
 .../Messaging/ExternalMessageAttribute.cs | 18 ++++++++++
 .../Messaging/IMessageBrokerClient.cs     | 10 ++++++
 .../Messaging/IMessageSubscriber.cs       | 10 ++++++
 3 files changed, 38 insertions(+)

Three new files. Thirty-eight added lines. Zero changed lines and zero deletions across the entire abstractions assembly. Not one existing signature moved.

And the file the whole exercise was a test of:

$ git rev-parse master:src/Shared/Inflow.Shared.Abstractions/Messaging/IMessageBroker.cs
1e98e6f05aee606da39975f57ff06017ce673ade
$ git rev-parse origin/microservices:src/Shared/Inflow.Shared.Abstractions/Messaging/IMessageBroker.cs
1e98e6f05aee606da39975f57ff06017ce673ade

Same blob hash. Not “compatible”, not “source-compatible after a rename” — the identical bytes:

public interface IMessageBroker
{
    Task PublishAsync(IMessage message, CancellationToken cancellationToken = default);
    Task PublishAsync(IMessage[] messages, CancellationToken cancellationToken = default);
}

That is the strongest single fact on this branch, and it is worth being precise about what it means. It does not mean the system got a broker for free. It means the publishing contract that every command handler, event handler and saga in five modules depends on was correct enough that adding RabbitMQ under it required no negotiation with any of them.

Thirty-nine lines across four modules

The claim only matters if the call sites stayed still too, so here is the module-side ledger from the transition commit, excluding migration-designer churn:

File +/- What changed
PaymentsModule.cs +7 A Subscriptions() block for four events
Payments Events/External/*.cs (4) +2 each One attribute line per record
SagaModule.cs +5 A Subscriptions() block for two events
Saga Messages/*.cs (2) +2 each One attribute line per record
WalletsModule.cs +4 / -4 UseContracts() block becomes Subscriptions()
Wallets Events/External/*.cs (2) +1 / -10 each Contract class deleted, attribute added
UsersController.cs +9 One new HTTP endpoint

Thirty-nine inserted lines and twenty-four deleted, across four modules. Strip the Users endpoint — which is a synchronous HTTP concern, not messaging — and the entire asynchronous integration cost of moving a module out of process, spread over four surviving modules, is thirty inserted lines. No command handler changed. No event handler changed. No saga changed. No controller, entity, value object, repository or DbContext changed. I looked for exceptions; the numstat above is the whole list.

Here is what a consumer module's registration became:

// PaymentsModule.Use, on origin/microservices
app.Subscriptions()
   .SubscribeEvent<CustomerCompleted>()
   .SubscribeEvent<CustomerLocked>()
   .SubscribeEvent<CustomerUnlocked>()
   .SubscribeEvent<CustomerVerified>();

No queue name. No exchange. No routing key. No handler delegate, no retry policy, no options lambda. A type parameter and nothing else — and it reads almost exactly like the in-process registration it replaced. Whatever else this branch gets wrong, that ergonomic result is real, and it is why a reader skimming the modules comes away believing the extraction was cheap.

The implementation took the whole hit

So where did the change go? Into MessageBroker — the class behind IMessageBroker, which on origin/microservices is the same file path as master's Messaging/Brokers/InMemoryMessageBroker.cs, renamed in code but not on disk. It gained one constructor dependency and one statement.

On master, publishing has exactly one outgoing path per call:

foreach message: register a MessageContext, log it
if (_outboxBroker.Enabled) { await _outboxBroker.SendAsync(messages); return; }   // exclusive
await Task.WhenAll(asyncDispatcher-or-moduleClient .PublishAsync)

On origin/microservices, it has two, always:

foreach (var message in messages)
{
    var name = message.GetType().Name.Underscore();
    var module = message.GetModuleName();
    var messageId = Guid.NewGuid();
    if (!string.IsNullOrWhiteSpace(module))
    {
        // ... register MessageContext, log request/message/correlation/trace/user ids
        messageId = messageContext.MessageId;
    }

    // Publish an external message to the real message broker (not just in-memory), make use of outbox etc. when needed
    _logger.LogInformation("Publishing an external message: {Name} [Message ID: {MessageId}]...", name, messageId);
    await _brokerClient.SendAsync(message, messageId, cancellationToken);
}

if (_outboxBroker.Enabled)
{
    await _outboxBroker.SendAsync(messages);
    return;
}

Read that ordering slowly, because three things are decided by it.

The external send is inside the loop and above the outbox check. On master, an enabled outbox is an exclusive path — the message is persisted and the in-process dispatch happens later, from the outbox processor. Here the broker send happens first and unconditionally, and only then does the outbox get its turn. The transactional guarantee the outbox exists to provide covers the in-process leg and not the network one. The author's own comment says so — “make use of outbox etc. when needed” — which makes this a documented teaching simplification rather than a silent bug, but it does mean the branch demonstrates the Transactional Outbox in the topology where it matters least and skips it in the topology where it is mandatory.

There is no [ExternalMessage] filter. Nothing here asks whether the message is meant to leave the process. Ten types on the branch carry that attribute; every other internal event — WalletAdded, DepositCompleted, FundsAdded and the rest — is serialised and pushed to a topic exchange where nothing is bound to receive it. The in-process bus and the broker both carry the full traffic. That is Part 6's problem, because it is the conventions builder that decides where those orphans land.

The module guard is a boundary the extracted service falls outside. GetModuleName() derives a module from the namespace, and Inflow.Services.Customers.Core.* is not a *.Modules.* namespace, so it returns empty. The service therefore never registers a MessageContext at all — its messageId is the bare Guid.NewGuid() from the top of the loop, correlated with nothing. Hold that thought for Part 7.

And note the await inside the loop: publishing an array of N messages is N sequential broker round-trips, where master's in-process path was a single Task.WhenAll.

It held because it promised almost nothing

Why did this abstraction survive a change that breaks most of them? Look at what IMessageBroker does not say. There is no topic. No queue. No partition key, no routing key, no headers dictionary, no delivery mode, no priority, no TTL, no ordering guarantee, no acknowledgement, no return value. It takes something that implements IMessage and a CancellationToken, and it returns a Task.

An abstraction survives a transport change in almost exact proportion to how little it promised — because every promise is a place where a real transport can disagree with you. Inflow's messaging interface promised one thing, “this message is now somebody else's problem”, and that promise is true over channels, over RabbitMQ, and over anything else you would plausibly put underneath it.

That is the design lesson, and it is a genuinely good one: the portable seam is the narrow one.

The asterisk

It is also, precisely and unavoidably, the reason for the next nine parts.

In-process, everything the interface declined to say was still available. The correlation id lived in an ambient IContext. The message id lived in an IMemoryCache keyed on the object instance. The handler ran in the caller's DI scope, inside the caller's transaction, on the caller's thread, and threw exceptions the caller could catch. None of that had to appear in the signature, because none of it had to travel.

What survived of that envelope when the message started travelling is one method:

Task SendAsync(IMessage message, Guid messageId, CancellationToken cancellationToken = default);

A message and a Guid. No correlation id, though the publisher has one in hand and logs it two lines earlier. No trace id. No user id. No content type, no schema version. The narrowness that made the seam portable is the same narrowness that emptied the envelope, and nobody widened it back.

The seam held. Next, the two hundred and sixty-one lines that hold it up — five files in one folder, and the four-line registration method whose correctness depends entirely on the order of two adjacent statements.