Two Hundred and Sixty-One Lines of Transport
The whole RabbitMQ adapter that makes Inflow's monolith talk to an extracted service is five files in one folder. Reading them shows how small a real transport swap can be - and where the fan-out quietly narrowed from every handler to exactly one.
The transport adapter is usually where “we can swap the message bus later” goes to die. You start with an interface and an in-memory implementation, and the day a real broker arrives you discover that the real broker needs connection lifecycles, channel pooling, serialisation, topology declaration, consumer registration, acknowledgement, retry and dead-lettering — and four thousand lines later the abstraction you were protecting has sprouted seven options objects.
Part 3 showed that Inflow's IMessageBroker did not move. This part is the folder that made that possible, and its total size is the headline: src/Shared/Inflow.Shared.Infrastructure/Messaging/RabbitMQ/ on origin/microservices is five files and 261 lines, usings and braces included.
| File | Lines | Job |
|---|---|---|
Extensions.cs |
23 | AddRabbitMQ() - the whole registration |
RabbitMqMessageBrokerClient.cs |
20 | Publish one message |
RabbitMqMessageSubscriber.cs |
39 | Bind a handler to a type |
CustomConventionsBuilder.cs |
89 | Exchange, routing key, queue name |
CustomRabbitMqInitializer.cs |
90 | Declare exchanges at startup |
It is small because it is not a broker client. It is an adapter over one — Convey.MessageBrokers.RabbitMQ, pinned at the floating version 1.0.*, which supplies the connections, channels, serialisation, retries and consumer plumbing. That is the right trade for a teaching repository and, honestly, for most production systems. This part walks the first three files. The conventions builder and the initializer get Part 5 and Part 6 to themselves, because between them they encode the entire wire contract.
Four lines, and one of them is load-bearing in a way nothing says
Here is the whole registration, verbatim:
public static IServiceCollection AddRabbitMQ(this IServiceCollection services)
{
services
.AddConvey()
.AddRabbitMq();
services.AddHostedService<CustomRabbitMqInitializer>();
services.AddSingleton<IMessageBrokerClient, RabbitMqMessageBrokerClient>();
services.AddSingleton<IMessageSubscriber, RabbitMqMessageSubscriber>();
services.AddSingleton<IConventionsBuilder, CustomConventionsBuilder>();
return services;
}
Stare at the last line. AddRabbitMq() — Convey's, lowercase q — registers Convey's own IConventionsBuilder. Inflow then registers CustomConventionsBuilder against the same interface. Microsoft.Extensions.DependencyInjection resolves a single service to the last registration, so the override works.
It works because of statement order, and only because of statement order. There is no services.Replace(...), no TryAddSingleton guard on the Convey side that would make the intent explicit, and no comment marking the line as order-sensitive. Move AddSingleton<IConventionsBuilder, CustomConventionsBuilder>() three lines up, above AddRabbitMq(), and it still compiles, still starts, still connects — and every routing key, exchange and queue name in the system silently reverts to Convey's defaults. Anything resolving IEnumerable<IConventionsBuilder> would get both; nothing does today.
This is the cleanest library-design lesson in the estate. A four-line AddX method whose correctness depends on the order of two adjacent statements, overriding a third party by shadowing rather than replacement, with no test and no assertion. And it is doubly instructive because the surrounding code has the inert version of the same pattern: in AddModularInfrastructure, services.AddRabbitMQ() sits immediately after services.AddMessaging(), and reversing those two changes nothing, because MessageBroker resolves IMessageBrokerClient at construction time. Some of the ordering is load-bearing and some is decorative, and nothing in the source distinguishes them.
Two smaller notes while we are here. AddRabbitMQ (capital MQ) sits one line from AddRabbitMq (lowercase q), which is the kind of near-collision that survives code review forever. And the client and subscriber are AddSingleton while IMessageBroker itself is AddTransient — fine today, because the singletons capture Convey's IBusPublisher and IBusSubscriber which are themselves long-lived, but it is a captive-dependency bug waiting for the day either becomes scoped.
Twenty lines of publisher
internal sealed class RabbitMqMessageBrokerClient : IMessageBrokerClient
{
private readonly IBusPublisher _publisher;
public RabbitMqMessageBrokerClient(IBusPublisher publisher)
{
_publisher = publisher;
}
public Task SendAsync(IMessage message, Guid messageId, CancellationToken cancellationToken = default)
=> _publisher.PublishAsync(message, messageId.ToString("N"));
}
One expression. Everything interesting about it is what it declines to pass. Convey's publishing interface is:
Task PublishAsync<T>(T message, string messageId = null, string correlationId = null,
string spanContext = null, object messageContext = null,
IDictionary<string, object> headers = null) where T : class;
Six parameters available; two supplied. No correlation id, no span context, no message context, no headers — even though, as Part 3 showed, the calling MessageBroker has a correlation id, a trace id and a user id in scope and logs all three on the line above. That omission is the whole of Part 7, and it turns out to be worse than “the correlation id is missing”.
Note also the generic parameter. SendAsync receives an IMessage, so T binds to the interface, not the record type. It works anyway because Convey's publisher resolves conventions from message.GetType() rather than typeof(T) — a detail one library down, under a floating version pin, on which every routing key in this system depends.
Thirty-nine lines of subscriber, and where fan-out narrowed
public IMessageSubscriber SubscribeEvent<T>() where T : class, IEvent
{
_busSubscriber.Subscribe<T>(async (serviceProvider, @event, _) =>
{
using var scope = serviceProvider.CreateScope();
await scope.ServiceProvider.GetRequiredService<IEventHandler<T>>().HandleAsync(@event);
});
return this;
}
Three things matter, and the first is a semantic change that nothing announces.
Inflow's in-process EventDispatcher publishes like this: scope.ServiceProvider.GetServices<IEventHandler<TEvent>>() — all registered handlers — awaited through Task.WhenAll. The RabbitMQ path uses GetRequiredService<IEventHandler<T>>() — exactly one, the last registration, and it throws if there are none.
| Handlers registered for T | In-process (EventDispatcher) |
Over RabbitMQ (RabbitMqMessageSubscriber) |
|---|---|---|
| 0 | no-op | throws InvalidOperationException |
| 1 | runs it | runs it |
| 2 or more | runs all, concurrently | runs the last registered, silently |
Only the middle row agrees. The in-process path is a Publish-Subscribe Channel; the remote path is single-handler dispatch. A module that legitimately wants two reactions to one event gets both in-process and one over the wire, with no error, no warning, and no test that would notice.
The top row is not hypothetical on this branch. SagaModule.Use subscribes SubscribeEvent<CustomerCompleted>(), and SagaEventHandler implements IEventHandler<CustomerVerified>, IEventHandler<WalletAdded>, IEventHandler<DepositCompleted> and IEventHandler<FundsAdded> — not CustomerCompleted. Grepping the branch for IEventHandler<CustomerCompleted> returns Payments, Wallets and a Wallets test, never Saga. So the queue saga/customers-service.customer_completed is declared, bound, and delivered to, and GetRequiredService throws on every single delivery, three retries apart, into a discard — because there is no dead-letter configuration anywhere on this branch.
The git history makes this one sharper rather than kinder. Messages/CustomerCompleted.cs existed in the Saga module at init as dead code. Master's .NET 6 commit deleted it, seven lines, along with an unused SignedUp.cs. The microservices branch, which forked before that cleanup, still had the record — and the transition commit gave it an [ExternalMessage] attribute and a subscription. One branch swept the dead message away; the other wired a queue to it. I read this as a real bug rather than a simplification: the subscription and the missing handler are in the same commit, and unlike the four places on this branch where the author leaves an explicit TODO comment, there is nothing here acknowledging it.
And the narrowing is also a validation regression. Before, a mismatched contract failed startup, loudly, in app.ValidateContracts(...). Now a missing handler fails at message-delivery time, on a background consumer thread, three retries later, into silence. The check moved from boot to runtime and from fatal to invisible in the same commit that made the boundary remote — which is Part 9.
To be fair to those thirty-nine lines, they get two things right that are easy to get wrong. The subscriber creates a DI scope per delivery, mirroring the in-process dispatcher, so DbContext and friends are per-message rather than captive. And because it resolves IEventHandler<T> from the container rather than calling the dispatcher, any Scrutor decorator registered over IEventHandler<> — including Inflow's inbox decorator — does still apply on the RabbitMQ path. It also declines to catch exceptions, letting them reach Convey's retry and nack machinery, which is the correct call.
What 261 lines actually means
The number is genuinely impressive and I do not want to walk it back. But two things inflate it in the adapter's favour.
Half the subscription API is unexercised. SubscribeCommand<T> has exactly two occurrences on the branch — its declaration and its implementation. Zero call sites. Every cross-service interaction here is either an event or an HTTP GET; there is no command-over-the-wire example at all, which for a teaching artefact about extraction is a meaningful gap, because a real extraction almost always needs one.
And the adapter is small partly because it carries almost nothing. There is no header mapping, no content-type negotiation, no schema version, no correlation propagation, no idempotency key handling, no poison-message policy — not because they were solved elsewhere, but because they were not carried across. A transport adapter that moves a payload and a Guid can afford to be 261 lines.
Next, the eight string literals that stand between this system and three modules quietly competing for the same events.