Four Lines From a Switch
Inflow's transport seam is one method wide and genuinely transport-agnostic, but it has exactly one implementation and no default. A four-line Null Object and one if statement would have turned a fork in the road into a configuration flag.
Part 9 was about something that could not have been kept. This part is the opposite: something that could have been kept for four lines, and would have changed what kind of artefact this branch is.
Here is the question that gets you there. Given that IMessageBroker did not change, that no handler changed, and that the transport adapter is 261 lines in one folder — what configuration on origin/microservices gives you the pre-extraction system back?
None. There isn't one.
One implementation, no default
Grep the branch for IMessageBrokerClient and you get three files: the interface, RabbitMqMessageBrokerClient, and the _brokerClient field on MessageBroker. There is no NullMessageBrokerClient, no InMemoryMessageBrokerClient, no no-op default anywhere.
MessageBroker takes it as a required constructor dependency:
public MessageBroker(IModuleClient moduleClient, IAsyncMessageDispatcher asyncMessageDispatcher,
IContext context, IOutboxBroker outboxBroker, IMessageContextRegistry messageContextRegistry,
IMessageBrokerClient brokerClient, MessagingOptions messagingOptions, ILogger<MessageBroker> logger)
and the only registration of that interface lives inside AddRabbitMQ(). Two consequences follow immediately:
- If
AddRabbitMQ()is not called,IMessageBrokercannot be resolved at all. The container throws on the first injection into any handler — aMessageBrokerwith no broker client is not a degraded system, it is an unstartable one. AddRabbitMQ()is called unconditionally, inAddModularInfrastructurefor the monolith and inInflow.Services.Customers.Core.Extensions.AddCorefor the service.
Here is the composition root, exactly as the transition commit left it:
services.AddEvents(assemblies);
services.AddDomainEvents(assemblies);
services.AddMessaging();
services.AddRabbitMQ();
services.AddSecurity();
No flag, no options check, no if. On this branch, RabbitMQ is not a transport choice; it is a load-bearing wall.
The codebase already has the idiom
What makes this a wiring decision rather than a design limitation is that the two methods on either side of it do exactly the right thing.
AddMessaging() gates an optional hosted service on configuration:
if (messagingOptions.UseAsyncDispatcher)
{
services.AddHostedService<AsyncDispatcherJob>();
}
AddOutbox() gates most of its registrations, including a Scrutor decorator and three hosted services, on outboxOptions.Enabled — and returns early with the singletons still registered so that anything depending on IOutboxBroker still resolves. That second detail is precisely the pattern the broker client needed: register a benign implementation always, register the real one conditionally.
So the author knows this idiom, uses it twice within twenty lines, and does not apply it to the one registration on the branch where it would have mattered most.
Four lines
The seam is one method wide and takes no transport-specific types. A Null Object implementation is this:
internal sealed class NullMessageBrokerClient : IMessageBrokerClient
{
public Task SendAsync(IMessage message, Guid messageId, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
Register it as the default inside AddMessaging(), make AddRabbitMQ() conditional, and the composition root becomes:
services.AddMessaging(); // registers NullMessageBrokerClient
if (messagingOptions.UseExternalBroker)
{
services.AddRabbitMQ(); // last registration wins
}
That is four lines of class and one if. Nothing else on the branch has to change — not MessageBroker, not the modules, not the abstractions, not the conventions builder. The DI container's last-registration-wins behaviour, which Part 4 showed this code already relies on for IConventionsBuilder, does the rest.
What that flag would buy
It is worth being concrete about why I care, because “add a feature flag” is otherwise just tidiness.
One artefact, two topologies. With the flag, the same build boots as a modular monolith or as monolith-plus-service depending on appsettings. Without it, the two topologies are two branches, and moving between them is a checkout, a rebuild and a redeploy of everything.
Rollback without a redeploy. Part 8 praised copy-and-disable for making the module side of the cutover a config flip. The broker side is not: you can re-enable the Customers module in configuration, but you cannot stop the monolith publishing everything it does to RabbitMQ, because that path has no off switch. The strangler-fig property is only half fitted.
Local development without infrastructure. As shipped, dotnet run on the Bootstrapper requires a reachable broker. CustomRabbitMqInitializer.StartAsync opens a connection and declares exchanges synchronously, with no try/catch, so an unavailable RabbitMQ aborts host startup. The sibling hosted service, DbContextAppInitializer, wraps its extensible initializer loop in a try/catch and logs failures. Two hosted services in the same host, opposite failure policies, neither documented.
A migration you can canary. This is the real one. The entire subject of this branch is choreography — how you get from one process to two without a big-bang cutover. A configuration switch lets you run the monolith with the broker enabled but the service not yet deployed, verify the publishing side in production, then start the service. A fork gives you one atomic jump.
The seam is not the limitation here. The wiring is. Everything the counterfactual needs — a narrow interface, a container that supports overriding, an options object, an established gating idiom — is already present in the same file.
A fork is a branch that cannot come home
There is a second-order cost, and Part 2 already measured it without naming it.
Because the change is unconditional, origin/microservices can never be merged into master. Merging it would force every reader of the default branch into RabbitMQ. So the branch is not a feature branch awaiting review; it is a permanent alternative universe, and permanent alternative universes drift. Master took a .NET 6 port, an encapsulation pass that made 88 types internal, and a package update in July 2022. The microservices branch took its own .NET 6 port and nothing since — it is still pinned to Microsoft.EntityFrameworkCore.Design 6.0.1 where master has 6.0.7, and behind on Npgsql, Humanizer and Serilog too.
The branch documenting how this estate does microservices has been rotting relative to the branch people actually read, and the reason it cannot be kept current is the same unconditional registration. With the flag, all of this would have been one branch, one merge, and one line of configuration — and the fifty-one one-line diffs that Part 2 had to filter out would never have existed, because there would have been nothing for the two trees to drift apart about.
The decorators that left with it
One more thing rode along in the same commit, and it is worth reading as a symptom of the same absence of a switch. git show 35a9859 on the composition root:
services.AddMessaging();
+services.AddRabbitMQ();
services.AddSecurity();
services.AddSingleton<IClock, UtcClock>();
services.AddSingleton<IDispatcher, InMemoryDispatcher>();
-services.AddLoggingDecorators();
services.AddPostgres();
One line added, one line removed, in the same hunk. master still has AddLoggingDecorators() at line 100; origin/microservices does not, and never got it back. LoggingCommandHandlerDecorator<T>, LoggingQueryHandlerDecorator<T> and LoggingEventHandlerDecorator<T> are all still in the tree on the branch, fully implemented, registered by nobody.
I read this as collateral damage rather than intent, and the evidence is in what was not done: there is no comment, no configuration flag, no #if, and the decorator classes were left in place rather than deleted. It has the texture of a line lost while editing an adjacent one.
The effect, though, is the thing. Those decorators are what put a log line with a message id and a correlation id around every command, query and event handler in the monolith. The commit that introduced a network hop is the commit that removed per-handler observability from the process on one side of it — which is the moment you most need to be able to say “the handler ran, here, with this id”. Combined with Part 7's finding that the correlation id crossing the wire is a fresh random Guid, the branch's debugging story is materially worse than the monolith's in two independent ways introduced by one commit.
In fairness
For a teaching repository, a fork has one real advantage over a flag: you can git diff two branches and see the whole delta as a single artefact. That is exactly what this series has been doing, and a configuration-switched version would have hidden the change inside conditionals and made the lesson harder to read, not easier. If the goal is “show a reader what extraction touches”, the fork is defensible and possibly correct.
But the README's framing is migration — “the sample module to microservice transition”, with a link to the finished system — and migration is the one use case a fork cannot serve. A fork teaches you what the destination looks like; a switch is how you get there without stopping. This branch is four lines and one if from being both.
Next, nobody moved the data: a fresh migration, three empty tables, a new database, and not one line anywhere that copies a customer from the old one.