Ten Hours and No Way Back
The rewrite multiplied the JWT lifetime by ten and deleted the only way to revoke a token early - one change in a JSON file, the other an absent folder, and neither visible in any diff of the domain.
I went looking for the places where two builds of the same product disagree about a business rule, expecting to find drift in prices, limits and policies. There is almost none. Every constant, every invariant, every state machine survived the rewrite unchanged — the ad price formula sits at Ad.cs:56 in both trees, the same expression on the same line number.
The five disagreements are all somewhere else, and two of them together are the strongest single finding in this estate. Part 12 showed what the rewrite carried forward. This is what it changed, and then the retrospective that closes all three of these series.
What did not move
Worth establishing first, because it is what makes the exceptions legible:
| Rule | Value | Microservices | Monolith |
|---|---|---|---|
| Ad price | (int) Math.Floor((To - From).TotalDays) * 100 |
Ad.cs:56 |
Ad.cs:56 |
| Ad states | { New, Approved, Rejected } |
AdState.cs:3-8 |
AdState.cs:3-8 |
| Ad state machine | approve/reject require New; pay/publish require Approved |
Ad.cs:59-105 |
Ad.cs:59-105 |
| Sign-up bonus | const decimal bonusFunds = 10000 |
CreateUserHandler.cs:37 |
SignUpHandler.cs:41 |
| Story text length | < 10 too short, > 200 too long |
StoryTextFactory.cs:20,25 |
StoryTextFactory.cs:20,25 |
| Author eligibility | !user.Locked && user.Rating >= -10 |
StoryAuthorPolicy.cs:7 |
StoryAuthorPolicy.cs:7 |
| Rate range | value is < -1 or > 1 |
Rate.cs:12 |
Rate.cs:12 |
| Default visibility | from.AddDays(7) |
Visibility.cs:24 |
Visibility.cs:24 |
The Ads aggregate is the cleanest illustration. Its four state transitions are line-for-line identical across forty-seven lines, with exactly one edit: DateTime.UtcNow became an injected timestamp parameter. Approve() became Approve(DateTime approvedAt). The domain was ported, not rewritten — and that is the correct call, because the domain was the part that worked.
D1 and D2: the regression that no domain diff shows
The microservices Trill.Services.Users configures its token like this (appsettings.json:88):
"expiry": "01:00:00"
The monolith configures it like this (appsettings.json:12):
"expiry": "10:00:00"
One hour becomes ten. On its own that is a defensible convenience change for a sample you run on a laptop — nobody wants to sign in twice an afternoon while demoing.
It is not on its own. Trill.Services.Users.Core has a RevokeAccessToken command, a RevokeAccessTokenHandler whose body is one line, and an endpoint at Program.cs:43:
public async Task HandleAsync(RevokeAccessToken command)
{
await _accessTokenService.DeactivateAsync(command.AccessToken);
}
IAccessTokenService is Convey's Redis-backed JWT blacklist, and Extensions.cs:105 wires .UseAccessTokenValidator() into the pipeline so every request checks it. Revoke a token and it stops working on the next call.
In the monolith that entire feature is absent. No RevokeAccessToken.cs, no handler, no endpoint, no validator middleware, no blacklist. The Users module's endpoint list drops from thirteen to twelve and this is the one that went. RevokeRefreshToken survives — but revoking a refresh token only stops you getting a new access token; it does nothing to the one already issued.
Put the two together and the sentence writes itself: the rewrite multiplied token lifetime by ten and simultaneously deleted the only mechanism for cutting a token short. A compromised token in the distributed build has a one-hour worst case and an immediate kill switch. In the monolith it has a ten-hour worst case and no kill switch at all. That is a forty-fold change in exposure window, expressed as one JSON value and one folder that is not there.
Neither half appears in a diff of the domain layer. One lives in configuration; the other is an absence. If you reviewed this rewrite by comparing entities, value objects, handlers and policies — which is what a careful reviewer does — you would sign it off.
The .rest fixture still calls the deleted endpoint (Trill.Modules.Users.rest:99-104), which is the only trace left in the repository that the capability ever existed.
The lesson generalises past this codebase: security posture is not made of code, so it is not caught by code review. It lives in a token lifetime, a cookie flag, a CORS origin, a middleware ordering, and a feature that used to be in the endpoint list. A “same product, second build” review needs an explicit inventory diff — endpoints, middleware, configuration keys — because the artefacts that carry risk are exactly the ones structural comparison ignores.
D3 and D4: the saga that is smaller, better and worse
PublishAdSaga is the second disagreement, and it cuts in both directions in the same file.
The monolith's version is 58% smaller than the microservice's — ten files and one package against twenty-four files and twenty-four packages — and it fixed a genuine bug. The distributed ResolveId handles three message types and lets AdActionRejected fall through to the base implementation, so a rejection event cannot be correlated back to its saga. The monolith adds the fourth arm (PublishAdSaga.cs:32):
AdActionRejected m => m.AdId.ToString(),
That is a real fix, found during the rewrite, of exactly the kind the rewrite was supposed to find.
And in the same file, the compensation path was neutered. The microservice (Trill.Saga/Sagas/PublishAdSaga.cs:73-76):
public Task CompensateAsync(AdActionRejected message, ISagaContext context)
{
return RejectAsync();
}
The monolith (Trill.Modules.Saga/Sagas/PublishAdSaga.cs:82-86):
public async Task CompensateAsync(AdActionRejected message, ISagaContext context)
{
LogStep(message);
await Task.CompletedTask; // An edge case scenario
}
The whole fifth saga action went too. ISagaAction<StoryActionRejected> — the arm that aborts an ad workflow when the story side fails — has no counterpart in the monolith; Trill.Modules.Saga/Events/External/ simply has no such file. If Stories rejects a paid ad's story, the ad stays paid and the saga hangs.
Chronicle is registered with services.AddChronicle() and no persistence, so saga state is in memory. A hung saga leaks until restart, and a restart loses every in-flight workflow.
Smaller and correct on the happy path; smaller and worse on the sad path. The comment is the confession: an edge case is precisely what a compensation handler is for. The distributed build's own saga has its problems — it cannot compensate either, for different reasons — but it at least still calls RejectAsync().
D5: the one unambiguous improvement
Story's constructor accepts int version = 0 in both builds — the capability was always there. Only the monolith uses it (StoryDocument.cs:21,38,43): the document gained a Version property, the constructor persists it, and ToEntity() passes it back. The microservice's document has no Version field, so every rehydrated aggregate starts at zero and the optimistic-concurrency value is silently discarded on every read.
One property, three lines, and an aggregate that can now tell you how many times it has changed. It sits three arguments away from the title/text swap in the same expression, which is a fair summary of this whole estate.
The ledger of what was dropped
Ten things exist in the distributed build and not the monolith. Only three of them are architecture:
- Real-time push — the entire gRPC
Trill.Pusherservice and the Blazor client'sIPusherService. A user-facing feature deleted, not ported. - Access-token revocation.
- Distributed tracing (Jaeger).
- Metrics (Prometheus and Grafana).
- Service discovery and load balancing (Consul and Fabio).
- The saga's handling of
StoryActionRejected. - The saga's HTTP fallback client.
- Containerisation of the application itself.
- Stories' MVC controllers — a genuine cleanup, since the service shipped two complete HTTP surfaces.
- Timeline's Infrastructure layer, folded into Core.
Nothing was added as a user-facing feature. The monolith is a strictly smaller product with a strictly larger framework, and items 3 through 5 are the honest saving while items 1, 2 and 6 are the honest cost.
The retrospective
What the monolith proved. Module boundaries can be enforced by a compiler, and the enforcement holds across six modules with four different internal architectures. A module can be disabled by a boolean without recompiling. Cross-module message shapes can be validated at startup. An entire six-module application can be booted in-process against real Mongo for a test in about two seconds — a scenario that needs eighteen containers in the other column. Eighteen containers become two. And the test count went from zero to fourteen, which is real progress even at roughly three percent coverage.
What it did not prove. That in-process eventing is simpler to test — the flagship test is Task.Delay(2000). That one database gives you transactions — they are disabled in all four environments, and the scope-per-message design would defeat them anyway. That a contract mechanism scales — four registrations out of twenty-two declarations. That consolidation improves the product: Timeline is more broken here, because there it was one dead container and here it is a dead route inside the live application.
The pattern behind the pattern. Five complete subsystems ship in the off position — outbox, inbox, Vault, transactions, and the transactional branch of the unit of work. A full authorisation surface ships with zero call sites. There are zero TODO comments in the entire repository; the debt is expressed as disabled flags, empty method bodies and commented-out registrations instead. This codebase hides its unfinished edges rather than marking them, and that is a documentation failure more than an engineering one.
When I would take this road. When the domain has real seams but the team and the traffic do not yet justify distribution; when you want the compiler enforcing consistency across a shared kernel rather than nine repositories enforcing nothing; when the operational budget for eighteen containers does not exist. Not when the modules would share one aggregate — walls through the middle of a transaction just relocate pain — and not when you need independent deployment cadence today.
Closing three arcs
Three series read the same estate three ways. The microservices spine read the platform — a gateway that publishes what it cannot name, a channel that is not a topic, two contracts that drifted, a second architecture switched off by comment. Reading the services read the domain — a factory that does not own construction, a sign-in that fails on capital letters, seven test projects with zero tests. This series read them against each other.
The single number I keep coming back to is the file count: 435 against 453. Nobody wrote less code. The distributed build spent its complexity on distribution and paid for consistency with sixty copy-pasted files. The monolith spent its complexity on maintaining boundaries no network enforces and paid for a framework with 4,600 lines it now owns. Neither paid a small price. They paid different prices, in different currencies, and the only honest conclusion is that the currency you can afford is a property of your team rather than your domain.
What both builds share is more instructive than either. Four defects at matching line numbers. No tests where it mattered. A safety net covering a fifth of its surface. Five subsystems shipped switched off. And a security posture that changed by a factor of forty without a single line of the domain moving.
The DevMentors conference sample closed its own series with the observation that the road out of a monolith is built inside it, wall by wall, while everything still runs. Trill is the same claim tested at twice the scale and read from both ends of the road at once. What it demonstrates is that the walls are the easy part. The compiler will hold a boundary you ask it to hold. It will not hold a token lifetime, a saga's sad path, or the memory of a feature you decided not to port.