Extraction by Copy and Disable
Inflow did not delete the module it extracted. It copied sixty-odd files into a new service, flipped one boolean, and left both copies in the solution - a decision that is better than it first looks and costs more than it first looks.
Part 7 followed a message across the new boundary. This part is about the boundary itself, and it starts with the smallest change in the whole 108-file transition commit.
--- a/src/Modules/Customers/Inflow.Modules.Customers.Api/module.customers.json
+++ b/src/Modules/Customers/Inflow.Modules.Customers.Api/module.customers.json
@@ -2,7 +2,7 @@
"customers": {
"module": {
"name": "Customers",
- "enabled": true
+ "enabled": false
That is the entire change to the module that was extracted. Not a deletion, not a project removal, not even a comment. One boolean. Every other file in src/Modules/Customers/ is byte-for-byte what it was — the aggregate, the repositories, the handlers, the controller, the DbContext, the migration, all of it, still in the tree, still in the solution, still compiled into the Bootstrapper's output directory on every build.
What “disabled” means mechanically
The flag is read at startup by ModuleLoader.LoadAssemblies, which is unchanged on this branch:
var files = Directory.GetFiles(AppDomain.CurrentDomain.BaseDirectory, "*.dll")
.Where(x => !locations.Contains(x, StringComparer.InvariantCultureIgnoreCase))
.ToList();
var disabledModules = new List<string>();
foreach (var file in files)
{
if (!file.Contains(modulePart))
{
continue;
}
var moduleName = file.Split(modulePart)[1].Split(".")[0].ToLowerInvariant();
var enabled = configuration.GetValue<bool>($"{moduleName}:module:enabled");
if (!enabled)
{
disabledModules.Add(file);
}
}
modulePart is the literal string "Inflow.Modules.". The module's identity is derived by splitting a file path on that substring and taking everything up to the next dot; that string becomes a configuration key. Disabled files are removed from the list before AppDomain.CurrentDomain.Load(...) runs, so the assembly is on disk and never loaded. Downstream, three things follow:
- The module's
IModuleimplementation is never discovered, so neitherRegisternorUseever runs. - Its controllers are additionally stripped by a
ConfigureApplicationPartManagerloop inAddModularInfrastructure, which removes application parts whose name contains any disabled module's name. - Critically, its types are absent from
AppDomain.CurrentDomain.GetAssemblies()— which is whatCustomRabbitMqInitializerreflects over to decide which exchanges to declare.
That last one deserves a moment. The durable topology this system declares on a shared broker is a function of which DLLs the assembly loader chose to load, which is a function of a filename split, which is a function of a JSON boolean. There are five indirections between module.customers.json and an exchange.declare frame, and none of them is visible from either end.
There is a fourth consequence that only shows up later. DbContextAppInitializer — the hosted service that runs MigrateAsync on every DbContext at startup — also discovers its work by scanning AppDomain.CurrentDomain.GetAssemblies(). Disable a module and its DbContext is no longer in the process, so its schema stops being migrated. The rows are still there in the customers schema of the inflow database; they simply drop out of schema management the moment the flag flips, silently, on the same startup that stops serving them. That is the thread Part 11 picks up.
And note how brittle the key derivation is on its own terms. The module name comes from file.Split("Inflow.Modules.")[1].Split(".")[0].ToLowerInvariant() — a filename, split on a hard-coded namespace prefix, lowercased, then used as a configuration key. Rename the assembly, introduce a module whose name contains a dot, or ship a DLL whose path happens to contain that substring, and the mapping breaks in a way whose only symptom is a module that does not appear.
The copy
The new service is a namespace-renamed copy of the module's Core. Diff any file and you get the same three-part answer. Here is CompleteCustomerHandler, module version against service version, on the same branch:
-using Inflow.Modules.Customers.Core.Domain.Repositories;
+using Inflow.Services.Customers.Core.Domain.Repositories;
-namespace Inflow.Modules.Customers.Core.Commands.Handlers;
+namespace Inflow.Services.Customers.Core.Commands.Handlers;
-internal sealed class CompleteCustomerHandler : ICommandHandler<CompleteCustomer>
+public sealed class CompleteCustomerHandler : ICommandHandler<CompleteCustomer>
Usings, namespace, visibility. The body — repository read, guard clauses, customer.Complete(...), UpdateAsync, PublishAsync — is identical. Sixty-two files in the module directory; sixty-four in the service one.
The visibility change is not an architectural statement, and it is worth explaining because it is the kind of thing that gets over-read. Every module Core project carries assembly attributes like [assembly: InternalsVisibleTo("Inflow.Modules.Payments.Api")], which is how the module keeps its types internal while its Api project still sees them. The copied service's Extensions.cs does not carry those lines forward, and Startup.cs calls SubscribeEvent<SignedUp>() from the Api project — so SignedUp had to become public, and the rest of the copy followed. One dropped attribute line widened the visibility of fifty files of domain code.
The case for copy-and-disable, honestly made
My first reaction to two copies of a domain in one repository was that it was sloppy. Reading it properly, I think it is the right call for this artefact, and possibly for a real one.
It makes the extraction diffable. Because both copies are on the same branch, you can diff the module against the service and see exactly what a module has to become to be a service — which is, remarkably, only namespaces and visibility, plus a host, a client and a migration. Delete the module and that lesson is gone; you would be left comparing across branches, and Part 2 showed how badly that goes.
It makes the cutover and the rollback the same operation. Flipping "enabled" back to true and stopping the service restores the previous topology of the module, immediately, with no code change and no deploy of anything but configuration. That is the strangler-fig property you actually want during a migration: a switch you can throw at three in the morning.
It matches how this happens in practice. Nobody deletes the old module on day one. The service runs alongside, traffic is shifted, and the module is deleted months later once nobody is nervous. This branch is a photograph of that intermediate state, which is the state most write-ups skip.
What it costs
Three costs, in ascending order of how much they would bother me.
The DLL still ships. “Disabled” is a load-time filter, not a build-time or deploy-time one. Both Inflow.Modules.Customers.Api and .Core are still in Inflow.sln — the solution now has two folders called Customers, one under Modules and one under Services — still compiled, still copied to the output directory, still part of the deployment artefact and its attack surface and its dependency graph. The boundary here is a runtime condition, not a packaging one.
The dead module keeps making claims. CustomersModule.Use still registers a synchronous module endpoint and a pair of contracts:
app.UseModuleRequests()
.Subscribe<GetCustomer, CustomerDetailsDto>("customers/get", ...);
app.UseContracts()
.Register<SignedUpContract>()
.Register<UserStateUpdatedContract>();
None of it runs, because Use is never called on an unloaded module. But those two Contract<T> registrations are the only cross-boundary contracts the system still validates at boot, which is the subject of Part 9 and a genuinely strange place to end up.
Nothing keeps the copies honest. There is no test, no analyser, no build step and no comment asserting any relationship between the two Customers domains. Fix a bug in one and the other keeps it. That is tolerable while the module is switched off and short-lived; it is exactly how you get two subtly different definitions of a customer if the intermediate state lasts a year. And this branch's intermediate state has lasted since October 2021.
The one that got extracted had no tests
A last observation, which I noticed while looking for the service's test project and not finding one.
git ls-tree -r origin/microservices src/Services/ returns 69 files and not a single test. The extracted service — new host, new database, new transport, new HTTP client, new failure modes — has zero automated tests. Meanwhile the branch still contains Inflow.Modules.Wallets.Tests.Unit, .Integration and .EndToEnd, plus two shared test projects.
The module that got extracted is the one with no tests before or after; the module with three test projects stayed exactly where it was. I do not think that is causal — Customers is the smallest and most self-contained module, which is why it was chosen — but it is the wrong way round for what a reader learns from it. The whole argument for extraction being safe rests on behaviour being preserved across the copy, and behaviour preservation is precisely the claim a test suite makes. Copying an untested module gives you two untested copies and no way to tell whether they agree.
If I were doing this for real, the cheapest useful step would be the one the branch skips: before copying anything, write the black-box tests against the module's public surface, then run the same suite against the service. That converts “we renamed the namespaces and it compiled” into evidence.
Next, what happened to the contract checking — the validation that ran at startup on master and, on this branch, covers only the module that is switched off.