Comparing a new implementation against an old one is only a test while the old one stands still.
👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This series is about one write path that left the monolith and what it took to accept it as working. This part is about checking a migration while both sides keep moving - and about response codes that describe the wire rather than the storage. Running notes live on my GitHub: github.com/brilliant-almazov.
This is how I do it right now, with the price attached - maybe you already do it better, maybe you'd do it another way.
Where the boundary was drawn
The migration moved computation out of the monolith before it moved data. For a stretch of about seven weeks that produced a split that looks reasonable in a diagram and is very hard to check:
the monolith the extracted service
├── works out the composition ├── owns the identity of a rule set
├── asks the service for identity ────► ├── answers over the wire
└── mirrors the row into its own DB └── knows nothing about the mirror
no shared transaction between them
One operation - work out what the composition should be, and save it - now spans two processes and two databases with no shared transaction. Over 49 days that boundary produced four tasks of exactly one shape: the monolith computed the right thing and something else got stored. In the two weeks right after ownership moved, the old system took 18 more edits on this subject alone.
The response code describes the wire
The first thing a parity check leans on is the response. Three cases from this period, all of them real:
-
502after commit - the row was written; the caller retried a completed write. -
200 completed- the row was silently deleted; the run counted it as success. -
504at 20.26 s - the write went through, and the cascade kept working 8.7 s after the answer.
None of these is a lie by the transport. A code honestly reports that the call arrived, the body parsed and the request's own transaction closed. With two storages and no shared transaction, that is all it reports. So the rule that went into the acceptance instructions and has stayed there:
Assert on the persisted read-back, not on the write response. A green answer is not a green database.
The baseline was being fixed at the same time
Now the harder problem. The new write path was accepted by comparing it against the old one - byte for byte, field for field. That is a sound method exactly once: when the reference is frozen.
Ours was not. On one day the same input went through nine iterations, and the size of the cascade the two sides produced went:
run 1 ─────► 2 rows
run 4 ─────► 258 rows same input
run 6 ─────► 2 rows
run 9 ─────► 8 rows settled — for that day
And the diff itself, one object, both sides in the same run:
the old implementation the new implementation
{ {
"rule_set": { "id": 1806235194000891392 }, "rule_set": { "id": "1806235194000891392" },
"scope_rule_set_id": "9412008365512340992", "scope_rule_set_id": "9412008365512340992",
"priority": null, "priority": 0,
} }
Two differences, two entirely different causes. The identifier being a number in one field and a string in another is the old side being inconsistent with itself. The null against 0 is the contract: on the wire, zero and absent are the same thing, so a real value of zero cannot be told from no value at all.
A machine can produce that diff in a second. What it cannot produce is the sentence "the right-hand side is correct here and the left-hand side is correct there".
Every one of those runs was honest work. The swing came from the reference itself changing definition mid-day: what counts as a sibling in the cascade, whether inactive objects belong in it, what the scope of a client-level write is. And the reference was internally inconsistent too: the same identifier came back as a number in one field of one response and as a string in another.
Three times out of nine, the new implementation turned out to be the correct one. Which is the real lesson of the day: when you name a reference, you are also declaring it correct - and that declaration needs to be true.
A green suite that could not fail
The same period had a quieter version of the same mistake. The write path had a switch selecting which implementation runs. Production ran the new one. The test environment's config file still pointed at the old one.
So the suite was green, permanently, and green meant nothing: it exercised a code path production had stopped using. One flag, set differently in two places, for weeks. A switch that selects an implementation belongs in the test matrix, not in an environment file.
And then the comparison stopped existing
The end of the parity story is worth telling in full, because it is not a failure - it is a cost.
Partway through, the owner decided the old write endpoint would be removed entirely: one door, one write path, no dual entry. Correct decision, and it simplified everything downstream. It also erased, in one line:
- nine iterations of parity work from a single day;
- the cumulative field-by-field contract diff between the two answers;
- every timing measured on the old path;
- and the whole defect class "the two implementations disagree", which stopped being a defect class at all.
What was missing was not the decision. It was the date it would take effect, agreed in advance. A comparison is worth its cost only while both sides are going to keep existing; the moment one is scheduled for removal, the parity work has a known expiry and can be scoped to it.
The real bill: finding out what was wanted
Add it up and the shape of the period is not "we wrote buggy code":
- 23 requirements that had two defensible readings;
- 7 reversals of decisions that were already implemented;
- ~13 of 27 iterations spent working out what was wanted rather than fixing code (an estimate over intervals, not a measurement);
- 15 iterations of briefs written and discarded within a day;
- and in 5 reversals out of 6, the code was correct against the specification it was written against.
That last number is the one I keep. The implementation was rarely the problem. The requirement moved, and nothing moved with it.
What keeps them in step
None of the remedy is exotic. It is a loop, run per iteration rather than per release:
- Freeze the requirement in words, with the owner of each value named.
- Write the check before the code, in two halves: what proves the value, and which reads do not prove it.
- Run it against the live system, on everything rather than on a sample.
- Re-sync deliberately: when the requirement moves, the check and the specification move in the same change, or the requirement hasn't actually moved.
The one addition the multi-system case forces: when a comparison is part of the method, write down when it expires. Which side is going away, and on what date.
What automation settled, and what it handed back
Nine iterations in a single day are only possible because the run itself is cheap - generated, executed and folded by an automated executor. Worth splitting precisely what that bought.
Mechanical, and automated end to end: building the request matrix per object, running all 392 rather than a sample, folding both answers into a field-by-field diff, repeating the whole pass on the next build, attaching the trace contents to every failure so the report outlives the retention window.
A decision, and still ours: which of the two sides is entitled to be right; who owns a value the two disagree about; when the comparison expires because one side is being removed; the observation that a switch was set differently in the test environment and in production; and the distinction that mattered most - did the build break, or did the requirement move?
The last one is the whole reason this period cost what it did. To an executor, a requirement that changed overnight and a regression look identical: yesterday's expectation, today's mismatch. It will report both with the same confidence and the same formatting. Deciding between them is not a gap in the tooling - it is the job the tooling exists to make room for.
So the honest framing of the multiplier: fast iteration does not settle what "correct" means, it multiplies whatever answer is currently written down. Nine cheap runs against a definition that changed by evening produce nine artefacts and no verdict. Freeze the definition, name the owner of each value, give the comparison an expiry date - and the same automation stops producing noise and starts producing evidence.
Anatomy of a service split — Part 4. Next: the cascade that does not fit inside a response - a constant deadline against a cost that grows with the number of children, and a write that succeeds after the caller has already been told it timed out.
If you do this better, tell me how you keep a reference frozen while it is still being changed. If you have been through this, what did your green suite turn out not to be testing? If you see it differently, say where parity against an old implementation earns its cost. How is it solved on your side, and what broke there?














Top comments (0)