A field that cannot separate "absent" from "zero" is not one field. It is two facts sharing a slot.
👋 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 the contract: a value that exists, is stored and is correct and still cannot be shown - and a rule the system obeyed for a year and a half that nobody had ever written down. 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.
The path in one paragraph
A user saves a set of matching rules; every domain under that account gets marked by them. Four participants: the Go API gateway as the only door, the rule-set-markup service which takes the write and folds the cascade, the domain-rule-map service which owns rule-set identity and recalculates the map, and PostgreSQL behind them - the monolith's database for the rule data, the identity service's own for identity.
Two of those rules can match the same domain. Something has to decide which one wins. That decision is a number on the rule - its priority - and everything else in this article follows from the fact that this number was never fully described anywhere.
The value was right the whole time
The symptom was narrow and boring, which is why it survived so long. A rule is saved with a priority. The write is accepted. The row is in the database with the right number in it. Read the rule back and the number is there. Read the map row the engine produced - the thing that says which rule marked this domain - and the priority is gone.
Nothing was lost, nothing was corrupted, nothing was recomputed wrongly. The value simply could not be presented, because the highest priority in this system is written as 0, and on the wire 0 is exactly what a field looks like when nobody set it at all.
the field has three states the wire carries two
┌────────────────────────────┐ ┌────────────────────────────┐
│ 0 the strongest rule │ ────────► │ nothing on the wire │
│ 7 a weaker rule │ ────────► │ 7 │
│ — not set at all │ ────────► │ nothing on the wire │
└────────────────────────────┘ └────────────────────────────┘
reader's rule: "no value means not set" → the strongest rule reads as unset
This is not exotic. It is the default behaviour of a wire format that omits zero values to save bytes, meeting a domain where zero is the most meaningful value there is. Both halves are reasonable on their own. Together they mean the strongest rule in the system is indistinguishable from a rule with no strength at all.
Both sides were locally correct
The part worth sitting with is that neither end of this is a mistake anyone would flag in review.
The producer follows the serialisation rules it was given: a numeric field holding its zero value carries no bytes, and that is the documented, intended behaviour of the format - it is how the wire stays small. The consumer follows the only rule available to it: a field that arrived with nothing in it was not provided, so fall back to the default. Read either repository on its own and you find a service behaving exactly as specified.
The defect exists only in the sentence neither side owns: what does absence of this field mean for this particular value? No reviewer is asked that question by a diff. It belongs to the contract, and the contract had never said.
The registry carried two entries for it: one where the map row carried no priority, which took five iterations to close and came back once after being fixed, and one where priority 0 arrived as absent on the wire, which took three. In a period of 32 days and 26 numbered defects, that is a lot of attention for a value that was never wrong.
Eight of 26, and 91 days of the contract changing its mind
That pair was not an accident of one field. Counting the whole registry, eight of the 26 numbered defects were defects of contract shape - a field missing, a field ambiguous, a value that could not be told apart from its own absence, an identifier that was a string in one place and a number in another. It is the largest single class in the set. Nothing in it was a computation error.
The same field's own history says why the class stays alive:
day 0 day ~45 day 91
│ │ │
● priority removed from the contract ● put back ● meaning pinned down
"the engine derives it" "callers need it after all" "0 is a value, and
absent is a third state"
─────────────────────── 91 days of three different answers ───────────────────────
Each of those three moves was defensible when it was made. What nobody did until the third one was write down what the field means when it is absent - and until that sentence exists, every consumer invents its own answer, and the answers disagree quietly.
A field that leaves a contract and comes back three months later is worth treating as a signal in itself: it means the question "who needs this and why" was answered by removing it rather than by writing it down. The cheap version of that conversation is one sentence in the contract. The expensive version is two services, several runs and a class of defects that keeps producing new numbers.
The count that made it visible
The reason this ran for weeks is that nothing in the responses said anything was wrong. Status codes were fine, payloads validated, the data was correct in storage. So the check that finally made it observable was not an assertion about a response - it was a count over state: how many map rows exist that carry no priority at all.
map rows with no priority
first measurement ████████████████████████ 27
after the next run ██████████████████████████████████████ 41 ← growing
after the fix ▏ 0
Two honest notes about those three numbers, because they are easy to over-read.
First, 27 → 41 is not a measurement of a system getting worse at a steady rate; it is two counts taken while data kept being written. Second - and this holds for every number in this series - a share is never published without its absolute value. Three windows of observation exist here with different instruments, and comparing across them compares the instruments, not the system.
What matters is the zero at the end, and the fact that a single SELECT produced the whole story. The class of defect that response codes cannot express is exactly the class that a count over stored state can.
The other half: how did this ever work in the monolith?
While that was going on, a plainer question came up and stopped everyone: two rules match the same domain and have the same priority. Which one wins?
Nobody knew. Not "nobody remembered" - nobody had ever decided. Reading the original specification produced nothing: it described what a rule is and what marking does, and said not one word about ties, order or precedence.
The behaviour existed all the same, and it was completely deterministic: the first matching rule in the order the array happened to be in wins. Not a rule anyone chose. A property of how a loop was written, promoted to a norm by nothing except the fact that it never broke.
where the tie-break rule lived
the written specification ─ silent on order, silent on ties
the interface contract ─ no field expressing it
the documentation ─ not mentioned
the code ● "first match in array order wins"
▲
the only copy, held as norm for 17 months
Seventeen months of production behaviour resting on a line of code nobody had ever been asked to agree with.
This is what makes a migration expensive in a way that no estimate contains. Moving a feature out of a monolith is not "reimplement this specification elsewhere". It is, first, archaeology: find out what the system actually did. Then, second, an argument nobody can settle from documents: was that intended, or is it a bug we have been shipping since it was written?
Those are two separate bills, and only the first one gets estimated. Recovering the behaviour is finite work: read the loop, reproduce it, write down what it does. Deciding whether the recovered behaviour is a requirement is not finite, because there is no document that can settle it - only people, and only by making a decision that should have been made a year and a half earlier, now, under the schedule pressure of a migration.
The follow-up defect is still open at the end of the period, and its wording is the giveaway: rule order is not provable. Not "wrong" - unprovable. You cannot demonstrate that a system obeys a rule that was never stated. Until the rule is written, the check can only assert that today's output equals yesterday's output, which is a description, not a requirement.
Requirements read out of code are a liability, not a spec
Both halves of this article are the same failure wearing different clothes.
In the first, the requirement existed in someone's head - "priority 0 means strongest" - and never reached the contract, so the contract encoded a different, weaker requirement: "priority is a number that may be omitted".
In the second, the requirement existed in the code - "first match in array order wins" - and never reached a document, so for 17 months there was no way to tell an implementation detail from a promise.
Neither absence is visible from inside a running system. Code with an unstated rule behaves identically to code with a stated one, right up until somebody has to reimplement it somewhere else - and then the difference is the entire cost of the move.
Across the whole period, 23 places in the specification admitted two readings. Each of those is a small coin toss between two implementations, and the toss is resolved late, by a run, by an argument, or by a defect - never for free.
What changed in how this gets accepted
The practice that came out of it is not "write more documentation". It is narrower and it is checkable: a new task starts by freezing the tests for its requirements, and only then the code gets written. For a field crossing a service boundary, freezing it means answering four questions in writing before anyone implements a reader:
1 presence what does absent mean, and is it different from the zero value?
2 ordering when two candidates tie, what decides - and where is that written?
3 authority which service is allowed to write it, and which only reads it?
4 proof which stored count goes to zero when this is correct?
Question 4 is the one that would have caught this article's first half in an afternoon. "Priority is carried through correctly" is not testable as written; "zero map rows without a priority" is a SELECT, it runs forever, and it was the thing that eventually made the defect visible anyway - just several weeks later than it needed to be.
Question 1 is what turns a field description into a contract. "Priority: integer, optional" describes bytes. "Priority: integer; 0 is the strongest; absent means the rule carries no priority and loses every tie" describes a decision, and a decision can be checked.
Question 2 is the one that would have caught the second half, and it costs even less: someone types the sentence "ties are broken by X", the sentence turns out not to exist, and the archaeology happens on day one of the design instead of eighteen months later during a migration.
What this does not prove
The material behind this article covers 32 days of acceptance runs, and the instruments changed inside that window - the earlier stretch has verdicts without numbers, the later one has traces and counts. So none of this supports "the monolith was better" or "the split made it worse". It supports something narrower and, I think, more useful: the split did not create these defects; it made them expressible. A single process can hold an unstated tie-break rule indefinitely, because there is no boundary at which the omission has to be declared. Two services cannot: the boundary forces every implicit fact to be either written down or lost.
I also cannot put a money figure on any of it. Time and iteration counts were recorded, cost was not, and inventing it afterwards would be worse than leaving the column empty.
The multiplier line
Both defects were found and closed faster than they would have been by hand: an executor can re-run a full sweep on every build, pull the state counts, and print the difference without getting bored on the fortieth object. What it could not do is decide that 0 means "strongest" rather than "unspecified", or that ties should be broken by explicit order rather than by whatever the array happened to hold. Those are product decisions, and they are exactly the sentences that were missing. Fast iteration multiplies the requirements that exist in writing; where a requirement exists only in code, it multiplies the archaeology instead.
Anatomy of a service split — Part 6. Next: the last part of this set - what happened when one automated executor was pointed at the work of another, told to add the missing traces, run the measurement and report back on what it found.
If you do this better, tell me how you write down the difference between "zero" and "not set" in your contracts. If you have been through this, which of your production rules turned out to live only in a loop? If you see it differently, say where freezing requirement tests before code costs more than it returns. How is it solved on your side, and what broke there?














Top comments (0)