The Rewrite Kept Every Bug
Four non-trivial defects appear at near-identical line numbers in both builds of the same product - a ground-up rewrite by the same authors relocated them rather than finding any.
The strongest argument for a rewrite is that you will notice things. You read every file, you carry each decision across by hand, and the ones that do not survive scrutiny get left behind. Three months and four hundred files of undivided attention is the most expensive code review money can buy.
Part 11 closed the tour of what the second build changed. This part is about what it didn't, and the finding is unusually clean: four non-trivial defects appear in both builds, at line numbers that differ by at most three.
| Defect | Microservices | Modular monolith |
|---|---|---|
| Timeline's sorted-set key uses the wrong format specifier | RedisStorage.cs:92 |
RedisStorage.cs:95 |
| Sign-in lower-cases a name the entity only trims | UserRepository.cs:43 |
UserRepository.cs:44 |
StoryDocument.ToEntity() swaps title and text |
StoryDocument.cs:39 |
StoryDocument.cs:42 |
text.Substring(200) takes the tail, not the head |
StoryTextFactory.cs:27 |
StoryTextFactory.cs:27 |
I opened all eight files. Every line number above is confirmed in both trees.
One: the timeline that cannot be read
Timeline is the classic Twitter fan-out-on-write: when a story is sent, push its id into every follower's sorted set, scored by visibility start. The write is one line (Timeline.Core/Persistence/RedisStorage.cs:95):
var entry = new SortedSetEntry(story.Id.ToString("N"), score);
"N" is the Guid format specifier that strips the hyphens — 5f2c… instead of 5f2c-…. It is used correctly three lines further down, in GetTimelineKey(Guid userId) => $"timeline:users:{userId:N}:timeline".
But story.Id is not a Guid. Timeline.Core/Data/Story.cs declares public long Id { get; set; }. On a numeric type, "N" is the number format specifier, which produces group separators and two decimal places. Story 5 becomes the sorted-set member "5.00". Story 1234567890 becomes "1,234,567,890.00".
The read path then does this (RedisStorage.cs:30):
var storyKeys = storyIds.Select(x => (RedisKey) GetStoryKey((long) x)).ToArray();
(long) on a RedisValue holding "5.00" throws. Every timeline read on a non-empty timeline fails, which means the Timeline feature has never worked in either build. The line it was copy-pasted from is twenty-nine lines up, where the value genuinely was a Guid.
That is the sharpest one because the two builds are not even textually identical here — the monolith refactored the surrounding lines (GetScore(story.Visibility.From) replaced an inline call, and every Redis key gained a timeline: prefix) and left the defect in the middle of the edit untouched. Somebody had their cursor on the adjacent line.
Two: sign-in is impossible if you capitalise
Two files, forty lines apart in different projects. The entity stores the name as given, minus whitespace (Users.Core/Domain/Entities/User.cs:44):
Name = name.Trim();
The repository looks it up folded (Users.Core/Mongo/Repositories/UserRepository.cs:44):
var document = await _repository.GetAsync(x => x.Name == name.ToLowerInvariant());
SignInHandler uses GetByNameAsync. Register as Alice and the document stores Alice; sign in as Alice and the query asks for alice; no match, no user, sign-in fails permanently. Register as alice and it works forever.
The same asymmetry appears in the duplicate-name check, so Alice and alice can both register. Note that the same constructor gets Email right on the line above — Email = email.ToLowerInvariant() — so the intent was clearly to normalise both, and one of the two lines simply never got it.
The delta between builds here is one line of cleanup: the microservices constructor has a redundant Id = id; at line 42 that the base constructor already performed, and the monolith removed it, which is why the repository line moved from 43 to 44. The rewrite deleted a harmless redundancy one line above the defect and left the defect.
Three: title and text, swapped since commit one
StoryDocument.ToEntity() in the monolith (Stories.Infrastructure/Mongo/Documents/StoryDocument.cs:41-43):
public Story ToEntity()
=> new(Id, Author.ToValueObject(), Text, Title, Tags,
GetDate(CreatedAt), new Visibility(GetDate(From), GetDate(To), Highlighted), Version);
The Story constructor is (StoryId id, Author author, string title, StoryText text, ...). The document passes Text into the title slot and Title into the text slot. Every Story aggregate rehydrated from Mongo has its two string fields the wrong way round.
It compiles only because of an implicit conversion. StoryText declares public static implicit operator StoryText(string storyText) and public static implicit operator string(StoryText), so a string slides into the StoryText parameter and a StoryText slides into the string parameter, and the compiler never blinks. Remove those two operators and this becomes a build error in both repositories.
It is masked in practice by something structural: IStoryRepository exposes only GetAsync and AddAsync. There is no UpdateAsync. The two call sites that load an aggregate — RateStoryHandler.cs:38 and StoryRatingChangedHandler.cs:21 — read the story to check it exists and to feed the rating service, and never write it back. The corruption is real on every read and reaches persistence on none of them, which is precisely the kind of luck that lets a defect survive a rewrite: nothing observable ever changes.
The monolith's version of this line is the one place in the four where the rewrite did touch the defective statement — it added Version as a trailing argument, which is the one unambiguous improvement between the two builds. The arguments were reordered, extended, and left wrong.
Four: the error message that shows you the wrong 200 characters
The smallest of the four, and the only one where both builds share a line number exactly (Stories.Core/Factories/StoryTextFactory.cs:27):
if (text.Length > 200)
{
throw new TooLongStoryTextException($"{text.Substring(200)}...");
}
Substring(200) with one argument returns everything from index 200 onward. The intent was Substring(0, 200) — show the reader the first 200 characters of what they typed. For a 201-character story the message is one character followed by an ellipsis.
The guard itself is correct; only the diagnostic is wrong. The two files are otherwise byte-identical apart from the namespace and public becoming internal, which tells you exactly how this one travelled.
And two more that are not code
The .rest fixtures carry a triple-brace typo — PUT {{url}}/ads/{{{adId}}/approve — at lines 28, 36, 44 and 52 of the Ads request file. Four occurrences, same four line numbers, in both builds. The .rest files are the only executable specification this estate has, since all the test projects in the distributed build are empty, and four of the Ads requests cannot be sent as written in either.
The Analytics Tag model misspells its persisted field as OccurenceCount — one r — in Models/Tag.cs:6, and it survives into the monolith at the same line, plus a third copy in the Blazor client's TagDto. Because it is a Mongo document field, fixing it is a data migration rather than a rename, which is how a typo becomes permanent.
Why rewrites do this
The instinct is to call this carelessness. I do not think it is, and the specifics argue against it.
Look at where the defects sit. The Timeline key is one token inside a format string. The sign-in fold is a method call on the far side of a project boundary from the entity it disagrees with. The title/text swap is two identifiers in the correct position in a correct-looking argument list, held together by an implicit operator that exists to make exactly this kind of thing read naturally. The Substring is an off-by-one in an exception message.
Every one of them is invisible to a reader who is asking "does this do what the old one did?" — and that is the only question a rewrite asks. Porting is a translation task, and translation preserves meaning including the meaning you did not want. The reviewer's attention during a rewrite is spent on architecture: which layer does this belong in, does this module need this reference, what replaces the broker. It is not spent on whether "N" means the same thing to a long as it does to a Guid.
There is a second, harder factor. All four defects are silent. None throws at the point of the mistake. Timeline throws three lines later, in a different method, on a read path that only fires when a timeline is non-empty. Sign-in returns a clean “invalid credentials”. The title/text swap produces no error at all. Substring(200) produces a slightly odd message. Nothing about running the application draws a reviewer's eye to any of them, and neither build had a test that would have.
That is the honest lesson, and it cuts against both columns: a rewrite is a correctness-preserving transformation, and the correctness it preserves includes the bugs. Only tests find bugs, and the microservices estate has zero tests across nine repositories while the monolith has fourteen — none of which touch any of these four files.
The distributed build's own series documents three of these defects in their native habitat — the swapped title and text, the impossible sign-in, and the format string with two meanings — and reading them there and here is the same experience twice, which is exactly the point.
What the rewrite did change, it changed in five places, and two of them are a security regression that no domain diff would show you. That is the last part: ten hours and no way back.