The Branch Where the Monolith Splits
Every modular monolith promises you can extract a module later. Inflow has a branch where someone actually did it - one module lifted into its own process behind a gateway and a real broker. This series reads that branch line by line.
Every modular monolith is sold on the same promise: keep the boundaries honest, and when one module outgrows the process you can lift it out. The diagrams always show the dotted line where the split will happen. What almost nobody publishes is the commit where they did it — the one that turns the dotted line into a network hop, a broker, a second database and a second deploy. The talks stop at the boundary diagram. The repositories stop at master.
Inflow — the MIT-licensed sample app by Piotr Gankiewicz (spetz) and DevMentors at github.com/devmentors/Inflow, © 2021, the companion repository for their Building Modular Monolith course — has that commit. It is on a branch called microservices, it is dated 31 October 2021, and it changes 108 files. This series reads it.
To be clear up front: I did not write Inflow. I am a source-reader working from a local clone, using read-only git plumbing against origin/microservices and master — no checkout, no working tree touched. Where the implementation is the story I quote it, attributed; everything else is fresh illustrative code. The repository is a teaching artefact with no CI, no deployment and no hosted instance, and I have tried to hold my criticism to that standard: every sharp edge in the next eleven parts is paired with what the author was demonstrating.
A repository with a second ending
Inflow's master is a modular monolith: one ASP.NET Core host (the Bootstrapper), four business modules (Customers, Payments, Wallets, Users) plus a Saga module, no project reference between any two modules, one PostgreSQL schema each, and integration entirely through an in-process message broker built on System.Threading.Channels. That system is the subject of two companion series — The Framework Underneath for the shared mini-framework, and Five Modules, One Database Each for the domain code — and I will lean on both without re-deriving them here.
What matters for this series is that the repository has twelve commits across all refs and one author, and the interesting ones are not on master:
* 39e96da (master) packages update
* 95fe438 .NET6 upgrade
| * 4724753 (origin/microservices) .NET6 upgrade
| * 35a9859 (origin/net5-microservices) Microservices transition
|/
* 34f3f02 (origin/net5) init
Read that graph carefully, because it decides how every number in this series has to be computed. The extraction was written on .NET 5 as a single commit, 35a9859, branching from init. The branch was then ported to .NET 6 in 4724753, independently of master's own .NET 6 port in 95fe438. Master later took a packages update that the microservices branch never received. origin/net5-microservices is exactly the extraction with no .NET 6 port on top — which makes it the cleanest place to see what the author actually decided.
So when you run git diff master origin/microservices --stat and it says 161 files changed, that number is answering a question nobody asked: how far apart are two independently-ported .NET 6 trees. The honest extraction number is git show 35a9859: 108 files changed, 2,754 insertions, 54 deletions. That distinction is not pedantry — it is Part 2 in its entirety, because the difference between the two figures is made of exactly the kind of noise that makes migration retrospectives untrustworthy.
Three hosts where there was one
The 108 files buy you a topology change. On master you run one process. On origin/microservices you run three:
Inflow.APIGateway— a new YARP project whose entire service registration isservices.AddReverseProxy().LoadFromConfig(...), listening on:5000with two routes: a catch-all to the monolith and acustomers-service/{**catchall}prefix to the extracted service.Inflow.Bootstrapper— the same modular monolith asmaster, moved off:5000onto:5010, now missing one module.Inflow.Services.Customers.Api/.Core— the extracted Customers service on:5020, with its own PostgreSQL database (customers-service) and its own EF migration.
Plus nine new lines in docker-compose.yml that add rabbitmq:3-management, and a 37-line rabbitMq section in each host's appsettings.json.
The module that left is Customers. It did not leave by deletion. src/Modules/Customers/Inflow.Modules.Customers.{Api,Core} are still in the tree on origin/microservices, still in the solution, still compiled into the Bootstrapper's output directory. The transition commit's entire change to the module is one boolean in module.customers.json:
- "enabled": true
+ "enabled": false
Two full copies of the Customers domain now live in one repository, one of them switched off by a JSON flag that a filename-parsing assembly loader reads at startup. That is Part 8, and it is a much more defensible decision than it first sounds.
What the branch proves
Here is the result that earns this series, and I want to state it before any of the criticism, because it is genuinely good and it is the reason the rest is worth reading.
Inflow.Shared.Abstractions/Messaging/IMessageBroker.cs is byte-identical on both branches. Not “similar” — the same blob:
public interface IMessageBroker
{
Task PublishAsync(IMessage message, CancellationToken cancellationToken = default);
Task PublishAsync(IMessage[] messages, CancellationToken cancellationToken = default);
}
git rev-parse master:...IMessageBroker.cs and the same path on origin/microservices both return 1e98e6f0. And the whole of Inflow.Shared.Abstractions — 53 files of commands, events, queries, kernel, contracts and modules — diffs to three new files and zero changed files between the branches.
Downstream of that interface, nothing moved either. CompleteCustomerHandler still writes to its repository and then calls await _messageBroker.PublishAsync(new CustomerCompleted(...), cancellationToken). The saga still coordinates on events. No controller, no domain entity, no value object, no handler signature changed anywhere on the branch to accommodate a real network broker. The accommodation happened one layer down, in a 261-line folder called Messaging/RabbitMQ and a single new statement in the composition root.
The dependency-inversion boundary that the modular monolith was designed around held. That is a real, demonstrated, checkable result, and most “we extracted a service” write-ups cannot show you anything equivalent. Parts 3 and 4 are about how it held.
What the branch costs
Then there is the other half, and it is the reason this series is twelve parts and not three. The seam was narrow enough to be transport-agnostic — and nobody widened it back to carry the things a real transport needs. In-process, the envelope around a message was ambient: a correlation id in a shared context object, a message id in an in-memory cache keyed on the object instance, a shared DI scope, a shared transaction, exceptions that propagate to the caller. All of that is free when the message never leaves the heap.
What crosses the wire instead is SendAsync(IMessage message, Guid messageId, CancellationToken). One Guid. And, as Part 7 shows, even that Guid is thrown away on the receiving side.
Two things I cannot show you
Two honest limits on this series, declared once here.
The end state is not in this estate. The README on origin/microservices points at github.com/devmentors/Inflow-micro as “the complete, microservices based solution”. That repository is not present in my local clone of the DevMentors estate. Everything I say about where this migration ends is quoting the README, not reading code. What I can read — and what is genuinely the more interesting artefact — is the intermediate state: the moment when half the system is a monolith and one module is not.
Convey is pinned to a floating version. The transport under the adapter is Convey.MessageBrokers.RabbitMQ, referenced as Version="1.0.*". I read a local clone of Convey to trace what happens below Inflow's code, but a floating pin means Convey-side claims in this series are “the library behaves like this”, not “this exact build did”. Where that distinction changes a conclusion, I say so — and in Part 7 it changes one.
Where the series goes
- The branch where the monolith splits — this post.
- A hundred and eight files, not a hundred and sixty-one — how to compute the honest cost of an extraction.
- The interface that did not move — the seam that held, verified.
- Two hundred and sixty-one lines of transport — the whole RabbitMQ adapter, file by file.
- Eight queue names hold the fan-out together — the queue template, and the string literals that stop Competing Consumers.
- Your routing key is a class name — addressing derived from the CLR type, and what that couples.
- The envelope that did not cross — the message id that is published, delivered, and discarded.
- Extraction by copy and disable — two copies of a domain in one repository.
- Contract checking left when the network arrived — the validation that was deleted from exactly the boundary that became remote.
- Four lines from a switch — the Null Object that would have made this a configuration flag instead of a fork.
- Nobody moved the data — a fresh migration, empty tables, no backfill.
- The seam held, the envelope did not — the retrospective.
If you have ever drawn the dotted line on a modular monolith diagram and promised the room that extraction would be cheap, the next eleven parts are the invoice. We start with the number on it.