If a service cannot state what its answer should be, the only way to check it is to guess.
👋 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 thing that made checking it possible at all: read endpoints whose only job is to describe state. 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.
We knew our input exactly: the rules the user sent. What nobody could state was the expected output: given this input and this account, what should the resulting map contain, and how would we know the answer was complete rather than merely non-empty?
What a three-object test hides
The first weeks of checking ran against a playground of three objects. Everything passed. The runs were honest, the assertions were real, and the conclusion was worthless - three objects cannot show you anything that depends on the shape of the data.
Then a run went across everything the environment held:
playground 3 objects everything passes
full sweep 276 objects active-set registry EMPTY for 210 of 276
later sweeps 392 objects 392 of 392 covered, 129 map rows of 2 329
carrying neither rule set nor priority (5.5 %)
Nothing was fixed between the first two lines. The system was in exactly the same state; what changed is that it could now be asked. 210 of 276 is not a defect that appeared - it is a defect that was already there, in production data, invisible because no endpoint would tell us.
The endpoints that made the difference
Four reads, none of them user-facing features, all of them ordinary functionality the services already had internally:
- the input snapshot for an account - which domains are in scope at all, and how many.
- the rule set in effect, with its rules - what is supposed to be applied.
- the pointer to the active set - which set the engine believes is current.
- the stored map, page by page - what was actually written, per row.
With those four, checking stops being an opinion. You read the snapshot and the rules, compute the expected result locally, then compare it to the stored map and to what the engine answers live:
snapshot 8 693 domains in scope
rule set 87 rules
expected, computed 87 marked
stored map 87 marked
engine answer 87 marked
───────────────────────────────────
mismatches 0 ← a verdict, not an impression
This is the whole argument of this part. The expected result is not something a checker invents; it is something the system has to be able to state. If the only endpoint is "do the work", then every verdict about it is a guess dressed as a test.
Two ways this goes wrong when the surface is missing
Both of these are mine, both cost real days.
A green result that never ran. A probe sent a single-value field to an endpoint that requires a list of ids. The endpoint answered 422; the probe took the empty decision list as "nothing disagreed" and printed PASS 24, FAIL 3. Two builds were accepted on a check that had performed zero comparisons. A false FAIL creates work; a false PASS silently removes the check.
The rule that came out of it is one line: an empty selection is a FAIL, not a PASS, and every probe carries a counter of comparisons actually made, non-zero by construction.
The right-looking read of the wrong thing. The set attached to a configuration was read from a listing endpoint that quietly ignores the filter it was given - a request for one account returned a hundred rows belonging to other accounts. The probe printed a confident, entirely fictional finding. Five days later the same misreading produced two phantom defects, one of which was filed as a comment on another team's ticket before being withdrawn.
That one costs a section in every spec since, in two halves:
- positive definition - which endpoint returns this value, by contract;
- negative definition - which reads do not prove it, and why.
This is functionality, not scaffolding
The tempting way to read all this is "add some debug endpoints". That framing is why they never get built.
Every one of those four reads is capability the service already has. Resolving the active pointer, listing rules, paging the map - it does all of it internally on every request. The only question is whether it is exposed. A service that keeps it inside is not simpler; it is a service whose behaviour can only be inferred.
Where the endpoint lives is a detail, and the detail is smaller than it looks: it can sit behind the same gateway as everything else, under an operator role, under normal rights, or on a separate admin surface. What matters is that the capability is presentable, and that it is treated as part of the product rather than as something added while chasing a bug. The same reads that let a checker compute an expectation are what an operator wants during an incident and what a support answer needs when someone asks why one account looks different.
Two design notes that came out of using them in anger:
- Read endpoints must page and count honestly. A page of five rows out of eighty-seven that says "there is more" while giving no cursor to continue with is not a diagnostic - it's another guess. That was its own defect and it is still open.
- Absence must be answerable. An account with no data should return an empty, successful answer, not an error. Modelling "nothing here" as a failure means every sweep has to guess which failures are real - we had objects answering three different codes for one state.
And the reason to treat these as requirements rather than nice-to-haves: everything the sweep found came out of these reads, not out of running the feature.
Today an executor, tomorrow the pipeline
Right now the sweep is driven by an automated executor: it reads the four endpoints, computes the expectation, compares, and writes a report with the numbers and the trace contents attached. That is a stage, not a destination. The same four reads and the same comparison belong in a pipeline against a stand - a scheduled run, or one run per deploy of the services involved.
Conceptually nothing changes between those columns; only the thing holding the clipboard does. Which is exactly why it is worth building the surface first and arguing about the runner later - the surface is the durable part, and it is useful to a human during an incident whether or not anything automated ever consumes it.
What it costs
Being honest about the price, because there is one:
- Endpoints have to be maintained. A read that describes internal state drifts with the internals; a stale describe-endpoint is worse than none, because it is believed.
- They widen the surface. Rights, rate limits, and "who may see how many rows of whose data" all have to be answered - the same answers as for any other endpoint, but they must actually be given.
- Computing the expectation locally is real work. It means writing down the rule of the system in a second place, which is a second thing to keep true.
Against that: the sweep of 276 that found 210 empty registries took minutes once the reads existed. Before they existed, the same finding was unavailable at any price.
The multiplier line
An executor turned a two-hour manual sweep into a repeatable one, and made "run it again on the next build and print the difference" cost nothing. It could not invent the four reads, and it could not tell a genuine mismatch from a probe reading the wrong endpoint - it printed both with equal confidence. What made the automation worth anything was the surface underneath it and the definition of where each value legitimately comes from. Fast is only useful once correct is defined.
Anatomy of a service split — Part 2. Next: the part before all of this - the analysis that was not done. One value with four homes and no owner, and how long it takes to find that out when nobody wrote down who is allowed to write it.
If you do this better, tell me what your services expose for checking their own state. If you have been through this, what did your first full sweep turn up that the sample never did? If you see it differently, say where a describe-your-state endpoint costs more than it returns. How is it solved on your side, and what broke there?














Top comments (0)