Eighteen Containers Become Two
Running Trill as microservices takes eighteen containers and twenty-two published ports; running the same product as a monolith takes mongo, redis and dotnet run - and only the monolith still starts from a clean clone.
Every architecture comparison eventually reaches the operational column, and it is the one where the numbers are least arguable. You either need a Consul container or you don't. Part 9 finished the tour of what the monolith built for itself; this is the tour of what it stopped needing.
The monolith's entire docker-compose.yaml is twenty-nine lines and two services:
services:
mongo:
image: mongo
ports:
- 27017:27017
redis:
image: redis
ports:
- 6379:6379
The README's operating instructions are two commands: docker-compose up -d, then cd src/Bootstrapper/Trill.Bootstrapper && dotnet run.
The other column, counted
Trill/compose/infrastructure.yml brings up ten containers: consul, fabio, grafana, jaeger, mongo, prometheus, rabbitmq, redis, seq, vault. compose/services-local.yml brings up eight more: api-gateway, pusher, trill-web, ads-service, analytics-service, stories-service, timeline-service, users-service.
Eighteen containers. Published host ports: thirteen on the infrastructure side — 8500, 9998, 9999, 3000, 5775, 5778, 27017, 9090, 5672, 15672, 6379, 5341, 8200 — and nine on the application side: 5000, 5010, 5011, 5020, 5030, 5040, 5050, 5060, 5070. Twenty-two host ports against two.
Two extra Dockerfiles exist purely to bake configuration into infrastructure images: compose/prometheus/Dockerfile and compose/rabbitmq/Dockerfile. Neither builds application code.
| Microservices | Modular monolith | |
|---|---|---|
| Containers | 18 | 2 |
| Host ports | 22 | 2 |
appsettings*.json files |
23 | 4 |
| Per-module config files | — | 6 |
certs/ directories |
8 | 0 |
| Dockerfiles for app code | 9 | 0 |
| Orchestration definitions | 3 compose files + tye.yaml + services.yml |
one compose file |
The saga that nobody deploys
Count the eight application services in services-local.yml again and one is missing. Trill.Saga has a Dockerfile, has three appsettings files, has its own solution — and there is no saga-service entry in the primary compose file. tye.yaml lists it as one of nine named services; services.yml, a third runner definition that shells out to dotnet run, omits it too and has the Blazor client commented out.
So the estate has three ways to start the same system and no two of them agree on what the system is. The orchestrator that owns the entire ad-publishing workflow — approve, pay, publish, compensate — is absent from the file a newcomer would run first. Bring the stack up the documented way and the ads feature silently does nothing, because AdApproved reaches a saga that is not running.
That is the kind of failure that only exists above the level of any single repository. Nine repos, one orchestration repo, and the inventory drifted between them because nothing checks an inventory.
Why only one build still runs
Here is the finding that reframes everything above. Every microservice Dockerfile starts the same way:
FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build
WORKDIR /app
COPY . .
RUN dotnet publish src/Trill.Services.Users.Api -c release -o out
FROM mcr.microsoft.com/dotnet/core/aspnet:3.1
Nine of them, identical but for the project path. On 2021-04-22 every repository in the estate took a commit called net5.0 update, which retargeted the projects to net5.0. None of the Dockerfiles moved. An SDK 3.1 image cannot build a net5.0 project, so from that day forward not one of the nine images has been buildable.
The modular monolith is unaffected — because it has no Dockerfile at all. It has never been containerised, the README tells you to dotnet run it, and consequently there was nothing to leave behind on migration day.
The only build in this eleven-repository estate that still runs from a clean clone is the one that was never packaged. I do not think that is an argument for anything, and it is certainly not an argument against containers. It is an argument about surface area: the distributed build had nine build definitions to keep aligned with a framework version, kept none of them aligned, and nothing noticed for four months because there was no CI to notice with. The monolith had zero, so it had zero to break.
The nine dockerize.sh scripts in the estate branch on $TRAVIS_BRANCH, and there is no Travis configuration anywhere. The nine start.sh scripts set ASPNETCORE_ENVIRONMENT=local and no repository contains an appsettings.local.json. The scaffolding for a release pipeline is fully present and has never been connected to one.
Configuration: fewer files, one genuinely better idea
Twenty-three appsettings*.json across nine repositories, each service carrying .json, .development.json and .docker.json, plus eight certs/ directories containing byte-identical copies of the same self-signed certificate.
The monolith has four host files — appsettings.json, an empty appsettings.development.json, and two test configurations that are byte-identical to each other — plus six module files. And the six module files are the good idea:
{
"ads": {
"module": {
"name": "Ads",
"enabled": true
}
}
}
Eight lines, marked CopyToOutputDirectory=Always, one per module. ConfigureModules globs module.*.json recursively from the content root and layers them into IConfiguration (Modules/Extensions.cs:68-84). A module ships its own configuration and its own on/off switch, and the host never learns their names. Add a module project, and its settings appear; delete it, and they vanish. That is the correct shape for a plugin system and the distributed build has no equivalent, because “configuring a service” there means editing three files in a different repository.
The environment tier is implemented and unused: ConfigureModules also globs module.*.{Environment}.json, and no such file exists in the repository. A layering mechanism with zero instances.
The observability trade, and what it actually costs
| Concern | Microservices | Modular monolith |
|---|---|---|
| Structured logs | Serilog → console, file, Seq container | Serilog → console, file, Seq — container absent |
| Distributed tracing | Jaeger, plus RabbitMQ span propagation | None; a correlation id pushed into LogContext |
| Metrics | Prometheus + Grafana | None |
| Discovery / balancing | Consul + Fabio | None; IModuleRegistry is the routing table |
| Secrets | Vault container + Convey.Secrets.Vault |
Vault code ported, enabled: false, no container |
The tracing loss is smaller than it looks and the metrics loss is larger. Jaeger's value in the distributed build is telling you which of nine hops was slow; in one process, a correlation id in the log line does most of that job, and the monolith propagates one properly — IContext is built per request, the dispatchers back-fill it onto every message, and three Serilog decorators push it into the log context. One implementation replaces eight copies of CorrelationIdFactory.cs. That is a real improvement, not just a simplification.
Prometheus is different. Losing metrics does not become cheaper because there is one process; it becomes a gap. A monolith still wants request rates, error rates and latency histograms, and the monolith has none.
And one item on that table is not a trade at all. appsettings.json:39-43 enables the Seq sink and points it at http://localhost:5341 with an API key, while docker-compose.yaml starts only mongo and redis. Every log write attempts a host that the documented setup never starts. Serilog's Seq sink fails silently and buffers, so nothing surfaces; it is simply configuration describing a topology that does not exist. The same class of drift as vault.enabled: false with no Vault container, and mongo.disableTransactions: true in all four environments.
Reading the ledger honestly
The two-container number is real and it is the strongest single argument the monolith makes. A new developer clones one repository, runs one compose file, runs one project, and has the entire product. On the other side that is a clone of ten repositories, a compose file that is missing a service, nine images that no longer build, and twenty-two ports.
But the ledger only balances if you are honest about what the eighteen containers were for. Ten of them are infrastructure that a production monolith would still want — you do not stop needing metrics, tracing, secret management or a log sink because your code is in one process. The monolith did not solve those problems; it stopped having them because it is a teaching artefact that runs on a laptop. The eight application containers are the genuine saving, and eight-to-one is still a decisive number.
What the distributed build pays for isolation is eighteen containers; what the monolith pays for two containers is that a single crash takes the whole product down. That is the trade, stated plainly, and it is the only row in this table nobody can argue with.
Next, the claim modular monoliths make most often and this repository's own test suite refutes: in-process is not synchronous.