The Same App, Built Twice
DevMentors shipped Trill as nine microservice repos, then rebuilt the identical product as a modular monolith three months later - giving us a controlled experiment in architecture that almost never exists in public.
Architecture arguments are unfalsifiable in practice. Nobody builds the same product twice with the same team and the same requirements, so every comparison between microservices and a monolith is really a comparison between two different products built by two different teams under two different sets of pressure. The evidence is always contaminated.
Except, occasionally, it isn't. Sitting in the DevMentors GitHub organisation are eleven sibling repositories that between them contain one Twitter clone, implemented twice: once as nine microservices with a gateway, a push service and a saga orchestrator, and once as a six-module monolith running in a single process. Same authors. Same domain. Same value objects, in several cases the same source files with a different namespace. This series reads both builds side by side and prices what each one actually paid.
To be clear up front: I did not write any of this. Trill is open-source under MIT, copyright DevMentors — Piotr Gankiewicz and Dariusz Pawlukiewicz — and I am a source-reader working from a clean clone. Where the implementation is the story I quote it with a file and a line number; everything else is fresh example code. The register throughout is “here is what the code says,” not “here is what I would have done.”
Which monolith this is, and which it isn't
A caveat that matters more than it should. This corpus has already published a fourteen-part series, One Process, Three Modules, about a DevMentors modular monolith — but that one is a different repository: a conference-management sample with Conferences, Speakers and Tickets modules, eighty-four files, three modules, one of them a four-file stub. It shares an idea with this repo and nothing else.
The Trill monolith is a larger, later, and substantially more serious codebase: 453 C# files across 33 projects, six modules, a hand-rolled framework of 138 files, a Blazor client, a benchmark project and fourteen actual tests. Where the conference sample demonstrates a shared kernel, this one is one — its README credits DevMentors' modular-framework as the parent, and Trill.Shared.* is the applied instance. The kinship is the idea evolved across two years, not the same code, and I will not be treating findings from that series as evidence about this one.
The provenance, from git log
The framing of this entire series depends on one fact, so it is worth establishing before anything else. The monolith's history is four commits:
30e8321 2021-01-07 init
bf65976 2021-03-19 refactoring, domain, cleanup
4888f5b 2021-04-22 net5.0 update
cefd2fe 2021-08-25 Readme
Every microservice repo — Trill.Services.Users, Trill.Saga, and the seven others — carries init dated 2020-10-17, decorators on 2020-11-05, and net5.0 update on 2021-04-22.
Read those two histories together and the relationship is unambiguous. The monolith postdates the microservices build by roughly three months, and afterwards the two were maintained in lockstep — both took the same net5.0 migration on the same day. This is not the legacy monolith somebody escaped from; the distributed version came first and was finished. The monolith is a deliberate second implementation of a shipped product, written by people who already knew exactly what the product was.
That is what makes the diffs worth reading. When two builds of the same feature disagree, it is not because one team misunderstood the requirement. It is because somebody made a second decision.
The numbers that frame everything
Before the arguments, the measurements. Every one of these is counted from the working trees, not from a README:
| Microservices | Modular monolith | |
|---|---|---|
| Git repositories | 9 code + 1 orchestration | 1 |
| Projects | 30 | 33 |
.cs files |
435 | 453 |
| Third-party packages in the saga unit | 24 | 1 |
| Physical Mongo databases | 4 | 1 |
| Containers to run it | 18 | 2 |
| Published host ports | 22 | 2 |
| xUnit facts in the estate | 0 | 14 |
The first surprise is the file count. The two builds are essentially the same size — 435 against 453 — which demolishes the most common claim on both sides of this argument. Nobody wrote dramatically less code. What changed is what the code is: 138 of the monolith's files are a framework it had to write itself, replacing the twenty-six Convey packages the microservices build simply referenced. Meanwhile around sixty files in the microservices estate are near-identical copies of each other differing only by namespace — CorrelationIdFactory.cs exists eight times, MessageBroker.cs five times.
Neither build paid a small price. They paid different prices, in different currencies, and one of the jobs of this series is to name the currency each time.
What each build spent its complexity on
The microservices estate spends its complexity on distribution: a YARP gateway that bridges HTTP to AMQP, a gRPC push service, RabbitMQ with three-retry policies, Consul for discovery, Fabio for load balancing, Jaeger for tracing, Prometheus and Grafana for metrics, Vault for secrets, and Seq for logs. Ten infrastructure containers before a single line of Trill runs.
The monolith deletes all of that — discovery, load balancing, distributed tracing, metrics and service-to-service HTTP simply cease to exist as concerns — and spends its complexity instead on maintaining boundaries that no network is enforcing. Every module type is internal. No module project references any other module project. Cross-module calls go through a string-keyed routing table and are round-tripped through MessagePack so that two modules can never share an object reference. Message shapes are checked against each other at startup by a bespoke contract validator that has no equivalent anywhere in the distributed build.
That last one is the most interesting artefact in either repository, and it exists precisely because the microservices build shipped the failure it was designed to catch.
Honest about which way this cuts
This series is a natural experiment, not a verdict. The monolith is not the right answer and the microservices build is not the wrong one, and I have no intention of pretending otherwise.
Where the second build improved, it improved genuinely: compiler-enforced module boundaries that actually hold, a startup-time contract checker, an aggregate Version that survives rehydration where the first build silently discarded it, an injected clock replacing DateTime.UtcNow in the Ads aggregate, and fourteen tests where the entire nine-repo estate has zero.
Where it regressed, it regressed badly, and two of those regressions are the strongest single findings in the whole estate. Both land in the closing part, because they are invisible in a diff of the domain layer: one lives in a JSON file and the other is an absent folder.
And in the middle sits the finding neither camp will enjoy — four non-trivial defects appear at near-identical line numbers in both builds. A ground-up rewrite by the same authors relocated every one of them and found none.
Where the series goes
- The same app, built twice — this post.
- Boundaries the compiler enforces —
internal,InternalsVisibleTo, and zero cross-module project references. - One database, eleven string literals — four physical databases become one, partitioned by a constant.
- A framework instead of twenty-four packages — what
Trill.Shared.*replaces, and what it drops. - AddX, UseX, and two deliberate violations — the convention study, and where it breaks on purpose.
Guid.Emptyis a mode flag — one sentinel value read in three unrelated subsystems.- The contract checker they built after the drift — the repo's most original idea, and its adoption gap.
- Every in-process call pays for MessagePack — a performance cost bought deliberately, with a benchmark as the receipt.
- Minimal APIs, written by hand — reimplementing .NET 6 on .NET 5, string surgery and all.
- Eighteen containers become two — the operational ledger, and why only one build still starts.
- In-process is not synchronous — the unbounded channel and the test that has to sleep for it.
- The rewrite kept every bug — four defects at matching line numbers.
- Ten hours and no way back — where the two builds silently disagree, and the retrospective.
Two companion series read the distributed build in its own right rather than as a control group: Nine Repos and No Map covers the platform spine — gateway, pusher, saga, contracts — and Two Services, Two Architectures reads the services themselves. This series references their findings rather than re-deriving them.
If you have ever been in the meeting where somebody says “we should have just built a monolith” and somebody else says “we should have split that up,” the next twelve parts are the evidence neither of you had. We start with the wall that actually held: the one the compiler was made to enforce.