The Seam Held, the Envelope Did Not
Eleven parts of reading one commit. Inflow's dependency-inversion boundary survived a real transport swap intact - and everything ambient that used to travel with a message did not. The retrospective, the ledger, and what I would take from it.
Part 11 closed the last open thread. Eleven parts, one commit, 108 files. This is the retrospective, and it has to do two jobs that pull against each other: state the result honestly, and state the bill honestly. I want the result first, because the bill is longer and it would otherwise drown it.
The result
Inflow.Shared.Abstractions/Messaging/IMessageBroker.cs is the same git blob on master and on origin/microservices. The whole abstractions assembly — 53 files — diffs to three new files and zero changed lines. Downstream of it, across four surviving modules and one extracted service, no command handler, event handler, saga, controller, entity, value object, repository or DbContext changed to accommodate a real network broker. The module-side integration cost was thirty inserted lines, most of them one attribute per record.
That is not a small claim and it is not a rhetorical one. It is checkable in about ninety seconds with git rev-parse and git diff --numstat, which is more than can be said for almost any “we extracted a service and it was fine” story you will hear at a conference. The dependency-inversion boundary that the modular monolith was designed around did the exact job it was designed for, and the adapter that fills it is 261 lines in one folder.
If you take one thing from this series, take the mechanism rather than the outcome: the seam held because it promised almost nothing. IMessageBroker has no topic, no queue, no routing key, no headers, no delivery mode, no acknowledgement and no return value. Every one of those would have been a place where a real transport could disagree with the application, and none of them existed. Narrow abstractions survive; expressive ones negotiate.
The bill
And then the other half, which is the same sentence read backwards. Everything the interface declined to say was still available in-process — ambient, free, and load-bearing. Here is the ledger, with the part that develops each one.
| # | What was lost | Where |
|---|---|---|
| 1 | Publish-subscribe narrowed to single-handler dispatch (GetServices to GetRequiredService) |
Part 4 |
| 2 | A subscription with no handler - every delivery throws, three retries, discard | Part 4 |
| 3 | Fan-out held together by eight optional string literals | Part 5 |
| 4 | Every internal event published to an exchange nothing is bound to | Part 6 |
| 5 | Wire addresses derived from CLR type names, computed independently on both sides | Part 6 |
| 6 | Casing applied on the publish path and not the declare path | Part 6 |
| 7 | Correlation Identifier replaced by a fresh random Guid per publish | Part 7 |
| 8 | Message id delivered, parked in an accessor, never read | Part 7 |
| 9 | Idempotent Receiver defeated - saveToInbox skips dedup rather than reading the id |
Part 7 |
| 10 | Dual write - the external send sits above the outbox check, publish before commit | Part 3 |
| 11 | Boot-time contract validation now covers only a disabled module | Part 9 |
| 12 | No Format Indicator anywhere - no version, schema id or content type | Part 9 |
| 13 | Unconditional transport registration - a fork rather than a switch | Part 10 |
| 14 | Per-handler logging decorators dropped in the same commit | Part 10 |
| 15 | No data movement - empty tables in a new database | Part 11 |
Plus the absences I never gave a part to: no dead-letter configuration in either host, so a failed message is nacked without requeue into nothing; no health checks, no metrics scraper (port 15692 is exposed and nothing reads it), no distributed tracing; Microsoft.Extensions.Http.Polly referenced with no policy handler anywhere; a const string ApiUrl = "http://localhost:5010" where service discovery would go; a RabbitMQ container declared with no volume next to a Postgres container that has one, so docker-compose down erases the durable topology; a gateway with no authentication, no rate limiting, no health checks and one hard-coded destination per cluster; no test project for the extracted service; and no CI configuration of any kind on either branch.
Two security items ride along and should be named. The synchronous call between the two hosts is served by a new GET users/by-email/{email} endpoint marked [AllowAnonymous] — an unauthenticated email-to-user lookup, added solely so one service could call another. And the JWT issuerSigningKey in the Bootstrapper's appsettings.json is byte-identical to the one in the extracted service's, which I verified by hashing the two lines and will not reproduce. Symmetric-key trust propagated by copy-paste means each host can mint tokens the other accepts, and rotation becomes a coordinated redeploy.
The pattern: every one of them is silent
Read the ledger again looking for the thing they have in common. Not one of those fifteen items produces a compile error, a startup failure, or a failing test. A missing queue attribute produces competing consumption. A renamed record produces an unbound routing key and a dropped publish. A missing handler produces an exception on a background consumer thread three retries before a discard. A missing message id produces a skipped dedup. A missing correlation id produces a plausible wrong one. An unmigrated row produces a silent return.
That is not a coincidence; it is the structural consequence of what changed. In-process, the compiler and the container were your integration test — a handler either existed or the code did not build, an event either had subscribers or you could see them all in one solution. Across a broker, every one of those relationships becomes a string comparison performed at runtime by a process that has no opinion about whether it matched.
So the durable lesson from this branch, and the one I have actually changed my own practice on: when you move a boundary from the compiler to the network, you have to rebuild, by hand, the checks the compiler was giving you for free. A startup assertion that every subscribed type has a handler. A startup assertion that no two subscribers in a process compute the same queue name. A content type that names the contract and its version. A test that publishes and consumes over a real broker in CI. None of that is expensive. All of it is invisible work whose absence is also invisible, which is exactly why nobody schedules it.
Where the author told you
Fairness matters here, and the source is more honest than a summary of its defects suggests. Three comments on this branch mark simplifications explicitly, and they are all in the right places:
// Publish an external message to the real message broker (not just in-memory), make use of outbox etc. when needed— on the dual write.// On purpose, for sync communication sample— on the anonymous endpoint.// Extract URL to the service registry & discovery tool, add Polly for retries, error handling etc.— on the hard-coded localhost client.
Three of the sharpest edges in this series are things the author flagged as deliberate teaching shortcuts. That earns the code a different reading from the rest, and it is why I have separated “documented simplification” from “bug” throughout — the missing Saga handler and the dropped logging decorators get no such comment, and I called them differently for that reason.
The wider context matters too. This is a free MIT-licensed companion repository for a course, with no CI, no deployment, no hosted instance, one author, and twelve commits. It is not a product and does not claim to be. What it does claim — a working module-to-microservice transition you can read end to end — it delivers, and it delivers something rarer than that: a repository where you can diff the before and the after of an architectural decision in one command.
What I would take, and what I would not
Copy this, with confidence. The narrow publish interface. The adapter-in-one-folder discipline. Deriving the wire address from the message type so producer and consumer compute it the same way. Extracting by copy-and-disable so the cutover and the rollback are the same config flip. A subscription API that takes a type parameter and nothing else.
Copy this, but fix it first. Add the Null Object and the if so the transport is a flag. Add a correlationId parameter to the client and read IMessagePropertiesAccessor in the subscriber. Delete the queue-template override so correct fan-out is the default. Put an [ExternalMessage] check in the publish path. Assert the topology at boot.
Do not copy this. The unconditional AddRabbitMQ() in a shared composition root. A dedup guard that skips deduplication. Contract validation left in the pipeline with nothing to validate. And extracting a module you have no tests for.
The end state I cannot show you
One honest limit, declared in Part 1 and worth repeating at the close. The README points at Inflow-micro as the completed microservices solution. That repository is not in this estate, so everything about where this migration ends is quoted, not read. What I could read is the intermediate state — half monolith, one service, both live — and I have come to think that is the more valuable artefact anyway. Finished systems teach you architecture. Half-finished ones teach you migration.
Two companion series read the rest of this repository: The Framework Underneath takes the shared mini-framework apart, and Five Modules, One Database Each reads the domain code the modules are made of. The Repo Is the Lesson treats the repository itself — its README, its history, its workshop branches — as the artefact.
But the sentence to leave with is the one that took eleven parts to earn, and it is both halves of the same fact. Inflow's abstraction was narrow enough to survive a real transport swap untouched, and narrow enough that nothing else survived with it. The seam held; the envelope did not.