<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Anton Brilliantov</title>
    <description>The latest articles on DEV Community by Anton Brilliantov (@anton_brilliantov).</description>
    <link>https://dev.to/anton_brilliantov</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4056475%2F7d56fe3c-096c-42b1-88dd-e2ea7367624d.png</url>
      <title>DEV Community: Anton Brilliantov</title>
      <link>https://dev.to/anton_brilliantov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/anton_brilliantov"/>
    <language>en</language>
    <item>
      <title>What an Executor Needs Before It Can Test Production</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Sat, 17 Oct 2026 14:53:30 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/what-an-executor-needs-before-it-can-test-production-bp6</link>
      <guid>https://dev.to/anton_brilliantov/what-an-executor-needs-before-it-can-test-production-bp6</guid>
      <description>&lt;p&gt;&lt;em&gt;The quality of a check is set by the inputs, not by the executor.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services.&lt;/p&gt;

&lt;p&gt;A lot of that work is checking production: does the extracted service behave there the way the piece it replaced did. Most of those checks are handed to an executor - person or program, the rules below hold either way.&lt;/p&gt;

&lt;p&gt;Running notes live on my GitHub: &lt;strong&gt;&lt;a href="https://github.com/brilliant-almazov" rel="noopener noreferrer"&gt;github.com/brilliant-almazov&lt;/a&gt;&lt;/strong&gt;. This is the assignment I write today, price attached - maybe you write yours better, and I'd like to hear how.&lt;/p&gt;




&lt;h2&gt;
  
  
  The thesis
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The quality of a production check is decided by the completeness of the assignment, not by the intelligence of whoever executes it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Everything below is a consequence of that one line.&lt;/p&gt;

&lt;h2&gt;
  
  
  What one line of the assignment was worth
&lt;/h2&gt;

&lt;p&gt;I ran a sweep across a vertical of 276 configurations: does each one have a result, and if not, where does the path stop. 184 came back empty - against the assignment I had written, that was 184 problems.&lt;/p&gt;

&lt;p&gt;Then one condition entered the assignment: a configuration's schedule can be of a type that produces no tasks of this kind at all. With that line the same 184 became normal - the setup was doing what it was configured to do.&lt;/p&gt;

&lt;p&gt;One line changed &lt;strong&gt;67% of the picture&lt;/strong&gt;, and no care from the executor would have found it. The fact is not a field, not a status, not a counter - it lives in how the domain works, and it enters a check only if someone writes it into the assignment first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;One line of the assignment, 276 configurations
the same sweep, read against the assignment before and after that line

without the line
┌──────────────────────────────────────────────┬───────────────────────┐
│ 184  empty result - filed as a defect        │ 92  has a result      │
└──────────────────────────────────────────────┴───────────────────────┘

with the line
┌──────────────────────────────────────────────┬───────────────────────┐
│ 184  empty - the schedule makes no such task │ 92  has a result      │
└──────────────────────────────────────────────┴───────────────────────┘

67% of the picture changed, and no care from the executor would have
found it. The fact is not a field, not a status, not a counter.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsf4mcp7cbi8fzbf6bg2u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsf4mcp7cbi8fzbf6bg2u.png" alt="Before and after: a bar of 276 units where most are marked as a problem, then the same bar with 184 of them recoloured as normal, with a large 67% caption" width="799" height="562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is the whole argument. What follows is the list I now write before any check starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ten inputs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The surface, and a ban on every other one
&lt;/h3&gt;

&lt;p&gt;Name the one entry point the check goes through, and state that every other one is off limits. A system in migration has more than one front door, and results measured through two of them are not comparable.&lt;/p&gt;

&lt;p&gt;Without this line the executor picks whichever door answers, and the report silently mixes two systems.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. How to authenticate, with secrets living only in the environment
&lt;/h3&gt;

&lt;p&gt;State the authentication scheme and where credentials come from: the environment, never the assignment, an artefact or a commit.&lt;/p&gt;

&lt;p&gt;This is not only hygiene - an executor that has to improvise credentials improvises something that works once and cannot be repeated.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The exact shape of every request
&lt;/h3&gt;

&lt;p&gt;Path, method, every parameter with its range, every body field - the literal shape, not "call the listing endpoint".&lt;/p&gt;

&lt;p&gt;Anything left open gets interpreted plausibly, and a plausible-but-different request produces numbers that look comparable and are not.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. How each link is measured, and what counts as the fact
&lt;/h3&gt;

&lt;p&gt;For every link in the chain, say which single value is the fact: this field, this header, this counter. One number per link, read cheaply.&lt;/p&gt;

&lt;p&gt;Two people measuring "does this have data" in two different ways will disagree forever, and neither will be wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. The domain conditions that no read returns
&lt;/h3&gt;

&lt;p&gt;Provider coverage. Schedule types. What each task status actually means. No endpoint exposes any of it.&lt;/p&gt;

&lt;p&gt;This is the line that was worth 67% of my picture, and the one most likely to be missing - it is the only one that cannot be discovered by reading the system.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. What counts as a normal empty
&lt;/h3&gt;

&lt;p&gt;Say which empties are expected outcomes and which are defects, and how to tell them apart.&lt;/p&gt;

&lt;p&gt;An executor with no such rule counts every empty as a finding.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Where the artefacts go, and in what form
&lt;/h3&gt;

&lt;p&gt;One folder per run, one file per probe, written as the run goes, not assembled at the end.&lt;/p&gt;

&lt;p&gt;Left unspecified, the report becomes the only record - and a report is a summary of evidence, not the evidence.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. The rule for writing in production
&lt;/h3&gt;

&lt;p&gt;Snapshot, act, restore, a unique probe marker, and a confirmation by reading again.&lt;/p&gt;

&lt;p&gt;This one gets its own section below.&lt;/p&gt;

&lt;h3&gt;
  
  
  9. The shape of the verdict, and the duty to separate "not checked" from "passed"
&lt;/h3&gt;

&lt;p&gt;State the vocabulary of the verdict and require that unchecked cases be reported as unchecked.&lt;/p&gt;

&lt;p&gt;Left unstated, "not checked" quietly becomes "nothing found", which reads as "passed" to everyone downstream.&lt;/p&gt;

&lt;h3&gt;
  
  
  10. An explicit ban on concluding from one field without a control case
&lt;/h3&gt;

&lt;p&gt;Every conclusion needs a control beside the suspect: an object where the thing is known to work, read the same way, in the same run.&lt;/p&gt;

&lt;p&gt;I put this in writing because I broke it myself, twice.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ten inputs, written before the first request
the executor fills every one of these itself if the assignment leaves it out

    01  one surface, every other one banned
    02  auth scheme, secrets only from the environment
    03  the exact shape of every request
    04  one measured fact per link
 ▶  05  the domain conditions no read returns
        not exposed by any read - this one line moved 67% of the picture
    06  what counts as a normal empty
    07  artefacts: one folder per run, one file per probe
    08  snapshot, act, restore, verify
    09  verdict format: not checked ≠ passed
    10  no conclusion without a control case

Nine of them can be checked against the system. The fifth cannot - it is
a hand-written record of the world outside.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffc1ce3ep1he8825glx5y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffc1ce3ep1he8825glx5y.png" alt="Ten numbered lines listing the inputs, with the fifth line - external domain conditions - highlighted" width="800" height="654"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the normal empty gets its own line
&lt;/h2&gt;

&lt;p&gt;If a system answers "no data" with an error, every mass run looks like a catastrophe. Two thirds of the objects come back as not found, the genuine failures sit inside that pile, and every run starts by clearing the noise away.&lt;/p&gt;

&lt;p&gt;A normal empty is a success with zeros in it. Note where that requirement lands: on the system, not on whoever is checking it. Writing "treat two thirds of the answers as noise" into an assignment is a workaround for a contract that should have said so itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the write rule is not up for discussion
&lt;/h2&gt;

&lt;p&gt;Any write executed against production follows one shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;snapshot  →  act  →  restore  →  verify by reading again
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every object touched carries a unique probe marker, so anything left behind is identifiable as mine and removable without guessing. Restore is not done when the restore call returns - it is done when a fresh read shows the original state again.&lt;/p&gt;

&lt;p&gt;A check that leaves the polygon different from how it found it is not a check. It is a change, and whoever runs anything there next inherits it. Without the marker, cleanup depends on memory - the one part of a run nobody can audit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Every write executed against production

┌──────────┐   ┌─────┐   ┌─────────┐   ┌─────────────────────────┐
│ snapshot │──▶│ act │──▶│ restore │──▶│ verify by reading again │
└──────────┘   └─────┘   └─────────┘   └─────────────────────────┘
──────────────────────────────────────────────────────────────────
                              │
                              ▼
        every touched object carries a unique probe marker
   so anything left behind is identifiable as mine and removable
                        without guessing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjzsa39qj6bliywso7oyv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjzsa39qj6bliywso7oyv.png" alt="Four steps left to right - snapshot, act, restore, verify by reading again - with a probe marker labelled underneath" width="800" height="367"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when these are missing
&lt;/h2&gt;

&lt;p&gt;The executor does not stop. It fills the gaps itself, plausibly. A missing input does not produce an obviously broken report - it produces a coherent, confidently worded, wrong one, with nothing marking which parts came from the system and which from a guess.&lt;/p&gt;

&lt;p&gt;My own example, and not a flattering one: my first conclusion from a sweep was that a set of objects would not rebuild at all. They had failed on time, back to back. The objects were fine - the failure belonged to the pace I was driving the endpoint at, and one at a time with a pause they came back almost entirely clean. That distinction is the &lt;a href="//../1110-draft-property-of-the-object-or-of-the-pace/devto.md"&gt;next part&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;My assignment did not contain rule 10. Without it, one run and no control was enough to file a defect against a service that had lost nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;The inputs are assembled by hand, up front, before any checking starts. On a vertical of this size that is several hours whose only output is a document.&lt;/p&gt;

&lt;p&gt;On a one-off check that is pure overhead: if the question is asked once and never again, writing the ten inputs takes longer than answering it badly and answering it again. The trade pays when the check gets repeated - by the second run the assignment exists, while the guesswork would be re-invented from scratch.&lt;/p&gt;

&lt;p&gt;The other part of the price: the fifth input has no owner inside the system. It is a hand-written record of how the world outside works, and it goes stale silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that generalises
&lt;/h2&gt;

&lt;p&gt;A fast executor amplifies whatever is in the assignment, holes included. Complete inputs get a complete answer sooner; a gap gets a confident wrong answer sooner, and the confidence reads identically in both.&lt;/p&gt;

&lt;p&gt;The advantage goes to whoever can make correctness visible from the assignment itself, rather than from a second run and a comparison.&lt;/p&gt;




&lt;p&gt;This is my list right now, on one vertical, in a system halfway through a migration, price included.&lt;/p&gt;

&lt;p&gt;So: maybe &lt;strong&gt;you do this better&lt;/strong&gt;, and your assignments already carry the domain conditions in a form that stays current. Maybe &lt;strong&gt;you have been through this&lt;/strong&gt; and found which of the ten you can drop without paying for it. Maybe &lt;strong&gt;you see it differently&lt;/strong&gt; and think an executor should be trusted to work the conditions out for itself.&lt;/p&gt;

&lt;p&gt;How is this solved on your side, and what broke there?&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Testing production with an executor - Part 1.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Next: a failure on time, checked twice - once in a stream and once one at a time with a pause - and how to tell a property of the object from a property of the pace.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>productivity</category>
      <category>debugging</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Processing and Completeness Are Different Problems</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Thu, 15 Oct 2026 11:16:55 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/processing-and-completeness-are-different-problems-37ni</link>
      <guid>https://dev.to/anton_brilliantov/processing-and-completeness-are-different-problems-37ni</guid>
      <description>&lt;p&gt;&lt;em&gt;One verdict for two aspects keeps a closed task open forever.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services.&lt;/p&gt;

&lt;p&gt;This series is about the complaint that keeps landing on that migration: a user sees no result, and one service gets blamed by default. This part is about the last step of such a breakdown - writing down what was actually solved.&lt;/p&gt;

&lt;p&gt;Running notes live on my GitHub: &lt;strong&gt;&lt;a href="https://github.com/brilliant-almazov" rel="noopener noreferrer"&gt;github.com/brilliant-almazov&lt;/a&gt;&lt;/strong&gt;. It is how I write a verdict today, not a methodology - maybe you already do it better, maybe you'd do it another way entirely.&lt;/p&gt;




&lt;h2&gt;
  
  
  The thesis
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Mixing two aspects into one verdict turns a closed task into a permanently open one.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Everything below is a consequence of that.&lt;/p&gt;

&lt;p&gt;The two aspects are processing and completeness. They come out of the same breakdown and get written into the same report, and they are not the same problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two definitions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Processing&lt;/strong&gt; - how the system computes and answers on the data it already has: rules applied, the result rebuilt, the response deadline, concurrency, response codes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Completeness&lt;/strong&gt; - whether the data reaches the system at all: the schedule, the scheduler, the provider's queue, the region coverage.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌──────────────────────────────────┐   ┌──────────────────────────────────┐
│ PROCESSING                       │   │ COMPLETENESS                     │
│ how it computes on the data      │   │ whether the data arrives at all  │
│ it already has                   │   │                                  │
├──────────────────────────────────┤   ├──────────────────────────────────┤
│ rules applied                    │   │ schedule                         │
│ rebuild of the result            │   │ scheduler                        │
│ response deadline                │   │ provider queue                   │
│ concurrency                      │   │ region coverage                  │
│ response codes                   │   │                                  │
├──────────────────────────────────┤   ├──────────────────────────────────┤
│ owner: the processing side       │   │ owner: the collection side       │
└──────────────────────────────────┘   └──────────────────────────────────┘

no arrows: neither side moves the other column by working harder
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fib47k7fdz8oa9688b2c6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fib47k7fdz8oa9688b2c6.png" alt="Two columns side by side, processing on the left with rules applied, rebuild, deadline, concurrency and response codes, completeness on the right with schedule, scheduler, provider queue and region coverage, each column under its owning role, no arrows between them" width="800" height="419"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;No qualifiers on either definition.&lt;/p&gt;

&lt;p&gt;A fact belongs to one column or the other, and one question decides which: &lt;em&gt;does the system have the data yet?&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one number can't hold both
&lt;/h2&gt;

&lt;p&gt;The columns have different owners and different repairs.&lt;/p&gt;

&lt;p&gt;The team that owns processing cannot speed up somebody else's collection queue, and cannot create a schedule on a user's behalf.&lt;/p&gt;

&lt;p&gt;The team that owns collection cannot fix a response deadline.&lt;/p&gt;

&lt;p&gt;Neither of them moves the other column by working harder on its own.&lt;/p&gt;

&lt;p&gt;So a single "percent solved" over both averages two scales. Work on either side barely moves it. It stops being a signal and becomes a thing people argue about in the standup.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looked like in one breakdown
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Processing.&lt;/strong&gt; Every entity that had source data had a result: &lt;strong&gt;76 of 76&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A reference computation run beside production agreed with it on &lt;strong&gt;1 752 entities&lt;/strong&gt;, with &lt;strong&gt;0 discrepancies&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The two states where the accused service would actually be losing data - raw rows in with no snapshot, snapshot in with no result - came back &lt;strong&gt;0 and 0&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is a verdict, and the verdict is &lt;strong&gt;solved&lt;/strong&gt;. Where there was input, there was output, and the output matched an independent computation of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Completeness.&lt;/strong&gt; &lt;strong&gt;21&lt;/strong&gt; configurations were waiting for collection. &lt;strong&gt;184&lt;/strong&gt; are scheduled for a different type of collection entirely. &lt;strong&gt;14&lt;/strong&gt; have no keyword queries to collect for.&lt;/p&gt;

&lt;p&gt;Those are facts, and they stay facts. They are not a percentage of anything, and none of them says the computation is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the verdict I write now
&lt;/h2&gt;

&lt;p&gt;At the bottom of every breakdown, one line per aspect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;processing:   solved - 76/76 with data have a result; 1 752 entities, 0 discrepancies
completeness: 21 awaiting collection, 184 other schedule type, 14 without queries
              owner: the collection side
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhrgphnh875eppwfy887e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhrgphnh875eppwfy887e.png" alt="Two verdict lines, processing marked solved with 76 of 76 entities with data having a result and 1 752 entities matched with 0 discrepancies, completeness listing 21 configurations awaiting collection with the collection side named as owner" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The owner sits on the completeness line on purpose. A number with no address is a number somebody has to chase.&lt;/p&gt;

&lt;p&gt;The reader of a report should not have to derive the verdict. They should be able to read it.&lt;/p&gt;

&lt;p&gt;Everything above those two lines is the evidence. The two lines are the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What one verdict would have done
&lt;/h2&gt;

&lt;p&gt;The task would have been "partially solved" - and it would have stayed that way.&lt;/p&gt;

&lt;p&gt;Follow it through. Under a single verdict, the task closes only once the missing data arrives.&lt;/p&gt;

&lt;p&gt;That data arrives when a schedule fires, a queue drains, a region gets covered. None of those sit with the team that owns processing.&lt;/p&gt;

&lt;p&gt;So the task closes on somebody else's timeline, which in practice means it does not close.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ONE VERDICT                            TWO VERDICTS
one number over two aspects            two markers, no line between them

┌──────────────────────────────┐       ┌──────────────────────────────┐
│ partially solved             │       │ processing - closed          │
└──────────────────────────────┘       └──────────────────────────────┘
               │
               ▼
┌──────────────────────────────┐       ┌──────────────────────────────┐
│ closes only when the missing │       │ completeness - waiting,      │
│ data arrives                 │       │ owner named                  │
└──────────────────────────────┘       └──────────────────────────────┘
               │
               ▼
┌──────────────────────────────┐       each closes on its own owner's
│ it arrives on somebody       │       timeline
│ else's timeline              │
└──────────────────────────────┘
               │
               ▼
┌──────────────────────────────┐
│ so it comes back at every    │
│ review                       │
└──────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffhh7mrssc1jtztx8eyu1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffhh7mrssc1jtztx8eyu1.png" alt="One verdict on the left as a progress bar stuck at partially solved with an arrow looping back into reopened, two verdicts on the right as two independent markers, one closed and one waiting, with no line between them" width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This isn't status bookkeeping. An open task has a running cost: it comes back at every review, it gets discussed again, and the breakdown gets requested a second time by someone who wasn't in the first conversation.&lt;/p&gt;

&lt;p&gt;That is paid weekly, forever, for a computation that was already correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the split stops being honest
&lt;/h2&gt;

&lt;p&gt;The split is honest only while processing genuinely does not affect completeness.&lt;/p&gt;

&lt;p&gt;If the system loses data on its own leg - rows arrive and no snapshot is taken, a snapshot exists and no result is produced - that is processing, however much nicer it would be to hand it outward.&lt;/p&gt;

&lt;p&gt;So the split comes with a check, and the check gets named out loud: &lt;strong&gt;the two loss states must both be zero.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here they were 0 and 0, and that is the only reason the completeness column was allowed to belong to someone else. If either one is non-zero, there is no conversation about splitting aspects - there is a defect in processing, and it is mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;Two verdicts mean two addressees and two discussion threads, where one line used to do.&lt;/p&gt;

&lt;p&gt;The reader has to be walked through the difference once, and for a while afterwards the two still get merged in conversation - usually by whoever quotes the number without the line above it.&lt;/p&gt;

&lt;p&gt;I pay that, because the alternative is a task that never closes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that generalises
&lt;/h2&gt;

&lt;p&gt;An executor of any speed returns a verdict in the shape it was asked for.&lt;/p&gt;

&lt;p&gt;Ask for one number over two aspects and you get one number over two aspects, delivered faster. The averaging happens either way; it just happens sooner.&lt;/p&gt;

&lt;p&gt;The shape of the verdict is part of the assignment, not a matter of taste for whoever is reporting. It decides whether the work can ever be called done.&lt;/p&gt;




&lt;p&gt;This is how I split a verdict right now, and the price of it is written above. I'd like to hear the other side: if &lt;strong&gt;you do this better&lt;/strong&gt;, tell me how you separate what your system computes from what it receives; if &lt;strong&gt;you have been through this&lt;/strong&gt;, tell me which aspect ended up owning the never-closing task; if &lt;strong&gt;you see it differently&lt;/strong&gt;, say where drawing this line does more harm than the merged verdict. How is it solved on your side, and what broke there?&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Why there is no result - Part 4.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Next series: what an executor has to be given before a production check is repeatable - the surface, the exact shape of every request, the domain conditions no read exposes, and the difference between "not checked" and "passed". It starts in &lt;a href="//../1100-draft-what-an-agent-needs-to-test-production/devto.md"&gt;what an agent needs to test production&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>architecture</category>
      <category>productivity</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Two Cut-Off Factors Your Analysis Is Lying Without</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Wed, 14 Oct 2026 16:57:43 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/two-cut-off-factors-your-analysis-is-lying-without-10f9</link>
      <guid>https://dev.to/anton_brilliantov/two-cut-off-factors-your-analysis-is-lying-without-10f9</guid>
      <description>&lt;p&gt;&lt;em&gt;Coverage and backlog close a third of the cases before any debugging starts.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services.&lt;/p&gt;

&lt;p&gt;This series follows one question that keeps coming back from that work: &lt;em&gt;why does this entity have no result?&lt;/em&gt; Part 1 counted the empty screens, Part 2 split the path into links with owners. This part is about the two conditions that live outside the system entirely, and what happened when I ignored them. Running notes live on my GitHub: &lt;strong&gt;&lt;a href="https://github.com/brilliant-almazov" rel="noopener noreferrer"&gt;github.com/brilliant-almazov&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is how I do it right now, with the price attached - maybe you handle it differently, and I'd like to hear how.&lt;/p&gt;




&lt;h2&gt;
  
  
  The thesis
&lt;/h2&gt;

&lt;p&gt;Part of the empty cases should not be taken apart at all. They are closed by two external conditions, and &lt;strong&gt;neither of them is returned by any handle in the system.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Everything below is a consequence of that one sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Factor one: coverage
&lt;/h2&gt;

&lt;p&gt;The provider of the result data does not operate in some regions at all. Not slowly, not partially - it has no coverage there.&lt;/p&gt;

&lt;p&gt;That changes what the decision is made from. If the configuration sits in a region the provider does not cover, the answer is settled by the region alone.&lt;/p&gt;

&lt;p&gt;There is no point opening the task, reading its counters, or checking whether anything was ever queued. The chain of links has nothing to say here, because the chain never had an input.&lt;/p&gt;

&lt;p&gt;In the vertical I took apart, that was &lt;strong&gt;4 configurations&lt;/strong&gt; - removed from the investigation by one attribute of the configuration, before any measurement.&lt;/p&gt;

&lt;p&gt;The uncomfortable part: coverage is not a fact about my system. It is a fact about somebody else's product, and it can change without anyone telling me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Factor two: the queue
&lt;/h2&gt;

&lt;p&gt;The second condition is the provider's own backlog. A task can be created, accepted, and simply waiting - queued, not lost. From inside, both look identical: no rows, empty result.&lt;/p&gt;

&lt;p&gt;What separates them is the &lt;strong&gt;status of the task&lt;/strong&gt;, and there are three of them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task status&lt;/th&gt;
&lt;th&gt;What an empty result means here&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;waiting to be queued&lt;/td&gt;
&lt;td&gt;the task exists; nothing has been collected yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;receiving data&lt;/td&gt;
&lt;td&gt;collection is in progress - empty is a point in time, not an outcome&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;completed&lt;/td&gt;
&lt;td&gt;collection is over - this is the only status where empty is a question&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0iu8no3zpa5q6ndh5ulw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0iu8no3zpa5q6ndh5ulw.png" alt="Three task statuses - waiting to be queued, receiving data, completed - each with what an empty result means for it" width="799" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;An empty result on a task that is &lt;em&gt;receiving data&lt;/em&gt; is a wait. There is nothing in it to debug, nothing to file, and nobody to hand it to.&lt;/p&gt;

&lt;p&gt;Read as a failure, it produces a defect report against a service that is doing exactly what it should.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌────────────────────────────────────────────────────────────┐
│ all empty cases                                            │
│ every entity a user reported as having no result           │
└────────────────────────────────────────────────────────────┘
                              │
                              ▼
    ┌────────────────────────────────────────────────────┐
    │ region not covered                       4 removed │
    │ decided by the configuration's region alone        │
    └────────────────────────────────────────────────────┘
                              │
                              ▼
        ┌────────────────────────────────────────────┐
        │ queued at the provider          21 removed │
        │ a wait, not a loss - collection            │
        │ is in progress                             │
        └────────────────────────────────────────────┘
                              │
                              ▼
            ┌────────────────────────────────────┐
            │ worth debugging                    │
            │ the only ones opened link by link  │
            └────────────────────────────────────┘

neither cut-off is returned by any handle in the system
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe0oipgwcn05y12w62pk6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe0oipgwcn05y12w62pk6.png" alt="A funnel in three steps: all empty cases, minus four decided by a region the provider does not cover, minus twenty-one queued at the provider, leaving the cases worth debugging" width="800" height="546"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-field trap
&lt;/h2&gt;

&lt;p&gt;Here is the part I got wrong, and it is the reason this article exists.&lt;/p&gt;

&lt;p&gt;I reported &lt;strong&gt;seven configurations&lt;/strong&gt; as "the task never reached the provider".&lt;/p&gt;

&lt;p&gt;The evidence was a single field: on the row of the keyword query, the field naming the provider was empty. Empty field, no provider, task never sent. It reads like a fact.&lt;/p&gt;

&lt;p&gt;Then I ran the same read against a control object - one where the data had already arrived, where everything demonstrably worked. &lt;strong&gt;The same field was empty there too.&lt;/strong&gt; And the task behind it was in status &lt;em&gt;receiving data&lt;/em&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌──────────────────────────────────┐   ┌──────────────────────────────────┐
│ suspect                          │   │ control - data already arrived   │
│ reported as broken               │   │ demonstrably working             │
├──────────────────────────────────┤   ├──────────────────────────────────┤
│ key row                          │   │ key row                          │
│   provider: -                    │   │   provider: -                    │
├──────────────────────────────────┤   ├──────────────────────────────────┤
│ task, one level down             │   │ task, one level down             │
│   status: receiving data         │   │   status: receiving data         │
└──────────────────────────────────┘   └──────────────────────────────────┘

7 configurations reported as "the task never reached the provider"
on the evidence of a field that is empty in the working case too
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F14jwqcmq9r4eljh1tywe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F14jwqcmq9r4eljh1tywe.png" alt="Two columns, a suspect object and a control object where data has already arrived, showing the same empty provider field on the key row while the task status reads receiving data" width="800" height="408"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So the field was never the fact. It says nothing about whether a task was sent, because it is empty in the working case as well. The fact was one level away, in the status of the task itself.&lt;/p&gt;

&lt;p&gt;The rule that comes out of it is short: &lt;strong&gt;read the task status, not the key row.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The cost was not the debugging time. It was a rewritten report and a separate correction in the tracker: the conclusion had already been handed to people who would have gone looking for a defect that did not exist.&lt;/p&gt;

&lt;p&gt;A wrong conclusion travels. You have to go and catch it.&lt;/p&gt;

&lt;p&gt;The shape of the mistake is worth naming exactly, because it is not "I misread a field". It is: &lt;strong&gt;a conclusion drawn from one field, with no control case next to it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One field can only tell you what it contains. Whether that content means anything is answered by looking at the same field where things work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why these conditions can't be derived from the system
&lt;/h2&gt;

&lt;p&gt;Neither factor can be read out of the system at all. Not the provider's coverage, not the depth of its queue, not which type of schedule a configuration is on.&lt;/p&gt;

&lt;p&gt;No handle returns any of it, and no amount of careful measurement will produce it, because it isn't there.&lt;/p&gt;

&lt;p&gt;That knowledge lives with people. And while it lives only with people, every new breakdown rediscovers it - usually late, usually after a wrong conclusion has been written down.&lt;/p&gt;

&lt;p&gt;Hence the practical consequence: &lt;strong&gt;such conditions belong in the brief handed to whoever runs the check, as plain text&lt;/strong&gt;, on the same footing as the shape of the request.&lt;/p&gt;

&lt;p&gt;Not as background, not as "ask me if something looks odd" - as a stated condition, next to the endpoint and its parameters. That is the subject of a later part of this series, on what an executor needs before it can test production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does to the numbers
&lt;/h2&gt;

&lt;p&gt;Two cut-offs, applied before any link is read:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;4 configurations&lt;/strong&gt; removed by coverage - decided by the region alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;21 configurations&lt;/strong&gt; moved out of "broken" and into "waiting for collection" - tasks queued at the provider, being collected right now.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What is left after both cut-offs is a different question. Not &lt;em&gt;why is this empty&lt;/em&gt;, but &lt;em&gt;when does it arrive&lt;/em&gt;. That question has an answer, an owner, and no defect attached to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;Both conditions are external, and that is not a detail - it is the whole price.&lt;/p&gt;

&lt;p&gt;I cannot verify either of them with my own code. There is no assertion I can write that fails when the provider adds a region or drains its backlog.&lt;/p&gt;

&lt;p&gt;And I cannot keep them current automatically: a note about coverage is only true on the day it was written.&lt;/p&gt;

&lt;p&gt;So the honest statement is this: &lt;strong&gt;a stale record of coverage is exactly as damaging as no record at all&lt;/strong&gt; - worse, in one respect, because it looks like knowledge.&lt;/p&gt;

&lt;p&gt;It will close cases that should have been opened, and it will do it quietly, in the same voice as the correct record.&lt;/p&gt;

&lt;p&gt;I do not have a solution to that. The best I have is keeping the record small enough that its age is obvious, and writing down when it was last confirmed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that generalises
&lt;/h2&gt;

&lt;p&gt;An executor of any speed - a teammate, a new hire, an automated one - will fill in a missing condition on its own.&lt;/p&gt;

&lt;p&gt;Not out of carelessness: an analysis needs the condition to reach a conclusion, so if it isn't supplied, it gets inferred from whatever field is closest to hand. That is precisely how "the task never reached the provider" was produced.&lt;/p&gt;

&lt;p&gt;So the arithmetic is simple. Writing the condition down once costs a sentence. Unpicking a conclusion built on its absence costs a report, a correction, and other people's time spent looking for a defect that was never there.&lt;/p&gt;

&lt;p&gt;Speed doesn't change that balance. It just makes the second outcome arrive sooner.&lt;/p&gt;




&lt;p&gt;This is my experience and my price, on one vertical, in one system halfway through a migration.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you do this better&lt;/strong&gt;, tell me where external conditions like these live in your setup and how you keep them from going stale. If &lt;strong&gt;you have been through this&lt;/strong&gt;, tell me which single field turned out to be lying. If &lt;strong&gt;you see it differently&lt;/strong&gt;, say where reading one field without a control case is actually good enough.&lt;/p&gt;

&lt;p&gt;How is this solved on your side, and what broke there?&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Why there is no result - Part 3.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Next: processing and completeness are two different aspects and deserve two different verdicts - what a service computes on the data it has, versus whether the data arrives at all.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>debugging</category>
      <category>architecture</category>
      <category>productivity</category>
    </item>
    <item>
      <title>A Chain of Links, Not a Guess</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Tue, 13 Oct 2026 12:54:19 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/a-chain-of-links-not-a-guess-2o81</link>
      <guid>https://dev.to/anton_brilliantov/a-chain-of-links-not-a-guess-2o81</guid>
      <description>&lt;p&gt;&lt;em&gt;Measure the path the data takes, one number per link - not the endpoint that answered last.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 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 a complaint that keeps landing on that migration: a user sees an empty screen, the system thinks everything is fine, and one service gets blamed by default. This part is the method I use now to take those apart. Running notes live on my GitHub: &lt;strong&gt;&lt;a href="https://github.com/brilliant-almazov" rel="noopener noreferrer"&gt;github.com/brilliant-almazov&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It is how I work today, not a methodology - maybe you already do it better, maybe you'd do it another way entirely.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two different questions
&lt;/h2&gt;

&lt;p&gt;Checking a handle means asking: &lt;strong&gt;did the call answer correctly?&lt;/strong&gt; Status code, body shape, latency, error text. Clean question, clean test.&lt;/p&gt;

&lt;p&gt;Checking data means asking a different one: &lt;strong&gt;should this entity have had anything here at all?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The second question closes most of the complaints. It also can't be asked until the path the data takes is split into links - "should there be data here" is meaningless for a whole path at once. It only has an answer per link.&lt;/p&gt;

&lt;p&gt;Before that split, the first question was all I had, and it produced a specific kind of noise: two thirds of the entities came back with nothing, that read as a contract defect, and the few real failures drowned in it.&lt;/p&gt;

&lt;p&gt;The move from the first question to the second isn't a better assertion library. It's a different object under test: not the call, the path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The chain, and who owns each link
&lt;/h2&gt;

&lt;p&gt;A link is a place where data is &lt;em&gt;obliged&lt;/em&gt; to appear, and it has exactly one owner. Top to bottom, from the schedule to the finished markup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;schedule of the configuration        → the scheduler
set of keyword queries               → the dictionary/catalog service
collection task                      → the tool-task service
task run + its counters              → the tool-task service
raw result rows                      → the provider + the raw-data service
snapshot of entities                 → the domain-rule-map service
applied rules → markup               → the rule-set-markup service
rebuild of the result on request     → the rule-set-markup service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa6jsyqjx74rek0ohe8th.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa6jsyqjx74rek0ohe8th.png" alt="The eight links of the path from schedule to markup, each with the one number that counts as its fact on the left and its owning service on the right" width="800" height="647"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two things about that list before any measurement.&lt;/p&gt;

&lt;p&gt;The owner column is the point of the exercise, not decoration. A link without a single named owner isn't a link - it's a region of the system, and a region can't be handed to anyone.&lt;/p&gt;

&lt;p&gt;And some links are governed by conditions no read exposes: which types of schedule exist and what each means, which regions the provider covers at all. Not defects, not gaps in the code - domain facts that live outside every handle. The largest cohort below is decided by one of them.&lt;/p&gt;

&lt;p&gt;Note what the last two lines mean. The service blamed for the empty screen owns the bottom of the chain. It can't produce markup from a snapshot that was never taken, or snapshot raw rows that never arrived. Everything above it is somebody else's obligation - and until the chain is written down, saying that out loud sounds like deflection.&lt;/p&gt;

&lt;h2&gt;
  
  
  One number per link
&lt;/h2&gt;

&lt;p&gt;The measurement rule is what makes this cheap enough to repeat.&lt;/p&gt;

&lt;p&gt;Per link, take &lt;strong&gt;exactly one number&lt;/strong&gt;, with a cheap request at page size 1: read the total count off the response header, don't download the selection.&lt;/p&gt;

&lt;p&gt;That's the difference between a method and an intention. Pulling rows for eight links across a vertical takes hours, so nobody runs it twice. Reading eight counts takes tens of seconds, so it gets re-run after every fix - and "before" and "after" become the same measurement rather than two investigations.&lt;/p&gt;

&lt;p&gt;The number isn't evidence of correctness. It's evidence of &lt;em&gt;presence&lt;/em&gt;. That's all a link has to answer: is there anything here. Whether what's there is right is a later question, asked only of the links that came back full.&lt;/p&gt;

&lt;p&gt;Choosing that number is the one genuinely hard step, and it's a decision rather than a lookup: one thing per link is declared to be the fact, and every later reading uses it - including the ones that contradict what I expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  A control subject next to the suspect
&lt;/h2&gt;

&lt;p&gt;One number on its own is a guess. A link gets checked as a &lt;strong&gt;pair&lt;/strong&gt;: a control subject where everything works, and the suspect, same measurement, side by side.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                  control subject          suspect subject
                ┌─────────────────┐      ┌─────────────────┐
schedule        │      n &amp;gt; 0      │      │      n &amp;gt; 0      │
                ├─────────────────┤      ├─────────────────┤
queries         │      n &amp;gt; 0      │      │      n &amp;gt; 0      │
                ├─────────────────┤      ├─────────────────┤
task            │      n &amp;gt; 0      │      │      n &amp;gt; 0      │
                ├─────────────────┤      ├─────────────────┤
runs            │      n &amp;gt; 0      │      │        0        │
                ├─────────────────┤      ├─────────────────┤
raw rows        │      n &amp;gt; 0      │      │        0        │
                ├─────────────────┤      ├─────────────────┤
snapshot        │      n &amp;gt; 0      │      │        0        │
                ├─────────────────┤      ├─────────────────┤
markup          │      n &amp;gt; 0      │      │        0        │
                ├─────────────────┤      ├─────────────────┤
rebuild         │      n &amp;gt; 0      │      │        0        │
                └─────────────────┘      └─────────────────┘

a conclusion from one field without the pair is a guess
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff7v0jj7j8ny6kcd5743t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff7v0jj7j8ny6kcd5743t.png" alt="Two columns, a control subject and a suspect subject, one row per link and one number per cell, under a caption saying a conclusion from one field without the pair is a guess" width="799" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In this breakdown I got that wrong twice, the same shape both times: a conclusion drawn from one field, without the pair.&lt;/p&gt;

&lt;p&gt;The clearer of the two: I concluded a task had never reached the provider, because the field naming the provider on the keyword-query row was empty. Then I ran the same read against a control subject that already had data - same field, also empty, while the task's own status said it was accepting data. The field was never the fact; the status was. The full version of that trap, and the coverage factor next to it, is in &lt;a href="//../1020-draft-two-cut-off-factors/devto.md"&gt;Part 3&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The pair isn't a nicety or "running it twice". It's the only thing separating &lt;em&gt;this entity has no data&lt;/em&gt; from &lt;em&gt;this reading has no data&lt;/em&gt; - an empty number means both, and the response won't say which.&lt;/p&gt;

&lt;p&gt;Both mistakes cost the same: a conclusion about a service that was doing its job. Worse, they're wrong in the expensive direction - they send work to a team with nothing to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eight cohorts
&lt;/h2&gt;

&lt;p&gt;Once the chain is measured, every case lands in exactly one cohort:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;result present&lt;/strong&gt; - nothing to explain&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;schedule of a different type&lt;/strong&gt; - never scheduled for this kind of collection · &lt;em&gt;the scheduler&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;no keyword queries&lt;/strong&gt; - nothing to collect for · &lt;em&gt;the dictionary/catalog service&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;schedule present, no runs&lt;/strong&gt; - it should have fired and didn't · &lt;em&gt;the tool-task service&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;runs present, no raw rows&lt;/strong&gt; - collected, nothing back yet · &lt;em&gt;the provider + the raw-data service&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;raw rows present, no snapshot&lt;/strong&gt; · &lt;em&gt;the domain-rule-map service&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;snapshot present, no result&lt;/strong&gt; · &lt;em&gt;the rule-set-markup service&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;region the provider does not cover&lt;/strong&gt; - decided from the configuration's region alone · &lt;em&gt;the provider&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;COHORT                              OWNER OF THE EMPTY LINK      COUNT
─────────────────────────────────   ──────────────────────────   ─────
result present                      nothing to explain              76
schedule of a different type        scheduler                      184
no keyword queries                  dictionary/catalog service      14
schedule present, no runs           tool-task service                —
runs present, no raw rows           provider + raw-data service     21
raw rows present, no snapshot       domain-rule-map service          0
snapshot present, no result         rule-set-markup service          0
region the provider does not cover  the provider                     4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb19eb7mro45hh7zui4av.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb19eb7mro45hh7zui4av.png" alt="Table of the eight cohorts with the owner of the empty link and the count of configurations in each, the row for entities that already have a result highlighted" width="799" height="529"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The rule that makes this useful: &lt;strong&gt;"unclear" is not a cohort.&lt;/strong&gt; It's an unfinished measurement. A case that doesn't land means the next link hasn't been read - a task, not a verdict.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a conclusion is phrased
&lt;/h2&gt;

&lt;p&gt;Not "the service is broken". The form is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;link X is empty; the owner of link X is role Y.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the only phrasing I can hand to another team without a discussion attached. It names a fact and an address, it alleges no defect, and the receiving team can check it with the same number I read. "The markup service is losing data" starts an argument. "The snapshot link is empty, and that link belongs to the domain-rule-map service" starts a look.&lt;/p&gt;

&lt;p&gt;It also survives being wrong. If the owner reads the same link and finds it full, the disagreement is about one measurement, not about whose service is at fault.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers did
&lt;/h2&gt;

&lt;p&gt;The vertical had &lt;strong&gt;276 configurations&lt;/strong&gt;. Sorted by cohort:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cohort&lt;/th&gt;
&lt;th&gt;Configurations&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;result present&lt;/td&gt;
&lt;td&gt;76&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;schedule of a different type&lt;/td&gt;
&lt;td&gt;184&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;no keyword queries&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;region the provider does not cover&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;waiting for collection&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;raw rows present, no snapshot&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;snapshot present, no result&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last two rows carry the accusation. Those are the two states where the blamed service would actually be losing data - raw rows in, no snapshot; snapshot in, no result - and both are &lt;strong&gt;zero&lt;/strong&gt;. Where there was input, there was output.&lt;/p&gt;

&lt;p&gt;The 184 is the row to read twice. Those configurations are scheduled for a different type of collection: they were never going to have this result, and the system was right about every one of them.&lt;/p&gt;

&lt;p&gt;After the sorting, two configurations were left as genuine questions: one whose schedule is active but has never fired once, and one whose tasks are created daily and sit in the provider's queue. Two hundred-odd "problems" became two - and neither sat in the service the complaint pointed at.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;Two costs, and I'd rather name them than pretend the method is free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The chain has to be written down once&lt;/strong&gt;, by someone who knows the whole path: eight links, eight owners, and per link a decision about which single number counts as its fact. Nobody hands you that - it comes out of reading the system, before any measurement starts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And every link has to be measurable that cheaply&lt;/strong&gt;, which isn't always true. Where a total-count header lies, or a schedule isn't exposed by any read at all, the link gets measured by hand, subject by subject. There the breakdown stops being a sweep and becomes enumeration, and the cost argument above collapses for that link. It's worth knowing which links those are &lt;em&gt;before&lt;/em&gt; promising anyone a fast answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that generalises
&lt;/h2&gt;

&lt;p&gt;A chain of links with named owners is a form, not a document. It's the shape in which work can be handed to any executor - a teammate, a new hire, a fast automated one - and checked afterwards without re-running it, because the instruction and the result are both single numbers with addresses.&lt;/p&gt;

&lt;p&gt;Without that form, a faster executor just brings a wrong conclusion sooner. Speed multiplies whatever discipline is already there; it supplies none.&lt;/p&gt;




&lt;p&gt;This is my current answer to "there's no result", and the price of it is written above. I'd like to hear the other side: if &lt;strong&gt;you do this better&lt;/strong&gt;, tell me what your links are and how you measure them; if &lt;strong&gt;you have been through this&lt;/strong&gt;, tell me which link turned out to be the liar; if &lt;strong&gt;you see it differently&lt;/strong&gt;, say where the pair-based reading breaks down. How is it solved on your side, and what broke there?&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Why there is no result - Part 2.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Next: the two factors that cut cases out of the breakdown before any link is read - a provider that does not cover a region at all, and a provider queue where a task is waiting rather than lost.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>debugging</category>
      <category>architecture</category>
      <category>observability</category>
    </item>
    <item>
      <title>An Empty Screen Is Not Always a Failure</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Mon, 12 Oct 2026 12:09:05 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/an-empty-screen-is-not-always-a-failure-o1p</link>
      <guid>https://dev.to/anton_brilliantov/an-empty-screen-is-not-always-a-failure-o1p</guid>
      <description>&lt;p&gt;&lt;em&gt;One visible complaint, six different causes - five of them outside the service being blamed.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go.&lt;/p&gt;

&lt;p&gt;Most of what I do right now is carving a live PHP monolith into Go microservices, and much of that job is checking whether the extracted services behave the same way in production.&lt;/p&gt;

&lt;p&gt;This series is about one question that keeps coming back from that work: &lt;em&gt;why does this entity have no result?&lt;/em&gt; Running notes live on my GitHub: &lt;strong&gt;&lt;a href="https://github.com/brilliant-almazov" rel="noopener noreferrer"&gt;github.com/brilliant-almazov&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is how I answer that question today, with the price attached. Maybe some of it transfers; maybe you look at it differently, and I'd like to hear how.&lt;/p&gt;




&lt;h2&gt;
  
  
  The complaint
&lt;/h2&gt;

&lt;p&gt;Users open a screen and see nothing where they expect markup. The rule is configured, the result is empty.&lt;/p&gt;

&lt;p&gt;The complaint arrives already addressed: &lt;strong&gt;the rule-set-markup service&lt;/strong&gt;. One sentence, one accused service, no numbers.&lt;/p&gt;

&lt;p&gt;It is a reasonable accusation: that service is the last link before the screen, and the last link is what the user sees.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first way I counted, and why it was wrong
&lt;/h2&gt;

&lt;p&gt;My first move was a sweep: run every object in the vertical through the endpoint and count the answers.&lt;/p&gt;

&lt;p&gt;Roughly two-thirds of the objects answered &lt;em&gt;not found&lt;/em&gt;. A further group failed on time. Read as a report card, that says: unusable.&lt;/p&gt;

&lt;p&gt;It was wrong, and the reason is worth naming precisely. &lt;strong&gt;The sweep measured the endpoint, not the data.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Nobody had asked whether those objects were supposed to have a result at all. &lt;em&gt;Not found&lt;/em&gt; is a truthful answer about an object that never had input. Counted as a defect, it becomes noise - and the handful of real failures drown inside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the recount from the data showed
&lt;/h2&gt;

&lt;p&gt;The second pass asked a different question of each object: does it have input, and if so, does it have output? That turns one complaint into arithmetic.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Configurations in the vertical&lt;/td&gt;
&lt;td&gt;276&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;With a source snapshot&lt;/td&gt;
&lt;td&gt;76&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Of those, with a result&lt;/td&gt;
&lt;td&gt;76&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Snapshot rows / result rows&lt;/td&gt;
&lt;td&gt;433,348 / 2,239&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Raw rows exist, no snapshot"&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Snapshot exists, no result"&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fokb5abhb0nvwow7msgir.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fokb5abhb0nvwow7msgir.png" alt="Four rows of counts: 276 configurations; 76 with a snapshot and 76 with a result; zero snapshots without a result; zero raw rows without a snapshot" width="799" height="348"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Those two zeros are the whole verdict on the accused service. Every object that had input had output.&lt;/p&gt;

&lt;p&gt;Separately, a reference calculation of the same markup agreed with production on &lt;strong&gt;1,752 entities with 0 discrepancies&lt;/strong&gt; - so it is not producing merely &lt;em&gt;something&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The empty screens are real, and the service blamed for them lost nothing. That is the actual shape of the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Six causes behind one empty screen
&lt;/h2&gt;

&lt;p&gt;The rest of the vertical does not collapse into a single explanation. It splits into named cohorts, and each has a different owner:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;184&lt;/strong&gt; - the configuration's schedule collects a different type of data, one that produces no position tasks at all. Owner: the scheduler.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;14&lt;/strong&gt; - the configuration has no keyword queries at all. Nothing to plan. Owner: the dictionary/catalog service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1&lt;/strong&gt; - the schedule exists and is active, but has never fired once. Owner: the scheduler.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1&lt;/strong&gt; - tasks are created daily and stand in the provider's queue, waiting for collection. Queued, not lost. Owner: the provider-request service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4&lt;/strong&gt; - the configuration's region is not covered by the provider at all. Decided by region alone. Owner: outside our system entirely.&lt;/li&gt;
&lt;li&gt;and the sixth, the only one belonging to the accused service - &lt;strong&gt;failures on time&lt;/strong&gt;, which turned out to be a property of the pace of my own sweep rather than of the objects.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh9l2saf71s49itglm23t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh9l2saf71s49itglm23t.png" alt="Horizontal bar split into six labelled cohorts, with a line underneath reading zero lost by the service being blamed" width="800" height="459"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Five of the six sit &lt;em&gt;before&lt;/em&gt; the service that received the complaint.&lt;/p&gt;

&lt;h2&gt;
  
  
  The chain, and where it breaks
&lt;/h2&gt;

&lt;p&gt;The cohorts are addressable because the path from a schedule to a rendered result is a chain of links, and every link has an owner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LINK                                 OWNER                          EMPTY HERE
─────────────────────────────────    ──────────────────────────     ──────────────
schedule of the configuration        scheduler                      184 · 1
set of keyword queries               catalog service                14
collection task                      tool-task service              ·
task run + its counters              tool-task service              ·
raw result rows                      provider + raw-data service    1 queued · 4 region
snapshot of entities                 domain-rule-map service        0
applied rules → markup               rule-set-markup service        0
rebuild of the result on request     rule-set-markup service        timeouts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsdb3cb5ttvj9sjsyp2kd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsdb3cb5ttvj9sjsyp2kd.png" alt="Eight chain links top to bottom, each with its owner role on the right and, on the left, markers showing which of the six causes stops the path at that link" width="800" height="612"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read the right-hand column and the complaint stops being a complaint. It becomes a distribution: most of the emptiness is decided at the first link, and the two links owned by the accused service carry a zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest mistake, and what it cost
&lt;/h2&gt;

&lt;p&gt;Here is the part I got wrong. Leaving it out would make this a sales pitch.&lt;/p&gt;

&lt;p&gt;The first conclusion I published from the sweep was: &lt;strong&gt;"23 objects don't rebuild."&lt;/strong&gt; That was a defect report against the service, and it was false.&lt;/p&gt;

&lt;p&gt;The failures on time came in batches - 20 to 23 of them, back to back rather than scattered. I took the same objects and ran them one at a time with a pause: &lt;strong&gt;21 succeeded immediately, 2 after a pause.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The failure was a property of the pace I was driving the endpoint at, not of the objects.&lt;/p&gt;

&lt;p&gt;The cost was a whole extra round of measurement.&lt;/p&gt;

&lt;p&gt;The cause is not mysterious: a conclusion drawn from one run without a control case. One run tells you what happened; it does not tell you what the run itself contributed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the breakdown changes in the work
&lt;/h2&gt;

&lt;p&gt;While "no result" is a single complaint, it cannot be closed and it cannot be handed to anyone. It sits on whichever service is nearest the screen, and every conversation about it restarts from zero.&lt;/p&gt;

&lt;p&gt;Split into cohorts with an owner each, every part has an addressee: the scheduler, the keyword catalog, the provider, the processing service.&lt;/p&gt;

&lt;p&gt;Some parts turn out not to be work at all. 184 configurations that were never meant to produce this type of result are a fact about the setup, not a backlog item.&lt;/p&gt;

&lt;p&gt;What remains is small, specific, and belongs to someone by name.&lt;/p&gt;

&lt;p&gt;That is the difference. Not "the service is fine", but "here is where each empty screen stops, and here is who owns that link."&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;The breakdown took several runs and a lot of manual work, because &lt;strong&gt;the system does not answer "why is this object in this state" through its own interfaces.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There is no place to ask whether a configuration even has a schedule that produces this type of task, so the answer is assembled by hand, one link at a time.&lt;/p&gt;

&lt;p&gt;Until the system returns those answers itself, every new breakdown starts by clearing the noise from the previous one - a cost paid again on every investigation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that generalises
&lt;/h2&gt;

&lt;p&gt;A mass sweep is cheap now. Any executor can push a whole vertical through an endpoint before lunch.&lt;/p&gt;

&lt;p&gt;The conclusion drawn from it is not cheap. "23 objects don't rebuild" was produced quickly and was wrong, and being wrong quickly cost more than being right slowly would have.&lt;/p&gt;

&lt;p&gt;The discipline that matters is not speed. It is making the conclusion checkable &lt;em&gt;without&lt;/em&gt; re-running the thing: a control case next to the suspect, one number per link, and a stated rule for what counts as a normal empty. Speed multiplies whatever discipline is already there - including its absence.&lt;/p&gt;




&lt;p&gt;This is my experience and my price, on one vertical, in one system halfway through a migration.&lt;/p&gt;

&lt;p&gt;So: maybe you do this better - maybe your services already answer "why is this empty" themselves. Maybe you have been through this and found where it stops being worth the effort. Maybe you see it differently and think the sweep was the right instrument all along.&lt;/p&gt;

&lt;p&gt;How is this solved on your side, and what broke there?&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Why there is no result - Part 1.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Next: the chain of links as an instrument - one cheap number per link instead of guessing which service to accuse, and the eight cohorts any case reduces to.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>debugging</category>
      <category>architecture</category>
      <category>productivity</category>
    </item>
    <item>
      <title>What Checks A Release When There Is No Canary</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Sun, 11 Oct 2026 15:39:50 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/what-checks-a-release-when-there-is-no-canary-17me</link>
      <guid>https://dev.to/anton_brilliantov/what-checks-a-release-when-there-is-no-canary-17me</guid>
      <description>&lt;p&gt;&lt;em&gt;A canary and shadow traffic both ask for something the chain has to be able to do - the second binary is the cheap half of either one.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I am Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This block has been about the things a fleet has to do identically or every service reinvents them; this last part is about the moment after all of them: a change is built, and the only question left is what stands between it and everybody. Running notes live on my GitHub: &lt;strong&gt;&lt;a href="https://github.com/brilliant-almazov" rel="noopener noreferrer"&gt;github.com/brilliant-almazov&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is how I do it right now, with the price attached - maybe you already do it better, maybe you have been through it, maybe you see it differently.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where this sits
&lt;/h2&gt;

&lt;p&gt;A short primer, so this reads on its own.&lt;/p&gt;

&lt;p&gt;A PHP monolith is being taken apart into Go services. The &lt;strong&gt;Go API gateway&lt;/strong&gt; is the only door into the new services from outside; behind it the extracted services talk to each other over gRPC, and every one of them is raised from the same shared platform library - which is where health endpoints, metrics, tracing and the messaging layer come from, rather than from each service writing its own. Some work is answered inside the call; the rest is published as a domain event and picked up later by a background handler in a separate process.&lt;/p&gt;

&lt;p&gt;Two things follow from that shape and both matter below. The &lt;strong&gt;platform supplies the observation&lt;/strong&gt;, so the question is never "do we have metrics" but "do the metrics separate the thing we are comparing". And the &lt;strong&gt;publish boundary is a real boundary&lt;/strong&gt;: everything past it is a different process, a different tree in the trace, and - as it turns out - a different answer to whether a copy of a request is harmless.&lt;/p&gt;

&lt;h2&gt;
  
  
  What stands between a commit and everybody
&lt;/h2&gt;

&lt;p&gt;Before any opinion about canaries, the inventory - counted rather than described.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Three pipelines.&lt;/strong&gt; A build fires on a pushed tag &lt;code&gt;vX.Y.Z&lt;/code&gt; and stamps the version and the commit into the binaries. Tests run on every pull request, on every push to the main branch, and nightly on a schedule. Deployment is a &lt;strong&gt;manual pipeline run with two parameters&lt;/strong&gt;: which version, and which services to move - &lt;code&gt;server&lt;/code&gt;, &lt;code&gt;worker&lt;/code&gt;, &lt;code&gt;all&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;67 records&lt;/strong&gt; in the machine-checked metrics snapshot: 59 of them come from the platform library, 6 from the service, plus one &lt;code&gt;&amp;lt;dynamic&amp;gt;&lt;/code&gt; entry. &lt;strong&gt;13 domain metrics&lt;/strong&gt; sit on top of that. Drift between the snapshot and reality fails CI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nine alert rules&lt;/strong&gt;, described in text and created on the fleet monitoring side. The dashboard is assembled by a generator next to the rest of the fleet dashboards; neither dashboards nor alerts live as files in the service repository.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;86.7 % line coverage&lt;/strong&gt;, checked on every run against a ratchet whose threshold currently sits at &lt;strong&gt;&lt;code&gt;0&lt;/code&gt;&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero&lt;/strong&gt; requests routed into a canary.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkl0nu2gflckhgvobgzn0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkl0nu2gflckhgvobgzn0.png" alt="A horizontal band from commit to everyone with four gates on it - the tests pipeline, the tag build, the manual deploy with two parameters, and metrics and alerts after it - and a dashed rectangle below the band labelled canary, marked not here" width="799" height="442"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That last line is not an omission in the list. There is no routing of a marked request into a new version in production on my side, and the previous part is the reason: the mark has to reach the last hop, and mine would not have.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "checking a release before everyone" actually means
&lt;/h2&gt;

&lt;p&gt;Two techniques, and it is worth writing them down in one line each, because they are routinely discussed as if they were the same thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A canary.&lt;/strong&gt; Part of the live traffic is sent to the new version while everybody else stays on the old one, and the two are compared on the metrics that matter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shadow traffic.&lt;/strong&gt; A copy of the live traffic is sent to the new version, and the answer the user receives still comes from the old one. Nothing the new version produces is shown to anybody.&lt;/p&gt;

&lt;p&gt;Both answer exactly one question: &lt;strong&gt;does the new version behave the same way on real input.&lt;/strong&gt; Neither of them answers it by being deployed. They answer it by being observable, which is a different requirement and a more expensive one.&lt;/p&gt;

&lt;p&gt;The tempting reading is that the hard part is running two versions at once. It is not - on a deploy that already takes a version as a parameter, standing the second one up costs one more pipeline run. The hard part is everything the two techniques assume about the chain around them, and that is what the rest of this is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this is usually done
&lt;/h2&gt;

&lt;p&gt;Four shapes come up in public write-ups and standards, and all four are reasonable: a &lt;strong&gt;gradually increasing share of traffic&lt;/strong&gt; with the failure rate watched as the dial moves (&lt;a href="https://martinfowler.com/bliki/CanaryRelease.html" rel="noopener noreferrer"&gt;CanaryRelease&lt;/a&gt;); &lt;strong&gt;mirroring requests&lt;/strong&gt; into a second, non-answering path; &lt;strong&gt;two colours side by side&lt;/strong&gt;, where a whole second environment is raised and traffic is switched to it at once (&lt;a href="https://martinfowler.com/bliki/BlueGreenDeployment.html" rel="noopener noreferrer"&gt;BlueGreenDeployment&lt;/a&gt;); and a &lt;strong&gt;sandbox on a copy of the environment&lt;/strong&gt;, exercised with no live traffic at all.&lt;/p&gt;

&lt;p&gt;The previous part compared those four by blast radius - how much of the world meets one mistake. This part compares them along a different axis, and it is the axis that decides whether you can have them at all: &lt;strong&gt;what each one demands from the chain and from the data.&lt;/strong&gt; I am naming no gateways, no service meshes and no tracing products; the choice of product is not what makes any of this work or fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each way needs
&lt;/h2&gt;

&lt;p&gt;Three rows. The last one is not a technique, it is what is actually there.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Way&lt;/th&gt;
&lt;th&gt;What has to work&lt;/th&gt;
&lt;th&gt;What it costs&lt;/th&gt;
&lt;th&gt;Have it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;canary&lt;/td&gt;
&lt;td&gt;mark reaches the last hop; two generations, one set of rows&lt;/td&gt;
&lt;td&gt;a share of traffic; a version cut in metrics&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;shadow traffic&lt;/td&gt;
&lt;td&gt;a copy with no side effects; writes split from reads&lt;/td&gt;
&lt;td&gt;reads served twice; a separate write path&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;what I have&lt;/td&gt;
&lt;td&gt;executable checks before, metrics and alerts after&lt;/td&gt;
&lt;td&gt;a defect found after everyone met it&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the first row from the right: the two things a canary needs before it is worth anything are a &lt;strong&gt;marked request that survives every hop&lt;/strong&gt; and &lt;strong&gt;metrics that can tell the generations apart&lt;/strong&gt;. Neither of them is the second binary.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffbunmz8yntn9evncdqc6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffbunmz8yntn9evncdqc6.png" alt="Three rows - canary, shadow traffic and what we have - each with what has to work, what it costs and a verdict pill reading no, no and yes" width="800" height="527"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There are no numbers in that table on purpose. The share of canary traffic, the observation window, the rollback threshold, how long a rollout takes - none of those has been measured on my side, and a plausible figure would be worse than a visible hole.&lt;/p&gt;

&lt;h2&gt;
  
  
  The canary runs into the wall from the previous part
&lt;/h2&gt;

&lt;p&gt;Short, because that part already did the work. A canary needs the mark on the request to survive every hop; if it stops after the first one, the first service runs the new code and everything it calls afterwards runs the old one - one user action executed by two generations, with each half looking correct from where it stands. It needs the contract to be compatible in &lt;strong&gt;both&lt;/strong&gt; directions, and data written by the new version to be readable by the old one. It needs whoever is testing to know which branch they are in, or the debugging is done blind.&lt;/p&gt;

&lt;p&gt;None of that is technical difficulty. It is &lt;strong&gt;two generations of a system being true at the same time&lt;/strong&gt;, and people holding both in their heads while they work.&lt;/p&gt;

&lt;p&gt;There is a second wall, and it is the quieter one: &lt;strong&gt;there is nothing to compare the generations by.&lt;/strong&gt; The tenant identifier is deliberately kept out of metric labels for cardinality reasons, and there is no version dimension in the snapshot today either. A canary without metrics that separate the two versions is not a controlled experiment - it is a rollout on live people with someone watching a graph that cannot tell them apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a shadow contour stops being free
&lt;/h2&gt;

&lt;p&gt;Shadow traffic looks like the safe one of the two, because nothing it produces reaches a user. That holds exactly as long as the copied request does not write anything.&lt;/p&gt;

&lt;p&gt;The facts on my side that decide it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;delivery is &lt;strong&gt;at-least-once&lt;/strong&gt;, and the broker does not deduplicate;&lt;/li&gt;
&lt;li&gt;a redelivery carrying the same &lt;code&gt;message_id&lt;/code&gt; is &lt;strong&gt;normal behaviour&lt;/strong&gt;, not an error - the incident is a &lt;em&gt;spike&lt;/em&gt; in redeliveries, not their existence;&lt;/li&gt;
&lt;li&gt;the normal state of the outgoing queue is empty, which is what makes a spike legible at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So a mirrored request that gets as far as publishing does not produce a shadow message. It produces a &lt;strong&gt;real&lt;/strong&gt; message, on the real stream, for the real consumers, indistinguishable from one a user asked for.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9tdrtdbax9vl8bk7fzbx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9tdrtdbax9vl8bk7fzbx.png" alt="A left-to-right chain - request, read path, the write boundary drawn as a vermilion rule, publish and consumers - with the read half marked safe to mirror and everything past the boundary marked real messages, real consumers" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Which gives the rule in one line: &lt;strong&gt;a shadow contour is meaningful exactly up to the point where writing starts.&lt;/strong&gt; Past that point it needs a separate path for every write, and building that path costs about what building the new version cost. That is the whole reason shadow traffic is rare in systems that publish events, and common in systems that mostly read.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually checks a release here
&lt;/h2&gt;

&lt;p&gt;With both techniques unavailable, the honest question is what is left - and the answer is a list of things that fail the build rather than a list of intentions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before the deploy&lt;/strong&gt;, the test pipeline runs, in this order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  build
  linter
  bring up the shared test containers        one script, one container per type, per run
  run the tests with coverage
  check the time budget                      a separate step, not a note in the log
  tear the containers down                   if: always()
  coverage report
  threshold check
  ratchet                                    raising it is allowed, lowering fails the PR
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is &lt;strong&gt;one container of each infrastructure type for the whole run&lt;/strong&gt; - isolation between tests is done with schemas and data, never by raising another container - and the teardown is explicit rather than left to a reaper. The time budget is a step of its own for the same reason everything else here is: a run that quietly got three times slower is a defect, and a defect that fails nothing is a defect nobody will look at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nightly&lt;/strong&gt;, a separate job runs the race detector and the volume tests with &lt;code&gt;-p 1&lt;/code&gt;, so that a comparison of &lt;code&gt;ns/op&lt;/code&gt; between runs means something.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two catalogues fail the build on drift.&lt;/strong&gt; The environment variable catalogue - 59 variables, 45 from the platform and 14 from the service - and the metrics snapshot above. Both are machine-checked artefacts, not documents somebody is supposed to update.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One check exists purely because of a silent failure mode.&lt;/strong&gt; The version is stamped into the binary with a linker flag, and the linker &lt;strong&gt;silently ignores&lt;/strong&gt; a flag aimed at a symbol that does not exist: the build stays green and the version stays &lt;code&gt;dev&lt;/code&gt; - in the audit trail, in the trace attribute, everywhere. So the build target compiles both binaries with a test tag and greps the binary for it. Empty output means the symbol is wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After the deploy&lt;/strong&gt;, &lt;code&gt;/health&lt;/code&gt; and &lt;code&gt;/ready&lt;/code&gt; are served by the platform, and there is &lt;strong&gt;no "wait for health" step in the pipeline&lt;/strong&gt; - that check belongs to the environment. Then the metrics and the nine alert rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  The case: the ratchet is wired, the bar is not set
&lt;/h2&gt;

&lt;p&gt;This is the one I would fix first, and it is small enough to state in three lines.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  coverage    0 %                              86.7 %              100 %
              |------------------------------------|-----------------|
  threshold   ^ 0                                  ^ actual
              the ratchet can only move right - and it starts here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What is there.&lt;/strong&gt; A coverage ratchet: the threshold can be raised, never lowered, and a pull request that tries to lower it fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it costs today.&lt;/strong&gt; The threshold sits at &lt;code&gt;0&lt;/code&gt; while actual coverage is 86.7 %. So the mechanism is wired and works in one direction only - it cannot be walked back - but the bar it is holding is on the floor. Lowering coverage is impossible; &lt;strong&gt;shipping a release with coverage far below 86.7 % is not&lt;/strong&gt;, and nothing in the pipeline would notice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is holding form next to it.&lt;/strong&gt; The two drift checks and the container discipline above are the same idea done properly: an artefact that fails the build, not a rule somebody is meant to remember.&lt;/p&gt;

&lt;p&gt;The reading that generalises: a release here is checked by &lt;strong&gt;executable checks before the deploy and metrics after it&lt;/strong&gt;. A canary is not in that row, and neither is anything else that depends on a marked request travelling further than the first hop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it is arranged this way
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Because a check you can miss is not a check.&lt;/strong&gt; The things listed above all share one property: they fail something. The linter fails, the threshold fails, the snapshot drift fails, the ratchet fails a pull request. That is worth more per hour spent than any technique whose failure mode is "nobody looked at the dashboard". So the order is: ratchet and prohibiting tests first, canary second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Because the observation is not there yet.&lt;/strong&gt; With no way to separate the two generations in the metrics, a canary would produce a feeling rather than a comparison. Building the version cut is the prerequisite, not the follow-up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;Stated plainly, because the arrangement above is a trade, not a victory.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A defect that tests did not catch is found after everyone has met it.&lt;/strong&gt; The blast radius of a mistake is the entire traffic. That is the price of the whole section above, and it is not a small one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The ratchet holds nothing at the moment.&lt;/strong&gt; &lt;code&gt;0&lt;/code&gt; against 86.7 % - wired, one-directional, and set on the floor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The nightly signal arrives a day late.&lt;/strong&gt; Races and volume are checked once a night, not per pull request, so a race introduced in the morning is a tomorrow problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One check exists only because a tool fails silently&lt;/strong&gt;, and silent failures of that shape are almost certainly not down to one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A human is part of the deploy procedure.&lt;/strong&gt; A manual run with two parameters means somebody picks the version and the service set, and that choice is checked by nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7rkw5hlnhrl7qnvbqg00.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7rkw5hlnhrl7qnvbqg00.png" alt="A coverage scale with the actual value at 86.7 per cent and the threshold marked at 0, a ratchet arrow allowing movement in one direction only, and the line the ratchet is wired, the bar is not set" width="800" height="424"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to do this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;When traffic is small, a canary gives you no statistics.&lt;/strong&gt; A share of a small number is a smaller number, and a comparison on it is noise wearing a lab coat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When writes are not separated from reads, a shadow contour is dangerous&lt;/strong&gt;, not cautious. The copy stops being a copy at the first publish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When the mark does not reach the last hop, a canary checks the first step and lies about the rest&lt;/strong&gt; - and a half-migrated request is worse than no rollout mechanism at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the boundary of this write-up, said outright: &lt;strong&gt;there is no canary and no shadow contour here&lt;/strong&gt;, there is no routing of a marked request in production, there are no declared SLAs and no formal SLO, and the coverage bar is not set. Everything above is a breakdown of what these techniques require and what they cost, plus an inventory of what is holding the line instead - not a report on a working scheme.&lt;/p&gt;

&lt;h2&gt;
  
  
  The multiplier line
&lt;/h2&gt;

&lt;p&gt;An automated executor makes the mechanical half of this cheap: wiring the threshold check, writing the teardown step, generating the snapshot, adding the version cut to a metric. What it does not do is decide that a check which cannot fail the build is not a check, or notice that a mirrored request stops being a copy at the first publish. Those are judgements, and they are the whole content of this article. Speed amplifies whoever set the definitions; it does not supply them.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Access, tracing, rollout - Part 6.&lt;/strong&gt; That closes this block: who arrived, what they may do, where their request has been, what decides a branch, how one request reaches a new version, and - here - what checks that version when nothing routes traffic to it yet.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you do this better&lt;/strong&gt;, tell me what your release has to pass before it reaches everybody, and which of those checks can actually fail a build. If &lt;strong&gt;you have been through this&lt;/strong&gt;, what did your shadow copy turn out to write that nobody expected? If &lt;strong&gt;you see it differently&lt;/strong&gt;, say where shipping to everyone at once and watching the graphs is the honest answer. How is it solved on your side, and what broke there?&lt;/p&gt;

</description>
      <category>devops</category>
      <category>testing</category>
      <category>observability</category>
      <category>deployment</category>
    </item>
    <item>
      <title>The Mark That Stops After The First Hop</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Sat, 10 Oct 2026 13:31:44 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/the-mark-that-stops-after-the-first-hop-5hl</link>
      <guid>https://dev.to/anton_brilliantov/the-mark-that-stops-after-the-first-hop-5hl</guid>
      <description>&lt;p&gt;&lt;em&gt;A new version is not ready when it is up. It is ready when the mark reaches the last hop - and mine would not have.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This block is about the things a fleet of services has to do identically or every service reinvents them; this last part is about putting a new version in front of one request instead of all of them. Running notes live on my GitHub: &lt;strong&gt;&lt;a href="https://github.com/brilliant-almazov" rel="noopener noreferrer"&gt;github.com/brilliant-almazov&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is how I do it right now, with the price attached - maybe you already do it better, maybe you have been through it, maybe you see it differently.&lt;/p&gt;




&lt;h2&gt;
  
  
  The answer I already had
&lt;/h2&gt;

&lt;p&gt;This starts with something that was already broken, before the idea below was on the table.&lt;/p&gt;

&lt;p&gt;An audit of one of my own services asked a single question: where in this service is there code that the shared platform library already provides. One finding reads like a footnote and is not one:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The publish was going out with empty headers.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The first consequence was a decoding one. With no header to select a codec by, the platform typed receive was physically inapplicable, so the consumer had to call JSON decoding by hand, in the handler, on raw bytes.&lt;/p&gt;

&lt;p&gt;The second consequence is the one this article is about. &lt;strong&gt;Anything that travels in headers did not cross the queue boundary.&lt;/strong&gt; Not the trace context, not the claim about who arrived, and not a mark on the request.&lt;/p&gt;

&lt;p&gt;A mark on the request is exactly the mechanism the rollout technique below stands on. So the question "will the mark reach the last hop" was never open on my side. It already had an answer, and the answer was no.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this sits
&lt;/h2&gt;

&lt;p&gt;A short primer, so this reads on its own.&lt;/p&gt;

&lt;p&gt;A PHP monolith is being taken apart into Go services. The &lt;strong&gt;Go API gateway&lt;/strong&gt; is the only door into the new services from outside; behind it the extracted services talk to each other over gRPC, and every one of them is raised from the same shared platform library. Two things are already settled at that door: the edge parses the input once and passes on a claim about &lt;strong&gt;who arrived&lt;/strong&gt;, carried in the call metadata; and, as the previous part argued, a &lt;strong&gt;flag&lt;/strong&gt; is decided there too - the service reads a finished yes or no rather than the address of a flag store.&lt;/p&gt;

&lt;p&gt;Some work answers inside the call. The rest is published as a domain event and picked up later by a background handler in a separate process. That second half is where every mechanism in this article either survives or quietly stops.&lt;/p&gt;

&lt;h2&gt;
  
  
  The technique, in one paragraph
&lt;/h2&gt;

&lt;p&gt;The new version is raised &lt;strong&gt;beside&lt;/strong&gt; the old one. The standard path stays exactly as it was for everybody. Only a request carrying a mark is routed into the new version.&lt;/p&gt;

&lt;p&gt;The mark is not a new invention. It is the same kind of statement as the identity claim and the flag: a decision taken before the service runs, travelling in the call metadata, with the lifetime of one request. Nothing new appears in the service; a field that already exists gets one more neighbour.&lt;/p&gt;

&lt;p&gt;That paragraph is the entire appeal of the technique, and it is also why it is easy to underestimate. Standing a second copy of a binary next to the first one is a deployment parameter. Getting a mark to the end of a chain is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The condition that makes or breaks it
&lt;/h2&gt;

&lt;p&gt;Written as a condition, because that is the only form of it that can be checked:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The technique is ready when the mark arrives at the last hop - not when the second version is up.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Stated that way it becomes something a test can hold: send one marked request, walk the whole chain, and assert that the last participant saw the mark. Stated as an intention - "we will pass the flag through" - it becomes a rule, and a rule that produces no symptom when broken is a rule that is already broken somewhere you have not looked yet.&lt;/p&gt;

&lt;p&gt;If the mark stops after the first hop, the first service runs the new code, and every call it makes afterwards - synchronous or not - goes to the old one. That is not "the rollout is partially on". It is one user action executed by two generations of code, with nobody in a position to notice, because each half looks correct from where it stands.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  edge          hop 1            hop 2         hop 3         hop 4
  decides  --&amp;gt;  new version  |   old        --&amp;gt;  old      --&amp;gt;  old
  mark: yes     mark: yes    |   mark: -        mark: -       mark: -
                             |
                       the mark stops here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmiqahv0z2frguhafazra.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmiqahv0z2frguhafazra.png" alt="Four hops in a row - the first one routed into the new version and carrying a yes mark, the three after it routed into the old version with no mark, and a broken connector with a visible gap between the first and second hop labelled the mark did not travel" width="800" height="358"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The two places the thread breaks
&lt;/h2&gt;

&lt;p&gt;Both of them are already documented on my side, which is the reason I trust the condition above rather than my optimism.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A dropped call context.&lt;/strong&gt; The context is passed as the first parameter everywhere - handlers, repositories, managers, background handlers, the publish call itself - and it is checked by cancellation tests. Breaking that rule produces no symptom: nothing fails to build, nothing throws, no response code changes. It shows up as a thread that quietly stops being connected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A publish with empty headers.&lt;/strong&gt; The finding above. Everything the messaging layer needs in order to know what it is holding travels in the same place as everything the rollout needs in order to know which generation this request belongs to. When those headers are empty, both are broken at once - and fixing either one fixes the other.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F63w8vl0es1hzqg66pzwq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F63w8vl0es1hzqg66pzwq.png" alt="Two panels - on the left a dropped call context with a note that it produces no symptom, on the right a publish with empty headers with a note that the typed receive becomes inapplicable, and below them one wide bar reading the mark travels in exactly the places that were already broken" width="800" height="466"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The useful way to read that pair: the mark is not asking for a new transport. It is asking for the transport that was supposed to be working already.&lt;/p&gt;

&lt;h2&gt;
  
  
  The message that repeated forever
&lt;/h2&gt;

&lt;p&gt;Here is the concrete episode, from the same audit, and it is the reason I treat contract compatibility and consumer behaviour as one requirement rather than two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What we had.&lt;/strong&gt; A subscription that set &lt;strong&gt;no redelivery limit, no backoff and no dead-letter queue&lt;/strong&gt;. A decode error was returned as an ordinary error. The driver did what a driver does with an ordinary error: it redelivered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What that cost.&lt;/strong&gt; One message the consumer could not parse occupied the stream and was discarded by nothing. Not slowed down - repeated, indefinitely, with nothing in the arrangement able to give up on it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Friyqxgkb83uwbdjnhdyo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Friyqxgkb83uwbdjnhdyo.png" alt="One message that fails to decode looping back into the same consumer through three missing guards - no redelivery limit, no backoff, no dead-letter queue - with the loop drawn in the accent colour and a note that the decode error was returned as an ordinary error" width="799" height="489"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it belongs in an article about rollout.&lt;/strong&gt; Two generations of code on one stream is precisely the situation where a message the consumer cannot parse arrives as a matter of routine rather than as an accident. The new version writes an event the old consumer does not understand, or the reverse, and the poison loop above is no longer a latent defect - it is the expected outcome of the technique working as designed.&lt;/p&gt;

&lt;p&gt;So the requirement is one requirement, not two: &lt;strong&gt;the contract is compatible in both directions, and the consumer has a defined behaviour for a message it cannot parse.&lt;/strong&gt; Satisfying only the first is how you find out about the second.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this is usually done
&lt;/h2&gt;

&lt;p&gt;Four shapes show up in public write-ups and standards, and all four are reasonable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Routing on a header at the entry layer.&lt;/strong&gt; The decision is taken once at the door, and the entry layer sends the request to one of two upstreams. This is the shape the technique below is a version of.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two colours side by side.&lt;/strong&gt; A whole second environment is raised, traffic is switched to it at once, and the old one is kept ready to switch back - see &lt;a href="https://martinfowler.com/bliki/BlueGreenDeployment.html" rel="noopener noreferrer"&gt;BlueGreenDeployment&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A gradually increasing share of traffic&lt;/strong&gt;, with the share moved up while the failure rate is watched - see &lt;a href="https://martinfowler.com/bliki/CanaryRelease.html" rel="noopener noreferrer"&gt;CanaryRelease&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A separate sandbox on a copy of the environment&lt;/strong&gt;, where the new version is exercised with no live traffic at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The four side by side, in the only terms that decide between them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shape&lt;/th&gt;
&lt;th&gt;What it needs from the chain&lt;/th&gt;
&lt;th&gt;Blast radius of one mistake&lt;/th&gt;
&lt;th&gt;What it costs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;routing on a header&lt;/td&gt;
&lt;td&gt;the mark reaching every hop&lt;/td&gt;
&lt;td&gt;one marked caller&lt;/td&gt;
&lt;td&gt;a mark that must survive the whole chain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;two colours&lt;/td&gt;
&lt;td&gt;nothing - the whole environment moves&lt;/td&gt;
&lt;td&gt;everything, briefly&lt;/td&gt;
&lt;td&gt;a second environment, kept warm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a growing share&lt;/td&gt;
&lt;td&gt;nothing at the chain level&lt;/td&gt;
&lt;td&gt;the current share&lt;/td&gt;
&lt;td&gt;a way to watch failure rates and move the dial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a separate sandbox&lt;/td&gt;
&lt;td&gt;nothing - no live traffic at all&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;a copy of the environment, and no real load in it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I am not arguing any of them is wrong, and I am deliberately naming no gateways, no service meshes and no entry providers - the choice of product is not what makes this work or fail. The row that matters for this article is the first one, and its middle column is the reason to want it: the blast radius is one caller who agreed to be that caller. Its right-hand column is the reason it is hard.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it lands on the deploy I already have
&lt;/h2&gt;

&lt;p&gt;Worth separating what is already there from what is not, because they land very differently.&lt;/p&gt;

&lt;p&gt;What is already there. Deployment is a &lt;strong&gt;manual pipeline run with two parameters&lt;/strong&gt;: the version, and which services to move (&lt;code&gt;server&lt;/code&gt;, &lt;code&gt;worker&lt;/code&gt;, &lt;code&gt;all&lt;/code&gt;). Tags are set by one person, pseudo-versions are forbidden, and a service carries exactly the tag it was given. The version and the commit are stamped into the binary with linker flags, and there is a build target that checks the symbol is alive - because the linker will &lt;strong&gt;silently ignore&lt;/strong&gt; a flag aimed at a symbol that does not exist, leaving the version at &lt;code&gt;dev&lt;/code&gt; and sending &lt;code&gt;dev&lt;/code&gt; into the audit trail and into the trace attribute.&lt;/p&gt;

&lt;p&gt;So "stand the new version beside the old one" costs nothing new: it is the same pipeline, run twice, with two version arguments. There is no new code, no new component and no new runtime.&lt;/p&gt;

&lt;p&gt;"Get the mark to the last hop" lands on nothing at all. It is the part that has to be built, and the two breaks above are its actual scope.&lt;/p&gt;

&lt;p&gt;One honest boundary while I am on the subject of the pipeline: there is &lt;strong&gt;no "wait for health" step&lt;/strong&gt; in it. Health and readiness are served by the platform, and the check after a rollout is the environment's job, not the pipeline's.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;The four items below are the ones I would put in front of anybody who wants to try this, and none of them is a token entry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two generations of code live at the same time.&lt;/strong&gt; Not two branches in a repository - two running versions, both correct, both reachable, both maintained. Every fix in the period applies to a question nobody enjoys answering: which of the two, or both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The contract has to be compatible in both directions.&lt;/strong&gt; Forward compatibility is the one people plan for: the old side must tolerate what the new side sends. Backward compatibility is the one that gets discovered: the new side must tolerate what the old side is still sending, for as long as the old side exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data written by the new version is read by the old one.&lt;/strong&gt; On my side that is survivable by construction, because there is no destructive &lt;code&gt;UPDATE&lt;/code&gt; in the domain tables - a change is a new version of a row, and the old generation keeps seeing its own rows. That holds right up until the new version starts writing something the old version cannot read at all, at which point the storage layer, not the contract, is what ends the experiment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Whoever is testing has to know they are in the new branch.&lt;/strong&gt; Otherwise debugging is done blind: a behaviour is observed, attributed to the wrong generation, and the conclusion is confidently wrong. This is the item that looks smallest on the list and produces the most wasted hours.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  old version  &amp;lt;-- contract compatible both ways --&amp;gt;  new version
       |                                                   |
       +-------------------&amp;gt; data &amp;lt;-----------------------+
                  written by new, read by old
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fktu8iw317lghhj229gac.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fktu8iw317lghhj229gac.png" alt="Old and new versions drawn side by side with a two-way arrow between them reading contract compatible both ways, and one shared data band below with an arrow up from new and an arrow down to old labelled written by new, read by old" width="800" height="424"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Put together, the shape of the price is not what it looks like from the deployment side:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpml1sis9wkqxc55h2ud4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpml1sis9wkqxc55h2ud4.png" alt="Four cost rows stacked in monospace plates - two generations alive, contract compatible both ways, data written by new read by old, the tester must know which branch they are in - and a fifth row separated by a rule and set in serif reading organisational, not technical" width="799" height="522"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;This is organisational complexity, not technical complexity.&lt;/strong&gt; The technical part is a routing rule and a header. The expensive part is two generations of a system being true at the same time, and the people around it having to hold both in their heads.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why do it at all, then
&lt;/h2&gt;

&lt;p&gt;Two reasons. I do not have a third.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The alternative prices every mistake at the whole traffic.&lt;/strong&gt; Rolling out to everybody at once is not a cheaper technique; it is the same technique with the blast radius set to maximum. The marked request exists so that the first thing a mistake meets is one caller who agreed to meet it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The mechanism already exists.&lt;/strong&gt; The mark is the same decision, taken at the same place, travelling in the same call metadata as the identity claim and the flag. No flag store appears, no client appears in each service, no cache and no refresh loop appear - which matters, because a piece of state with a refresh loop is runtime, and runtime is what the platform provides. The technique earns its keep partly by &lt;strong&gt;not&lt;/strong&gt; adding anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to do this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;When the chain is longer than it can carry the mark.&lt;/strong&gt; Then the honest order is: stitch the chain first, argue about routing second. Routing a mark into a chain that drops it produces a half-migrated request, which is worse than no rollout mechanism at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When the change touches storage in a way that makes reading back impossible.&lt;/strong&gt; Compatibility in both directions is not achievable there, and the technique simply does not apply - no amount of routing discipline fixes a row the other generation cannot read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When there are few enough consumers to move all of them at once.&lt;/strong&gt; Then the whole apparatus is ceremony, and the cheaper answer is a coordinated switch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the boundary of this write-up, stated plainly because it matters: &lt;strong&gt;there is no routing of a marked request into a new version in production on my side.&lt;/strong&gt; This is a breakdown of a technique and its price, not a report on a working scheme. There are also no numbers here - not the share of marked traffic, not how long two generations coexist, not the number of hops in the chain, not how long a rollout takes - because none of those have been measured on my side. A hole you can see is worth more than a plausible figure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The multiplier line
&lt;/h2&gt;

&lt;p&gt;An automated executor makes the mechanical half of this cheap: threading one more field through the call metadata, setting headers at every publish site, writing the test that fails when the mark does not reach the background handler. What it does not do is decide that the readiness criterion is the last hop rather than the second binary, or notice that a poison loop and a contract change are the same requirement seen from two sides. That judgement is the work. Speed amplifies whoever set the definitions; it does not supply them.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Access, tracing, rollout - Part 5.&lt;/strong&gt; Next, and last in this block: checking a release before everybody starts using it - the coverage ratchet sitting at zero, one infrastructure container for the whole run, and what drifts between what is declared and what is actually there.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you do this better&lt;/strong&gt;, tell me how you prove a request mark reaches the last hop rather than the first. If &lt;strong&gt;you have been through this&lt;/strong&gt;, what did your two coexisting generations turn out to disagree about? If &lt;strong&gt;you see it differently&lt;/strong&gt;, say where rolling out to everyone at once is the cheaper answer. How is it solved on your side, and what broke there?&lt;/p&gt;

</description>
      <category>devops</category>
      <category>architecture</category>
      <category>microservices</category>
      <category>deployment</category>
    </item>
    <item>
      <title>A Flag Is A Decision Made Before The Service</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Thu, 08 Oct 2026 20:40:50 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/a-flag-is-a-decision-made-before-the-service-5e3j</link>
      <guid>https://dev.to/anton_brilliantov/a-flag-is-a-decision-made-before-the-service-5e3j</guid>
      <description>&lt;p&gt;&lt;em&gt;The service receives a finished yes or no the same way it receives who arrived - not the address of a flag store.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This block is about the things a fleet has to do identically or every service reinvents them; this part is the shortest of the four, and it is one thought with one price attached. Running notes live on my GitHub: &lt;strong&gt;&lt;a href="https://github.com/brilliant-almazov" rel="noopener noreferrer"&gt;github.com/brilliant-almazov&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is how I do it right now, with the price attached - maybe you already do it better, maybe you see it differently.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where this sits
&lt;/h2&gt;

&lt;p&gt;A short primer, so this reads on its own.&lt;/p&gt;

&lt;p&gt;A PHP monolith is being taken apart into Go services. The &lt;strong&gt;Go API gateway&lt;/strong&gt; is the only door into the new services from outside; behind it the extracted services talk to each other over gRPC, and every one of them is raised from the same shared platform library. Identity is already settled at that door: the edge parses the input once and passes on a claim about who arrived, carried in the call metadata next to the call itself. By the time a service runs, &lt;em&gt;who is calling&lt;/em&gt; is a finished statement, not a question.&lt;/p&gt;

&lt;p&gt;This part asks whether a second statement can travel the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thesis, in one line
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A flag is not a variable inside a service. It is a decision made before the service runs.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The edge decides whether a branch is on for this caller, and the result travels the same way the identity claim does. The service reads a finished yes or no. It never learns where the answer came from, and it is never handed the address of a flag store.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;a flag client in every service        the decision at the edge
------------------------------        ------------------------

  [service A] --+                       +-- [service A]  yes
   config       |                       |
   cache        +--&amp;gt; [ flag store ]     |
   refresh      |                     [edge] -- [service B]  no
  [service B] --+     each service     decides  |
   config       |     keeps its own    once     |
   cache        |     copy of the               +-- [service C]  yes
   refresh      |     machinery
  [service C] --+                       services hold no config,
                                        no cache, no refresh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqqz39f9sufldh24wy02h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqqz39f9sufldh24wy02h.png" alt="Left, a flag client in every service: three services, each carrying its own config, cache and refresh, all pointing at one shared flag store. Right, the decision at the edge: one edge box deciding once and three services each holding a finished yes or no, with no arrows leaving them" width="800" height="555"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The case that made me write this down
&lt;/h2&gt;

&lt;p&gt;Not a production incident, and I will not dress it as one. It is an audit finding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What we had.&lt;/strong&gt; An audit of one of my own services asked a single question: where in this service is code that the platform library already provides? Among the findings, two matter here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three caches written by hand&lt;/strong&gt; - a map plus a read-write mutex, &lt;strong&gt;no TTL, no capacity limit, no metrics&lt;/strong&gt;. And a &lt;strong&gt;retention schedule nailed down as a constant&lt;/strong&gt; in the code instead of arriving as configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What that cost.&lt;/strong&gt; The platform library already ships a cache with a TTL, a capacity bound and metrics, and configuration arrives as an import rather than as code somebody writes. So each of the three hand-written copies was a piece of runtime that nobody could observe: no eviction anyone could reason about, no size anyone had bounded, no number on a dashboard when it misbehaved. Nothing was on fire. That is exactly the problem - a piece of runtime with no metric under it does not announce itself, it just sits there being load-bearing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frbt4246f838k7art1eof.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frbt4246f838k7art1eof.png" alt="Left, written by hand and found in the audit: three caches built from a map and a mutex with no TTL, no capacity and no metrics, and a retention schedule as a constant. Right, what the shared platform library already provides: a cache with TTL, capacity and metrics, and schedules arriving as configuration" width="800" height="461"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it belongs in an article about flags.&lt;/strong&gt; A flag client living inside a service reproduces exactly these shapes - a piece of configuration, a cache, a refresh loop - and adds one more on top. It is the same mistake with a nicer name.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this is usually done
&lt;/h2&gt;

&lt;p&gt;Three shapes show up in public write-ups, and all three are defensible depending on what you are building:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An in-process client of a flag store in every service&lt;/strong&gt;, with a local cache and a background refresh. This is the default in most write-ups on the subject, and its appeal is real: the decision sits next to the code it affects, so the branch reads naturally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flags as configuration that ships with the deploy.&lt;/strong&gt; No runtime lookup at all - a value arrives with the release. Simple and observable; the cost is that changing it means a deploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The decision made on the entry layer&lt;/strong&gt;, with the result attached to the request as it travels inward. Less common, and the one this article is about.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The three shapes, side by side, in the only terms that decide between them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Shape&lt;/th&gt;
&lt;th&gt;What the service holds&lt;/th&gt;
&lt;th&gt;Who changes the value&lt;/th&gt;
&lt;th&gt;What a change costs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;client in every service&lt;/td&gt;
&lt;td&gt;configuration, cache, refresh&lt;/td&gt;
&lt;td&gt;a human, in an admin page&lt;/td&gt;
&lt;td&gt;nothing at request time, a runtime layer forever&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;configuration with the deploy&lt;/td&gt;
&lt;td&gt;one value, read at start&lt;/td&gt;
&lt;td&gt;whoever cuts the release&lt;/td&gt;
&lt;td&gt;a deploy per change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;decided at the edge&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;a human, at one participant&lt;/td&gt;
&lt;td&gt;one more field in the call metadata&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Martin Fowler's &lt;a href="https://martinfowler.com/articles/feature-toggles.html" rel="noopener noreferrer"&gt;Feature Toggles&lt;/a&gt; is still the clearest public breakdown of the categories and their lifetimes, and the vendor-neutral &lt;a href="https://openfeature.dev/specification/" rel="noopener noreferrer"&gt;OpenFeature specification&lt;/a&gt; is worth reading for what a flag evaluation is actually made of - a key, an evaluation context, a default. Both describe the first shape, because that is what most systems need. I am not arguing they are wrong. I am saying the first shape has a price that a fleet of small services pays repeatedly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a flag client in a service drags in
&lt;/h2&gt;

&lt;p&gt;Four things, and each one is a separate piece of runtime:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Its own configuration&lt;/strong&gt; - where the store is, how to authenticate to it, what the defaults are.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Its own cache&lt;/strong&gt; - because you are not making a network call per branch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Its own refresh&lt;/strong&gt; - because a cache that never updates is a constant with extra steps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Its own admin page&lt;/strong&gt; - somewhere a human changes the value and sees what it currently is.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fggjthhni8ox7qa8q4sxw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fggjthhni8ox7qa8q4sxw.png" alt="Four plates in a row - configuration, cache, refresh, admin page - above one wide bar reading: all four of these are runtime, with a note that the audit found three hand-written caches in a single service" width="800" height="412"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There is a standing rule on my side that decides this before taste gets a vote: &lt;strong&gt;the platform owns the runtime, and writing your own version of a platform layer is forbidden.&lt;/strong&gt; The service writes its input, its processing and its output. Everything else - the cache, the configuration, the scheduling, the metrics registry - is an import.&lt;/p&gt;

&lt;p&gt;So "a flag client in every service" is not a preference I dislike. It is a request to hand-write a runtime layer in N services, in a fleet where that is already prohibited, and where an audit has already found the exact same shapes written by hand three times over in a single service.&lt;/p&gt;

&lt;p&gt;One line, and it is the only thing here I would defend without qualification:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A "small" feature that has state and a refresh is runtime.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How I do it - one example
&lt;/h2&gt;

&lt;p&gt;One behaviour branch, enabled for some callers and not others. Neutral domains: an &lt;code&gt;entity&lt;/code&gt; belongs to an &lt;code&gt;account&lt;/code&gt;, and an &lt;code&gt;order&lt;/code&gt; is written against it.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The &lt;strong&gt;edge&lt;/strong&gt; decides, once per request, whether the new branch is on for this caller. It is the only participant that has the caller resolved and the only one that has to be told anything.&lt;/li&gt;
&lt;li&gt;The result travels in the &lt;strong&gt;call metadata&lt;/strong&gt;, next to the claim about who arrived - the same channel, the same lifetime, the same rules.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;handler&lt;/strong&gt; reads a finished yes or no. No lookup, no client, no cache, no default value to get wrong.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And the part that is easy to forget until it bites: &lt;strong&gt;the mark has to survive the queue boundary the same way the call context does.&lt;/strong&gt; If the synchronous half of the work runs the new branch and the published message goes out without the mark, the background handler picks up the old branch - and now one user action ran two different versions of the same logic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  edge          handler         publish      background      next call
  decides  --&amp;gt;  new branch --&amp;gt;   ...     |   old branch  --&amp;gt;  old branch
  mark: yes     mark: yes                |   mark: -         mark: -
                                         |
                                   the mark stops here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcto5fcybowennngj4o3v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcto5fcybowennngj4o3v.png" alt="Four steps in a row - edge decides, handler, background handler, next call - the first two carrying a yes mark and running the new branch, the last two carrying no mark and running the old branch, with a dashed break in the gap where the queue boundary drops it" width="799" height="395"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That failure is worse than the branch being off everywhere, because it is not visible from either side. The synchronous path looks correct. The background path looks correct. Only the pair is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually travels
&lt;/h2&gt;

&lt;p&gt;Worth being precise, because "the edge decides" is easy to over-read.&lt;/p&gt;

&lt;p&gt;What travels is a &lt;strong&gt;mark on this request&lt;/strong&gt;: this call, for this caller, runs the new branch. It is a fact about one request, with the lifetime of one request, sitting in the same place as the claim about who arrived.&lt;/p&gt;

&lt;p&gt;What deliberately does not travel is the &lt;strong&gt;rule&lt;/strong&gt; that produced it - the percentage, the account list, the date after which everyone gets it. None of that is a service's business, and none of it belongs in a message that crosses four processes. A service that received the rule instead of the answer would be right back to evaluating flags, only with a worse input.&lt;/p&gt;

&lt;p&gt;That distinction is also what keeps the compatibility story sane: a mark is a boolean with a name, and a name plus a boolean is a thing you can add, deprecate and remove. A rule is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the edge and not the service
&lt;/h2&gt;

&lt;p&gt;Two reasons, and I do not have a third.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The decision needs the caller, and the caller is resolved at the edge.&lt;/strong&gt; The service receives an input that has already been parsed; whether it also receives the resulting yes or no is a matter of putting one more field next to one that is already there. Deciding in the service means re-deriving something that was already known one hop earlier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing new appears in the service.&lt;/strong&gt; No flag configuration, no cache, no background refresh - which is to say, no runtime that then has to be maintained separately in every service that has a flag. The count matters here: one decision point does not scale with the number of services; a client in each of them does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this costs
&lt;/h2&gt;

&lt;p&gt;An honest list, not one token item:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Product logic moves to the edge.&lt;/strong&gt; "Is this branch on for this account" is a product decision now living in the door, which is not where anyone looks for it. That is a real navigational cost for whoever debugs it next.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The set of marks in the call metadata grows&lt;/strong&gt;, and it becomes part of the compatibility surface between the edge and everything behind it. Removing one is a fleet-wide change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local development needs a way to inject a mark by hand.&lt;/strong&gt; Otherwise a service is only runnable with the edge in front of it, and the developer experience quietly degrades into "you can only test this on a stand".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A mark that does not survive the whole chain gives you a half-enabled branch&lt;/strong&gt; - the failure described above, and the most unpleasant of the lot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What I have just described is a direction, not a running system.&lt;/strong&gt; There is no flag store on my side and no page to manage flags from a browser. The cost of "its own admin page" is therefore named here, not measured - I am counting a cost I have not paid yet, and it belongs in the list marked as such.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5vkmzavinknemhehzd23.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5vkmzavinknemhehzd23.png" alt="Three cards - a flag store, a page to manage flags, a marked request routed in production - each carrying a badge reading does not exist, above a line saying this part describes a direction and its price rather than a working scheme" width="799" height="376"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to do this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;When the switch is purely technical and lives inside one service.&lt;/strong&gt; A timeout, a batch size, a worker count - that is configuration, not a flag, and it arrives as an environment variable derived from the resource declaration. Routing it through the edge would be ceremony.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When the decision depends on data the edge does not have.&lt;/strong&gt; This is the same dividing line as with permissions: a question that needs rows is answered where the rows are, which is the service. The edge decides what it can decide from the caller alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When there is exactly one service.&lt;/strong&gt; Then there is no fleet to protect from N copies of a runtime layer, and the whole argument is premature.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the honest boundary of this write-up, stated once more because it matters: &lt;strong&gt;there is no flag store here, no admin page for one, and no marked request routed into a new version in production.&lt;/strong&gt; There are also no numbers - not how many flags, not the TTL of a flag cache, not a refresh delay, not a share of enabled callers - because none of those have been measured on my side. A hole you can see is worth more than a plausible figure.&lt;/p&gt;

&lt;h2&gt;
  
  
  One line
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A flag is an answer, not a source. The service reads the answer; it does not go and fetch one.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The multiplier line
&lt;/h2&gt;

&lt;p&gt;The mechanical half of this is cheap now: threading one more field through the call metadata, adding a parameter to a handler signature, writing the test that fails when the mark does not reach the background handler. What did not get faster is the judgement that a flag is a decision rather than a lookup, and that a piece of state with a refresh loop is runtime no matter how small it looks in the diff. Get that wrong and you get a perfectly written flag client, in every service, passing review because it looks like every other flag client. Speed amplifies whoever set the definitions; it does not supply them.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Access, tracing, rollout - Part 4.&lt;/strong&gt; Next: what follows from this - a marked request routed into a new version running beside the old one, two generations of code alive at once, and the price that is organisational rather than technical.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you do this better&lt;/strong&gt;, tell me where your flag decision is evaluated and how the answer reaches a background handler. If &lt;strong&gt;you have been through this&lt;/strong&gt;, what did your half-enabled branch turn out to break? If &lt;strong&gt;you see it differently&lt;/strong&gt;, say where a client in every service is worth the runtime it brings. How is it solved on your side, and what broke there?&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>devops</category>
      <category>microservices</category>
    </item>
    <item>
      <title>The Trace Ends Where The Message Starts</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Wed, 07 Oct 2026 21:19:04 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/the-trace-ends-where-the-message-starts-1hb5</link>
      <guid>https://dev.to/anton_brilliantov/the-trace-ends-where-the-message-starts-1hb5</guid>
      <description>&lt;p&gt;&lt;em&gt;The synchronous path is one tree; everything after the publish is another one - and the audit found out why: the headers were empty.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This block is about the three things a fleet of services has to do identically or each service reinvents them: who arrived, what they may do, where their request has been. This part is the third one - where the request has been, and the exact point where the answer stops being available. Running notes live on my GitHub: &lt;strong&gt;&lt;a href="https://github.com/brilliant-almazov" rel="noopener noreferrer"&gt;github.com/brilliant-almazov&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is how I do it right now, with the price attached - maybe you already do it better, maybe you see it differently.&lt;/p&gt;




&lt;h2&gt;
  
  
  The shape of the system, briefly
&lt;/h2&gt;

&lt;p&gt;A PHP monolith is being taken apart into Go services. The &lt;strong&gt;Go API gateway&lt;/strong&gt; is the only door into the new services from outside; behind it the extracted services talk to each other over gRPC, and each of them is raised from the same shared platform library. Some work is synchronous and answers inside the call. The rest is published as a domain event and picked up later by a background handler in a separate process.&lt;/p&gt;

&lt;p&gt;That last sentence is the whole subject of this article. Everything before the publish is one call. Everything after it is a different process, at a different time, with a different life expectancy - and by default, a different trace.&lt;/p&gt;

&lt;h2&gt;
  
  
  What arrives without a line of service code
&lt;/h2&gt;

&lt;p&gt;Before any argument about instrumentation, the inventory. A snapshot taken from a freshly generated service - name, type, help text, labels, histogram buckets, where each one is declared - reads &lt;strong&gt;67 records: 59 of them from the platform, 6 from the service, plus one &lt;code&gt;&amp;lt;dynamic&amp;gt;&lt;/code&gt; entry&lt;/strong&gt; for the dynamic-metric factory. On top of that the service declares &lt;strong&gt;13 domain metrics&lt;/strong&gt; of its own: queue depth, age of the oldest queued row, relay cycle duration, publish failures by reason, resolve duration, audit records written.&lt;/p&gt;

&lt;p&gt;Collection is &lt;strong&gt;pull&lt;/strong&gt;. An agent scrapes the system port with a bearer token and remote-writes into a store; the application never pushes anything. Traces leave over OTLP.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhober85dv83tfz5ibfww.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhober85dv83tfz5ibfww.png" alt="One hero number, 67 records in the metrics snapshot, with the split beside it - 59 from the platform, 6 from the service, plus one dynamic entry - a card for the 13 domain metrics the service declares, and a strip describing the pull collection model" width="800" height="508"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The line I would keep from that paragraph: &lt;strong&gt;observability here is an import, not a body of code inside the service.&lt;/strong&gt; Health, readiness, the metrics endpoint, gRPC metrics, broker driver metrics, connection pool instrumentation, trace export - all of it comes with the runtime the service is built on. Nobody writes it per service, so nobody writes it slightly differently per service.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one thing the service still has to do
&lt;/h2&gt;

&lt;p&gt;Exactly one requirement is left on the service side, and it is small enough to state in a sentence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Do not lose the call context.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It is passed as the first parameter everywhere - handlers, repositories, managers, background handlers, and the publish call itself - and it is checked by cancellation tests. In tests the context comes from the test runtime; an empty one is forbidden.&lt;/p&gt;

&lt;p&gt;That last clause is the part that makes the requirement real, and it took a while to arrive at. A rule about context has a nasty property: &lt;strong&gt;breaking it produces no symptom.&lt;/strong&gt; A dropped context does not fail a build, does not throw, does not change a response code. It shows up as a trace that quietly stops being connected, which nobody notices until they need it.&lt;/p&gt;

&lt;p&gt;So the requirement is not written as a rule. It is written as a failure: a handler that drops the context fails its cancellation test, because the operation no longer ends when the caller goes away.&lt;/p&gt;

&lt;h2&gt;
  
  
  The case: a requirement that was only a rule
&lt;/h2&gt;

&lt;p&gt;Here is what it looked like while it was still an agreement, and the numbers are the point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What we had.&lt;/strong&gt; "Pass the context everywhere" as a written rule, honestly believed by everyone. Meanwhile the test code had accumulated &lt;strong&gt;421 calls building an empty context, across 184 test files.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What that cost.&lt;/strong&gt; A test built on an empty context does not cancel with itself, so it hangs until the package timeout instead of failing. Worse, it asserts nothing at all about the context: if production code dropped the incoming context and substituted its own, that entire suite would stay green. A test suite of that size, running on every push, was structurally incapable of noticing the one failure mode the rule existed to prevent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What changed.&lt;/strong&gt; Four things, and only the last one is enforcement:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the context comes from the test runtime, &lt;code&gt;t.Context()&lt;/code&gt; and &lt;code&gt;b.Context()&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;a port stub &lt;strong&gt;keeps&lt;/strong&gt; the context it was handed, as a field, and gives it back to the test - instead of accepting it as an ignored parameter;&lt;/li&gt;
&lt;li&gt;every transport, repository, manager and consumer has a cancellation test: cancelled context in, an error that is true under &lt;code&gt;errors.Is(err, context.Canceled)&lt;/code&gt; out, and no row written;&lt;/li&gt;
&lt;li&gt;writing a test with an empty context is blocked at the tool level, so the count cannot climb back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi8qq2m2h6cp498975pdo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi8qq2m2h6cp498975pdo.png" alt="Two hero numbers, 421 empty-context calls across 184 test files, above a card listing the four things that replaced the rule, closing with the line that a test built on an empty context does not cancel with itself and asserts nothing about the context" width="800" height="631"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The ladder underneath this is the same one from the first part of this block: a &lt;strong&gt;reminder&lt;/strong&gt; lives one session, a &lt;strong&gt;rule&lt;/strong&gt; works while it is being read, a &lt;strong&gt;check&lt;/strong&gt; works. 421 is what the middle rung looks like after a year.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this is usually done
&lt;/h2&gt;

&lt;p&gt;Four shapes show up in public write-ups and standards, and all four are reasonable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A correlation identifier passed by hand.&lt;/strong&gt; One header, generated at the door, logged by everyone. Cheap, works everywhere, and gives you grep rather than a tree - you can join records, but you cannot see nesting or duration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Standardised context propagation in the transport.&lt;/strong&gt; &lt;a href="https://www.w3.org/TR/trace-context/" rel="noopener noreferrer"&gt;W3C Trace Context&lt;/a&gt; defines the headers; client and server libraries carry them, and application code never touches them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instrumentation through interceptors&lt;/strong&gt; rather than call sites - one place per protocol wraps every call, so no handler contains tracing code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A separate collection layer next to the application&lt;/strong&gt;, receiving spans and forwarding them, so the application knows one export protocol and nothing about the backend.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We sit on the second and third: propagation is a property of the transport, instrumentation is a property of the platform library, and the service contributes neither. Which leaves the asynchronous path, where the transport is a queue and no library does it for you unless you decide what travels. The &lt;a href="https://opentelemetry.io/docs/specs/semconv/messaging/messaging-spans/" rel="noopener noreferrer"&gt;messaging conventions&lt;/a&gt; say the same thing in more words: the producer puts the context into the message, or there is no link.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we do it: what comes from where
&lt;/h2&gt;

&lt;p&gt;The whole arrangement fits in one table, and the last column is the useful one:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you see in the trace&lt;/th&gt;
&lt;th&gt;Where it comes from&lt;/th&gt;
&lt;th&gt;What the service does&lt;/th&gt;
&lt;th&gt;How it breaks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;service version attribute&lt;/td&gt;
&lt;td&gt;the value the build stamps in&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;a dead &lt;code&gt;-X&lt;/code&gt; target leaves &lt;code&gt;dev&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;spans of the synchronous path&lt;/td&gt;
&lt;td&gt;platform instrumentation&lt;/td&gt;
&lt;td&gt;passes the context first, everywhere&lt;/td&gt;
&lt;td&gt;a dropped context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;durations&lt;/td&gt;
&lt;td&gt;platform and service histograms&lt;/td&gt;
&lt;td&gt;declares its domain metrics&lt;/td&gt;
&lt;td&gt;names drift apart&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the link to background work&lt;/td&gt;
&lt;td&gt;the message headers&lt;/td&gt;
&lt;td&gt;sets them at publish&lt;/td&gt;
&lt;td&gt;empty headers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2cnymmcqxhl8cz5aiute.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2cnymmcqxhl8cz5aiute.png" alt="A four-row table - what you see in the trace, where it comes from, what the service does and how it breaks - with the failure column in the accent colour: a dead minus-X target, a dropped context, drifting names and empty headers" width="799" height="536"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three of those four failures are invisible in a green build. That is the shared property worth naming: &lt;strong&gt;every way this breaks produces correct-looking output.&lt;/strong&gt; The service answers, the data is right, the tests pass, and the trace is quietly wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the thread breaks
&lt;/h2&gt;

&lt;p&gt;Now the specific loss, the one this article exists for.&lt;/p&gt;

&lt;p&gt;The synchronous path traces beautifully. Door, service, validation, transaction, publish - one root, nested children, durations that behave the way nesting says they should. Then the message lands in a queue, a worker in another process picks it up some time later, and that work has &lt;strong&gt;its own root&lt;/strong&gt;. Two trees, no edge between them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;one trace                              a second tree, its own root
---------                              ---------------------------

gateway root                           consumer handler
└─ service                             ├─ decode, called by hand
   ├─ validate                         └─ domain work
   ├─ transaction
   └─ publish   ← the last span            no parent, no link back
                  with a parent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqs70z5tguh6dmgsoi2jr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqs70z5tguh6dmgsoi2jr.png" alt="Two panels drawn as nesting - on the left the synchronous trace, gateway root containing the service containing validate, transaction and publish; on the right a separate consumer tree with its own root; between them a publish plate, a broken dashed connector with a visible gap, and the accent line headers were empty" width="799" height="536"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The thread across that gap is stitched with &lt;strong&gt;message headers&lt;/strong&gt; - the same mechanism the serialisation already uses. And here is the finding from our own audit of the service, which is why this is written as a direction and not as a report:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The publish was going out with empty headers.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The consequence was not primarily a tracing one. With no header to select a codec by, the platform's typed receive was physically inapplicable - the consumer had to call JSON decoding by hand, in the handler, on raw bytes. One defect, two problems: the receiving side could not be typed, and the trace could not be continued.&lt;/p&gt;

&lt;p&gt;That is the part I find genuinely useful to notice. The headers you need for propagation are not an extra tax invented by observability. They are the same headers the messaging layer already needs in order to know what it is holding. If they are empty, both things are broken at once, and fixing either one fixes the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the message carries, and what it deliberately does not
&lt;/h2&gt;

&lt;p&gt;Since the whole argument rests on headers, it is worth being exact about the division:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;body&lt;/strong&gt; is protojson of the domain event: event id, tenant, type, subject, timestamps, revision, acting principal.&lt;/li&gt;
&lt;li&gt;The row identifier in the outgoing queue table is the same value as the message id.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Beyond the message id, the service was setting no headers of its own.&lt;/strong&gt; That is the defect above, stated as an inventory line.&lt;/li&gt;
&lt;li&gt;Delivery status and attempt counters are &lt;strong&gt;not&lt;/strong&gt; in the message at all: queue depth, age of the oldest row and relay cycle duration live in the metrics endpoint and never travel to the broker.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Which settles a design question that otherwise gets argued in circles: &lt;strong&gt;the thread is stitched with headers, not with a field in the event body.&lt;/strong&gt; The body belongs to the event contract - consumers depend on its shape, and it outlives any particular observability arrangement. Metadata about the delivery of a message is not part of what happened in the domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The version attribute that lies quietly
&lt;/h2&gt;

&lt;p&gt;One more failure from that table, kept separate because it is about trust rather than connectivity.&lt;/p&gt;

&lt;p&gt;The build stamps the service version into a symbol the runtime reads, and the runtime puts it into the trace as an attribute. The linker will &lt;strong&gt;silently ignore&lt;/strong&gt; an &lt;code&gt;-X&lt;/code&gt; flag aimed at a symbol that does not exist. No warning, no error, green build - and the version stays &lt;code&gt;dev&lt;/code&gt;. Then &lt;code&gt;dev&lt;/code&gt; travels into the audit trail and into the trace attribute, and every span claims to come from a build nobody can identify.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3t9b70zz1fztv7rdoa8o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3t9b70zz1fztv7rdoa8o.png" alt="A wrong minus-X target aimed at a symbol that does not exist with a green build-ok badge beside it, an arrow down to the trace attribute reading service version equals dev in the accent colour, and a check plate describing building both binaries with a test tag and grepping the binary for it" width="800" height="555"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The check is embarrassingly cheap: a build target that compiles both binaries with a test version tag and greps the binary for that string. &lt;strong&gt;Empty output means the symbol is wrong.&lt;/strong&gt; And the flags have to be right in two files, the &lt;code&gt;Makefile&lt;/code&gt; and the &lt;code&gt;Dockerfile&lt;/code&gt; - a template carrying a dead target in one of them hands the same silent defect to every service generated from it afterwards.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A trace attribute nobody checks lies quietly.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why it is arranged this way
&lt;/h2&gt;

&lt;p&gt;Two reasons, and neither is about elegance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Observability written by hand in a service is runtime written by hand in a service.&lt;/strong&gt; Runtime is what the platform provides - registries, instrumentation, export, pooling. The moment a service writes its own version of it, its trace stops lining up with its neighbour's: different span names, different attribute spellings, different histogram buckets. A fleet where each service is individually well instrumented and collectively incomparable is worse than it sounds, because every cross-service question turns into a translation exercise first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The single requirement left to the service is expressed as a check.&lt;/strong&gt; Not because rules are worthless, but because 421 is what a rule looks like when it is only a rule. A requirement with no executable form is a shared intention, and the failure it permits is silent by construction.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;An honest list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Context as the first parameter is a discipline in every signature.&lt;/strong&gt; It shows up in every review, forever, and it is the kind of thing that feels like ceremony right up until the day it isn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cancellation tests are written per transport and per handler.&lt;/strong&gt; That is real test code, and it grows with the surface, not with the feature list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Message headers become part of compatibility.&lt;/strong&gt; Once a consumer reads a header, changing it is a coordinated change, with the same care as changing a field in the contract.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cardinality is kept low on purpose.&lt;/strong&gt; The tenant identifier does not go into labels - there are too many tenants, and the label would blow up the series count. So a per-tenant cut is simply not available in traces or metrics; it lives in queries against the data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A thread stitched across a queue makes the tree long.&lt;/strong&gt; A trace spanning a synchronous call, a queue and a background handler is more expensive to read than two short ones, and a reader who does not know the shape will get lost in it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When not to do this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;When there is no asynchronous path.&lt;/strong&gt; Nothing to stitch; propagation in the transport already covers the whole story.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When the message leaves the perimeter&lt;/strong&gt; and its headers are controlled by someone else. Then the thread ends at your boundary by agreement, and the honest thing is to say so rather than to hope.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When the volume makes recording everything cost more than it returns.&lt;/strong&gt; That is a conversation about sampling, which is a different decision from whether the thread exists at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the honest caveat about the boundaries of this write-up: there are &lt;strong&gt;no names of specific tracing systems here, and no numbers on trace volume, sampling share, span durations or tree depth&lt;/strong&gt; - the first because naming them adds nothing, the second because those figures were never measured on my side. A hole you can see is worth more than a plausible figure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The multiplier line
&lt;/h2&gt;

&lt;p&gt;An automated executor made the mechanical half of this cheap: threading a parameter through hundreds of signatures, writing a cancellation test per transport, converting a rule into a blocking check. What it did not do is decide that the requirement should exist as a failure rather than a sentence, or notice that empty headers were simultaneously a decoding defect and a broken trace. That judgement is the work. Speed amplifies whoever set the definitions; it does not supply them.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Access, tracing, rollout - Part 3.&lt;/strong&gt; Next: feature flags, and why the decision belongs at the boundary - the service receives a finished yes or no, not the address of a flag store.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you do this better&lt;/strong&gt;, tell me what you put in the message headers and how you prove it is still there. If &lt;strong&gt;you have been through this&lt;/strong&gt;, what did your broken thread turn out to be hiding besides the trace? If &lt;strong&gt;you see it differently&lt;/strong&gt;, say where a correlation identifier and good logs beat a stitched tree. How is it solved on your side, and what broke there?&lt;/p&gt;

</description>
      <category>observability</category>
      <category>go</category>
      <category>microservices</category>
      <category>devops</category>
    </item>
    <item>
      <title>A Decision That Needs Data Belongs In The Service</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Tue, 06 Oct 2026 17:59:41 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/a-decision-that-needs-data-belongs-in-the-service-3nbf</link>
      <guid>https://dev.to/anton_brilliantov/a-decision-that-needs-data-belongs-in-the-service-3nbf</guid>
      <description>&lt;p&gt;&lt;em&gt;Three places can decide what a caller may do - only one of them can answer whether this tenant owns this row.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. The previous part was about who arrived. This one is about the second question - what that caller may do - and the only thing I am confident about is where the answer has to be computed. It is the shortest piece in this block, because it is one rule with one test under it. Running notes are on my GitHub: &lt;strong&gt;&lt;a href="https://github.com/brilliant-almazov" rel="noopener noreferrer"&gt;github.com/brilliant-almazov&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is how I do it right now, with the price attached - maybe you already do it better, maybe you see it differently.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where this sits
&lt;/h2&gt;

&lt;p&gt;A short primer, so this reads on its own.&lt;/p&gt;

&lt;p&gt;A PHP monolith is being taken apart into Go services. The &lt;strong&gt;Go API gateway&lt;/strong&gt; is the only door into the new services from outside; behind it the extracted services talk to each other over gRPC, and each of them owns its own data. Identity is settled at that door: the edge parses the input once and passes on a claim about who arrived, so by the time a service is running, &lt;em&gt;who is calling&lt;/em&gt; is a finished statement rather than a question it has to work out.&lt;/p&gt;

&lt;p&gt;Which leaves the second question, and it does not resolve itself the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thesis, in one line
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;"Who arrived" is answered before the service runs. "What they may do" is not - and it can be answered in three different places, at three different prices.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Picking one of them is usually discussed as a matter of taste, or of which layer feels tidier. It is neither. Two of the three places physically cannot answer some of the questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three places
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;At the edge.&lt;/strong&gt; The general check - &lt;em&gt;may this caller reach this at all&lt;/em&gt;. It is cheap, it happens before any service is involved, and it knows nothing about the data behind the call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In the service.&lt;/strong&gt; The check that needs a row to answer - &lt;em&gt;does this tenant own this row&lt;/em&gt;. This is the only place where such a decision is possible at all, because it is the only place the row exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In a shared resolver.&lt;/strong&gt; A separate service that answers "allowed or not" on request. It removes duplicated rules and gives you one place to look. It also adds a network call on the hot path and a new dependency between the service and something that must be up for it to serve.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1l00of2crv218b9kx84p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1l00of2crv218b9kx84p.png" alt="Three places to decide, drawn on one request path: the edge and the service sit on the path with their prices, and a shared decision service sits off the path, dashed, connected by a dashed line" width="800" height="635"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nothing on that list is wrong. They are priced differently, and the price is paid at different times: the edge charges you at design time, the shared resolver charges you on every request.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the edge can see
&lt;/h3&gt;

&lt;p&gt;Worth being precise, because the edge is the place people overestimate. It holds the claim about who arrived, the method being called and whatever the caller sent. That is enough to answer a large class of questions, and it is genuinely the cheapest place to answer them: nothing has been started yet, no connection has been taken from a pool, no transaction is open.&lt;/p&gt;

&lt;p&gt;What it does not hold is a single row of anything. It can be told which tenant is calling; it cannot be told which tenant owns the row that is about to change, because that fact exists only in the storage of the service that owns it. Handing the edge that fact means handing it the data, and we are back to the copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  One question decides the place
&lt;/h2&gt;

&lt;p&gt;The dividing rule is short enough to fit in a review comment:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Does answering this check need rows from this service?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two answers, and each names the place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No&lt;/strong&gt; - decide it as early as possible on the request path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Yes&lt;/strong&gt; - decide it in the service, next to the row.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwm7mhh1cw08l3igqvygb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwm7mhh1cw08l3igqvygb.png" alt="One question - does the answer need rows from this service - with two branches: no, decide as early as possible on the path; yes, decide in the service, next to the row, the yes branch in the accent colour" width="800" height="461"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The reason I like this phrasing is that it is a checkable condition, not a preference. It has exactly two answers, anyone can arrive at the same one, and it does not require agreeing on what "business logic" means - a discussion I have never seen finish.&lt;/p&gt;

&lt;h2&gt;
  
  
  Applying it in a review
&lt;/h2&gt;

&lt;p&gt;The rule is only worth having if two people apply it to the same check and land in the same place. Four checks, run through the question:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Needs rows from this service?&lt;/th&gt;
&lt;th&gt;Place&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;may this caller reach this method at all&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;as early as possible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;is the payload shaped correctly&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;as early as possible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;does this tenant own this row&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;in the service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;is this row in a state that allows the change&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;in the service&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last one is the interesting entry. It is not usually filed under permission at all - it reads as ordinary validation - and it lands in exactly the same place for exactly the same reason. That is a good sign about the rule: it stops producing a separate category for checks that were never separate.&lt;/p&gt;

&lt;h2&gt;
  
  
  One request, two checks
&lt;/h2&gt;

&lt;p&gt;The concrete case is not an incident, and I am not going to dress it as one. It is the ordinary shape of a write.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What we had.&lt;/strong&gt; A request to change one row needs two different answers before anything is written: &lt;em&gt;this caller is allowed to use this method&lt;/em&gt;, and &lt;em&gt;this tenant owns this row&lt;/em&gt;. Both are permission questions. Both are asked on the same request. They are not the same kind of question at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What that cost when they were treated as one thing.&lt;/strong&gt; The second check cannot be moved outward unless the data moves with it. To decide ownership somewhere else, that somewhere else needs to know which tenant owns which row - which means a copy of the ownership data, kept somewhere it is not authoritative. A copy starts to drift the day it is made, and a permission copy that has drifted fails in the worst available direction: it answers confidently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What follows.&lt;/strong&gt; The first check is settled earlier, without touching data. The second lives where the row lives - and once you say that out loud, it stops looking like security and starts looking like what it is: an invariant of the domain, standing in the same row as every other rule about what may be written. In our services those are expressed as ordinary typed checks - a &lt;code&gt;Rule&lt;/code&gt; over an input, a &lt;code&gt;Guard&lt;/code&gt; from an input to an output - and the ownership check is one of them, not a separate security layer bolted to the side.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiv1w6yp14u3rbnz6jzck.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiv1w6yp14u3rbnz6jzck.png" alt="One request with two checks: may this caller use this method needs no rows and is decided at the edge; does this tenant own this row needs one row and is decided in the service" width="799" height="536"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There are no numbers in this episode, so I am not attaching any. Not how many rules exist, not how many checks the edge runs, not what a hop to a resolver would cost us. &lt;strong&gt;Those go in when they are taken from the repo, not before.&lt;/strong&gt; A visible hole is worth more than a plausible figure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same rule after the queue
&lt;/h2&gt;

&lt;p&gt;Work does not always finish on the request that started it. Part of it is published as a message and picked up later by a background handler, and at that point the neat picture of "the edge decided the general part" needs re-reading.&lt;/p&gt;

&lt;p&gt;The claim about who arrived travels with the message, which is what keeps the later record naming the person rather than the machine. What does not travel is the edge: there is no door in front of a background handler. So the general check is not re-run there, and the data-dependent one still is - by the service that owns the row, exactly as before.&lt;/p&gt;

&lt;p&gt;That asymmetry is worth stating rather than discovering: &lt;strong&gt;the earlier a decision is made, the more places it has to survive to still be true.&lt;/strong&gt; A general check made once at the edge is a decision that has to hold for the whole life of the work it authorised, queue hops included.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this is usually done
&lt;/h2&gt;

&lt;p&gt;Three shapes show up in public write-ups and standards, and all three are defensible:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Policy as a separate decision service.&lt;/strong&gt; The vocabulary for this is standardised - the split between the point that &lt;em&gt;enforces&lt;/em&gt; a decision and the point that &lt;em&gt;decides&lt;/em&gt; it is described in &lt;a href="https://csrc.nist.gov/pubs/sp/800/162/upd2/final" rel="noopener noreferrer"&gt;NIST SP 800-162&lt;/a&gt;, along with the attribute-based model most such systems use. Rules live in one place and are auditable by querying it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A policy library embedded in every service.&lt;/strong&gt; The same rules, evaluated locally, no hop. The cost moves from latency to distribution: every service carries a version of the rules, and "which version is live where" becomes a question you need an answer to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role checks at the entry layer.&lt;/strong&gt; The oldest and cheapest arrangement, and the one that quietly stops being sufficient the moment a check needs a row.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The interesting part is that none of the three removes the constraint this article is about. A decision service still has to be given the data to decide on; an embedded library still has to read the row. The question of &lt;em&gt;where the data is&lt;/em&gt; survives every one of these designs.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we do it - one example
&lt;/h2&gt;

&lt;p&gt;One write, spelled out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;request: update one row of entity

  check 1   may this caller use this method     rows needed: none      decided at the edge
  check 2   does this tenant own this row       rows needed: one       decided in the service

  check 2 stands next to the other invariants of entity, not in a layer of its own
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check 1 never sees the database. Check 2 cannot exist anywhere else: the row that answers it is in this service's storage, and the service is the only participant that can read it without a copy.&lt;/p&gt;

&lt;p&gt;One thing that falls out of writing it this way: the two checks stop competing for the same slot in the code. Check 1 is a property of the route and lives with the route. Check 2 is a property of the write and lives with the write - which also means it runs inside the same transaction as the change it guards, rather than as a separate lookup that was true a moment ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this way
&lt;/h2&gt;

&lt;p&gt;Two reasons, and I would not add a third.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Moving a data-dependent check outward means moving the data outward.&lt;/strong&gt; There is no version of that trade where the data stays put. Either the deciding party reads this service's storage - which makes the storage shared, and the service's ownership of it a fiction - or it keeps a copy, and the copy drifts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A shared resolver is another runtime on the hot path.&lt;/strong&gt; The rule I work by is that the platform supplies the runtime and a service supplies its input, its processing and its output. Introducing a component that every request must consult, that must be available for any of them to answer, and whose failure mode is "everything is denied" or "everything is allowed" - that is a runtime decision, and it is a much bigger commitment than it looks like when it is drawn as one box.&lt;/p&gt;

&lt;p&gt;Neither reason is an argument that a shared resolver is a bad design. Both are arguments that it is a larger decision than the one this article is about, and that the data-dependent check would still be in the service afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;An honest list, not one token item:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The rules are split between the edge and the services.&lt;/strong&gt; There is no single place where "who may do what" can be read off. Answering that question means reading two things, and knowing that you had to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identical general checks in different services have to be kept identical without shared code.&lt;/strong&gt; Anything that is generic enough to share belongs in the platform layer; anything that is not drifts quietly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;With no resolver, auditing the rules is reading code.&lt;/strong&gt; You cannot ask a service "list everything this caller may do" - nothing is holding that list. It is a real capability we do not have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The tenant is not a dimension in the metrics.&lt;/strong&gt; A tenant identifier does not go into metric labels - the cardinality is not affordable - so "who was denied and why" is a question for queries against data, not a panel someone can open.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A denial at the edge and a denial in the service look different to the caller.&lt;/strong&gt; One arrives before any work started, one arrives after part of the request has been read and validated. Making them look the same to a client is extra work that nobody schedules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New services can get this wrong silently.&lt;/strong&gt; The dividing rule is easy to state and easy to skip, and skipping it produces code that works in the happy path and is wrong exactly once.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When not to do this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;When the rules are many and change faster than the code.&lt;/strong&gt; Then a shared resolver pays for itself, and it is not a debate - rules that change on a different clock than deployments want a different home.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When a consumer needs "am I allowed to" without performing the action.&lt;/strong&gt; A decision endpoint is genuinely useful there, and inventing one out of the checks you already run is worse than declaring one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When there are two services.&lt;/strong&gt; The whole discussion is premature; the split costs more than it returns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When the rules are owned by someone who does not write code.&lt;/strong&gt; If the people who decide what is allowed are not the people who deploy, a place they can edit is worth a hop, and no amount of tidy layering substitutes for it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the honest boundary of everything above: &lt;strong&gt;there is no single permission-decision service on my side.&lt;/strong&gt; The general check lives at the edge, the data-dependent check lives in the service, and there is no third participant deciding both. This is an account of three places and their prices, not a report on a resolver that runs in production.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9jxpjx5xl1lx13vmtso9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9jxpjx5xl1lx13vmtso9.png" alt="Three boxes: edge check and in-service data check drawn solid, a shared decision service drawn dashed and marked not here" width="800" height="414"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The one line
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The place of a check is decided by whether it needs data, not by which architectural layer it looks like it belongs to.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The multiplier line
&lt;/h2&gt;

&lt;p&gt;Writing the checks themselves is fast now - a typed rule over an input is a few lines, and generating the twentieth one is not where the time goes. What did not get faster is deciding which of the two questions a given check actually is. Get that wrong and you produce a perfectly written ownership check running in a place that has to be handed a copy of the data to answer - correct code, wrong location, and it will pass review because it looks like every other check. Speed amplifies whoever set the definitions; it does not supply them.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Access, tracing, rollout - Part 2.&lt;/strong&gt; Next: one request identifier that has to survive from the handler all the way into the queue and back - and the two places the thread reliably snaps.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you do this better&lt;/strong&gt;, tell me where you draw the line between the general check and the data-dependent one. If &lt;strong&gt;you have been through this&lt;/strong&gt;, what did your copy of the permission data drift into before anyone noticed? If &lt;strong&gt;you see it differently&lt;/strong&gt;, say where a shared resolver on the hot path was worth its hop. How is it solved on your side, and what broke there?&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>security</category>
      <category>microservices</category>
      <category>backend</category>
    </item>
    <item>
      <title>The Token Stops At The Edge</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Mon, 05 Oct 2026 20:21:41 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/the-token-stops-at-the-edge-2cc8</link>
      <guid>https://dev.to/anton_brilliantov/the-token-stops-at-the-edge-2cc8</guid>
      <description>&lt;p&gt;&lt;em&gt;The service never sees the caller's token - it receives a claim about who arrived, and a red test stand guards that.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This block is about the three things a fleet of services has to do identically or each service reinvents them: who arrived, what they may do, where their request has been. This part is the first and the smallest of the three - what actually travels between services when a person is behind the call. Running notes are on my GitHub: &lt;strong&gt;&lt;a href="https://github.com/brilliant-almazov" rel="noopener noreferrer"&gt;github.com/brilliant-almazov&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is how I do it right now, with the price attached - maybe you already do it better, maybe you see it differently.&lt;/p&gt;




&lt;h2&gt;
  
  
  The shape of the system, briefly
&lt;/h2&gt;

&lt;p&gt;Short primer, so this reads on its own.&lt;/p&gt;

&lt;p&gt;A PHP monolith is being taken apart into Go services. The &lt;strong&gt;Go API gateway&lt;/strong&gt; is the only door into the new services from outside; behind it sit the extracted services, talking to each other over gRPC. The PHP monolith still runs behind its own front door - the two doors are peers and do not route into each other. Every one of the Go services is raised from the same shared platform library, which is what makes a fleet-wide rule like this one enforceable in the first place: there is one place where "how a service reads who is calling" is decided.&lt;/p&gt;

&lt;p&gt;So when I say "the edge" below, I mean the door in front of the new services, and when I say "a service", I mean anything behind it - including the background worker that picks up a message an hour later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that fails
&lt;/h2&gt;

&lt;p&gt;The rule in this article exists in exactly one executable form, and it is a failure:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The test stand &lt;strong&gt;fails&lt;/strong&gt; if a token header arrived in the incoming call metadata.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not a warning. Not a lint note somebody reads on a good day. The stand goes red, and the run stops.&lt;/p&gt;

&lt;p&gt;That is worth stating before the design it protects, because the design is the boring part - most teams end up somewhere near it - and the enforcement is the part that decides whether the design is still true six months later.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is forbidden, and what travels instead
&lt;/h2&gt;

&lt;p&gt;The forbidden thing: &lt;strong&gt;the caller's token is not forwarded into the system.&lt;/strong&gt; It stops at the outer boundary.&lt;/p&gt;

&lt;p&gt;What travels instead: the edge parses the input &lt;strong&gt;once&lt;/strong&gt; and passes on a &lt;strong&gt;claim about who arrived&lt;/strong&gt;, carried in the call metadata alongside the call itself. A service downstream receives a finished claim. It does not verify tokens - it has neither the keys for it nor a reason to.&lt;/p&gt;

&lt;p&gt;Drawn as two paths through the same fleet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;token forwarded                      claim propagated
---------------                      ----------------

  request (token)                      request (token)
        |                                    |
        v                                    v
     [ edge ] -- token --&amp;gt;             [  edge  ]  &amp;lt;-- the only place with keys
        |                                    |
        |                                    |  claim: who arrived
   +----+----+------+                   +----+----+------+
   v         v      v                   v         v      v
[svc A]  [svc B]  [svc C]            [svc A]  [svc B]  [svc C]
 key      key      key                no key   no key   no key
 verifies verifies verifies           reads the claim, and that is all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc39ocozebhkgx1y4dgpv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc39ocozebhkgx1y4dgpv.png" alt="Two bands. Top, token forwarded: the request reaches the edge and then three services, each holding a key and verifying for itself. Bottom, claim propagated: the edge holds the only key, parses the input once, and the three services behind it hold no keys and read a claim about who arrived" width="800" height="626"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Say it as a property rather than a policy and it gets sharper: &lt;strong&gt;a service in this fleet is not capable of validating a caller's token.&lt;/strong&gt; Not "is asked not to". Not capable. There is no key material in it, no library wired for it, no configuration pointing at an issuer. If a token showed up, the service would have nothing to do with it - which is precisely why a token showing up has to be an error and not a shrug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The episode: a rule with nothing under it
&lt;/h2&gt;

&lt;p&gt;Here is the actual sequence, and it is not a production incident - I am not going to dress it as one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What we had.&lt;/strong&gt; The rule "no tokens travel inward" existed as an agreement. Everyone knew it. Everyone would have repeated it correctly if asked in a review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What that cost.&lt;/strong&gt; There was nothing to check it with. An agreement holds exactly until the first edit made by someone in a hurry, and the violation it produces is invisible: forwarding a header is not a compile error, not a failing assertion, not a 500. It shows up in production, and only if somebody goes looking for it. Nobody goes looking for a rule everybody agrees with.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What changed.&lt;/strong&gt; The rule was turned into a prohibiting check: the test stand fails if a token header arrived in the incoming call metadata. That is now the only form in which this rule exists in an executable state on my side.&lt;/p&gt;

&lt;p&gt;There are no numbers attached to this episode, so I am not attaching any. Not how many services accept a claim, not how large the metadata is, not how long the edge takes to parse the input - &lt;strong&gt;those numbers go in when they are taken from the repo, not before.&lt;/strong&gt; A hole you can see is worth more than a plausible figure.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this is usually done
&lt;/h2&gt;

&lt;p&gt;Four approaches show up in public write-ups and standards, and all four are defensible depending on what you are building:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Forwarding the caller's token all the way down.&lt;/strong&gt; Every service validates it independently. Simple to reason about, and the reason it is popular is that it needs no new format - the thing the client sent is the thing the service reads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exchanging the token at the boundary&lt;/strong&gt; for a narrower internal one. This is a standardised operation - &lt;a href="https://datatracker.ietf.org/doc/html/rfc8693" rel="noopener noreferrer"&gt;RFC 8693, OAuth 2.0 Token Exchange&lt;/a&gt; - and it keeps the external credential outside while still giving services something verifiable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A signed assertion about the caller&lt;/strong&gt; placed in the call metadata, usually as a &lt;a href="https://datatracker.ietf.org/doc/html/rfc7519" rel="noopener noreferrer"&gt;JWT&lt;/a&gt; issued by the boundary rather than by the external issuer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mutual authentication between services at the transport layer&lt;/strong&gt;, which answers a different question - &lt;em&gt;which process is calling&lt;/em&gt; - and is a separate layer from &lt;em&gt;which person is behind the call&lt;/em&gt;. The two are often confused; they compose, they do not replace each other.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these is wrong. The first is the cheapest to start with and the hardest to reverse: once three services have a verification path, removing it is a change to three teams' code. The second and third both move the parsing to one place; they differ in whether what comes out is still a credential. What we do is the third shape with the verification removed on the receiving side - the boundary issues the claim, and nothing behind it checks the issuing. That is a real trade-off and it gets a price list further down, rather than a shrug.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we do it
&lt;/h2&gt;

&lt;p&gt;Three statements, one line each:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One place parses the input.&lt;/strong&gt; The outer boundary, once, per request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identity travels in the call metadata, next to the call itself&lt;/strong&gt; - not in the body, not in a lookup the service performs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A service is a consumer of the claim, not a verifier of it.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And a fourth that is easy to forget until the logs go quiet: &lt;strong&gt;the same requirement applies on the asynchronous path.&lt;/strong&gt; The claim has to survive the queue boundary the same way the call context does - passed as the first parameter everywhere, including background handlers and the publish call itself. If it does not survive, the background handler is acting as the machine, and every record it writes says so. Our domain events carry a reference to the acting principal in the message itself (&lt;code&gt;principalRef&lt;/code&gt; in the event payload), which is what makes "who did this" answerable after the fact rather than at request time only.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8k2qex8uhw7es5xk7tni.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8k2qex8uhw7es5xk7tni.png" alt="One synchronous lane where the edge hands a claim to a service, and below it the asynchronous lane: service, queue, background handler, with two outcomes - the claim carried in the message metadata so the record names the person, or the claim dropped at the publish so the record names the machine" width="800" height="579"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a check and not a rule
&lt;/h2&gt;

&lt;p&gt;There is a ladder I keep coming back to, and it has three rungs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A reminder&lt;/strong&gt; - said in a thread. Lives one session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A rule&lt;/strong&gt; - written in an instruction file. Works while it is being read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A check&lt;/strong&gt; - a linter, a prohibiting test, a structure test, a blocking hook. Works.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq69qnccvn31owtzn7ula.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq69qnccvn31owtzn7ula.png" alt="A card of incoming call metadata with the authorization line highlighted in vermilion, and beside it the result plate: stand fails. Underneath, the line: a rule with no executable check is not a rule" width="800" height="466"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The stand that fails on a token header sits on the third rung, in the same row as the other prohibiting checks we run: the forbidden test asserting that the row-scan loop appears in exactly one package; the block on a test that uses an empty context instead of the test runtime's; the drift check that fails the build when the environment catalogue and the metrics snapshot no longer match the code.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7qfcsbwyap2raddg293s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7qfcsbwyap2raddg293s.png" alt="Three steps rising left to right - reminder lives one session, rule works while it is read, check fails the build - with the third step in the accent colour, and below them a strip listing the prohibiting checks that exist" width="800" height="555"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One line, and it is the only thing in this article I would defend without qualification:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;An agreement about identity with no executable check under it is not a rule.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It is a shared intention. Shared intentions are useful. They are not enforcement, and confusing the two is how a fleet ends up with three services that quietly do it the old way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the check actually asserts
&lt;/h2&gt;

&lt;p&gt;It is worth being precise about what a prohibiting check like this does and does not prove, because it is easy to oversell.&lt;/p&gt;

&lt;p&gt;It asserts one thing: &lt;strong&gt;on the path the stand exercises, no token header reached a service.&lt;/strong&gt; That is narrow. It does not prove the claim is well-formed, it does not prove the edge parsed correctly, it does not prove some other path exists where a header does get through.&lt;/p&gt;

&lt;p&gt;What makes it worth having anyway is the failure mode it removes. The violation it catches is the one nobody would otherwise notice - the header that gets forwarded because forwarding all headers was the convenient way to write the client, and that then works perfectly for months. A check that catches a class of silent, correct-looking mistakes is worth more than a broad check that catches loud ones, because the loud ones were going to be caught anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three consequences
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;One place parses the input.&lt;/strong&gt; Parsing, signature checking, clock skew, key rotation, malformed-input handling - all of it exists once. Not once per service, and not once per service &lt;em&gt;plus&lt;/em&gt; the four services that copied it from the first one before it was fixed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Changing how people log in does not touch the services.&lt;/strong&gt; The shape of the input is a property of the boundary. Behind it, services consume a claim whose meaning has not changed, so a change in the way identity is established is a change in one component instead of a fleet-wide migration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Logs and traces name the person who acted, not the machine that ran.&lt;/strong&gt; This is the one I underestimated. Because the claim rides with the call and continues into the event stream, an audit record can name the acting principal - and that record is written in the same transaction as the mutation it describes, not appended by a best-effort listener afterwards. "Who changed this" stops being an investigation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where identity ends and permission begins
&lt;/h2&gt;

&lt;p&gt;A short divider, because these two get mixed constantly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;"Who arrived" and "what they may do" are different questions.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A decision that needs the service's own data - &lt;em&gt;does this tenant own this row&lt;/em&gt; - is made in the service, because that is the only place it is answerable at all. Everything else is made as early on the request path as it can be. That is the whole boundary for the purposes of this article; the full breakdown of where permission decisions live is the next part.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;An honest list, not one token item:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The boundary becomes a single point of trust.&lt;/strong&gt; The service takes the claim as given and cannot check it. If the boundary is wrong, everything behind it is wrong, confidently and silently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A claim format exists now, and every participant must understand it.&lt;/strong&gt; Changing it is a breaking change across the fleet, with all the coordination that implies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A locally raised service needs a way to inject a claim.&lt;/strong&gt; Otherwise it is unusable without the boundary in front of it, and the developer experience quietly degrades into "you can only test this on a stand".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Debugging "why is the wrong principal here" moves to the boundary&lt;/strong&gt; - that is, into code the service's author does not own and may not have read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The prohibiting stand has to be maintained.&lt;/strong&gt; A check that gets switched off "just for now" is not a check any more; it is a comment with a longer commit history.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When not to do this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;When there is exactly one service&lt;/strong&gt; and no boundary as a separate participant. Parsing the input in place is cheaper, and a claim format between one sender and one receiver is ceremony.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When the service genuinely needs the original input&lt;/strong&gt; - for instance, when it &lt;em&gt;is&lt;/em&gt; the boundary, or when it re-issues credentials.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When an external consumer is contractually owed a response derived from the original token.&lt;/strong&gt; Then the token has a reason to travel, and the reason is written down in a contract rather than in habit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one honest caveat about the boundary of this whole description: &lt;strong&gt;there is no single permission-decision service on my side.&lt;/strong&gt; The general "may this caller reach this at all" check lives at the edge; the data-dependent check lives in the service; there is no third participant deciding both. Everything above is about identity - who arrived - and not about permission.&lt;/p&gt;

&lt;h2&gt;
  
  
  The multiplier line
&lt;/h2&gt;

&lt;p&gt;Wiring the check, moving the claim through the metadata, threading the context into the background handlers - that part is fast now, fast in a way that is genuinely different from a few years ago. What did not get faster is the decision that this rule deserves a prohibiting check at all, and that the check belongs in the stand rather than in a document. That call sets what "correct" means, and everything generated afterwards is correct or wrong relative to it. Speed amplifies whoever set the constraints; it does not supply them.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Access, tracing, rollout - Part 1.&lt;/strong&gt; Next: the same question one step further in - who decides what is allowed, and the three places that decision can be made, each with a different price.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you do this better&lt;/strong&gt;, tell me what your boundary passes inward and how a service proves it did not get a token. If &lt;strong&gt;you have been through this&lt;/strong&gt;, what did your identity agreement turn out to have drifted into before anyone noticed? If &lt;strong&gt;you see it differently&lt;/strong&gt;, say where forwarding the caller's token all the way down is the cheaper answer. How is it solved on your side, and what broke there?&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>security</category>
      <category>microservices</category>
      <category>go</category>
    </item>
    <item>
      <title>The Same Decision at Three Depths</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Sat, 03 Oct 2026 15:18:10 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/the-same-decision-at-three-depths-16d6</link>
      <guid>https://dev.to/anton_brilliantov/the-same-decision-at-three-depths-16d6</guid>
      <description>&lt;p&gt;&lt;em&gt;Layers, daemons, services - one boundary drawn at three depths, and each depth has its own bill.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This block is about what a service is to everyone &lt;em&gt;else&lt;/em&gt;: whose it is, who calls it, what it promises. Part 5 listed the four lines of a storage requirement; this last part picks one of them - the ratio of reads to writes - and follows it as far down as it goes. Running notes are on my GitHub: &lt;strong&gt;&lt;a href="https://github.com/brilliant-almazov" rel="noopener noreferrer"&gt;github.com/brilliant-almazov&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is how I do it right now, with the price attached - maybe you already do it better, maybe you see it differently.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the boundary gets drawn
&lt;/h2&gt;

&lt;p&gt;Short primer, so this reads without the earlier parts of the block.&lt;/p&gt;

&lt;p&gt;Each of our Go services raises itself from one declaration - a manifest listing its daemons, its databases, its queues, its schedules. The runtime and the deploy pipeline read that file; nothing about a service is true because a page somewhere says so. One line of the requirement feeding that file is the ratio of reads to writes, and it is written before the first table exists.&lt;/p&gt;

&lt;p&gt;What that line buys is not only a schema decision. It decides which executor runs a read and which one runs a write - and "executor" turns out to mean three different things, depending on how far down you take it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thesis
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Splitting reads from writes is not a decision about whether. It is a decision about how deep.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The same boundary can sit between two packages inside one process, between two daemons built from one image, or between two services with a stream of events between them. All three are the same decision taken at a different depth. Each step down buys something specific, and each step down is paid for in consistency, in latency and in the number of moving parts.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhhibvmeg6chp4bymvy9t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhhibvmeg6chp4bymvy9t.png" alt="Three cards left to right - layers in one process with repository and manager, daemons of one image with server and worker, separate services with a writer and an event stream - the third drawn with a dashed border and marked not in production" width="800" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The case: 44 copies of four read shapes
&lt;/h2&gt;

&lt;p&gt;Before any of that is worth discussing, the first depth has to actually be one thing rather than forty-four. Here is the audit that started this, run on the service in question in August:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  multi-row loop   ..............  18 copies
  single-row read  ..............   6
  COUNT(*)         ..............   8
  existence probe  ..............  12
  ────────────────────────────────────
  total            ..............  44   of which 12 differ only
                                        in the text of the error wrapper
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four shapes of reading rows out of a database, written by hand in every domain that needed them. Nothing in that list was wrong on its own. Every copy compiled, every copy was tested, every copy did what its caller wanted.&lt;/p&gt;

&lt;p&gt;The problem is what the four shapes differed by: &lt;strong&gt;the result type and the dependency, and nothing else.&lt;/strong&gt; That is the definition of a generic - written out with copy-paste instead of a type parameter. And the correct form already existed in the tree: a reader carrying an executor, a statement and a scanner, with one &lt;code&gt;Query(ctx, in)&lt;/code&gt; method. It was sitting inside a domain package, where no other domain could reach it, so everyone else wrote their own.&lt;/p&gt;

&lt;p&gt;How it got to 44 is the least interesting part and the most familiar one. Each copy was written by somebody who needed a list of rows that afternoon, could see three similar loops nearby, and had no reason to believe the fourth one was a problem. Nobody made a bad call. The count is what makes it visible: one loop is a loop, eighteen loops are a missing abstraction, and nothing in between produces a moment where anyone notices.&lt;/p&gt;

&lt;p&gt;The fix was not clever. The reader moved into one cross-cutting package, the 44 copies became calls into it, and the question was closed by a forbidding test: &lt;code&gt;for rows.Next()&lt;/code&gt;, &lt;code&gt;rows.Err()&lt;/code&gt;, &lt;code&gt;rows.Close()&lt;/code&gt; and &lt;code&gt;QueryRow(&lt;/code&gt; occur in exactly one package of the tree. Not "should occur" - the test fails the build if they occur anywhere else. A convention that is only written down decays; a convention with a failing build behind it does not.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjkbe9cpy1un5jhymqly6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjkbe9cpy1un5jhymqly6.png" alt="Four bars scaled to the largest count - multi-row loop 18, existence probe 12, count 8, single-row read 6 - totalling 44 copies, with a note that 12 differ only in the error wrapper text, and a card below naming the single reader primitive and the forbidding test that keeps it single" width="800" height="565"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is the connection to everything below. &lt;strong&gt;The first depth is only worth having when the read side is one primitive.&lt;/strong&gt; A read layer that is forty-four hand-written variants is not a layer; it is a naming convention. Splitting it from the write side buys nothing, because there is nothing coherent on either side of the line.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this is usually done
&lt;/h2&gt;

&lt;p&gt;Fairly, because all three of these are reasonable and I have used the first two.&lt;/p&gt;

&lt;p&gt;The common framing is command-query separation inside one application: the write path and the read path get their own models and their own objects, in one process and one deployment - the shape described in &lt;a href="https://martinfowler.com/bliki/CQRS.html" rel="noopener noreferrer"&gt;Fowler's note on CQRS&lt;/a&gt;. One level up, the read side becomes its own model, kept current by events emitted from the write side; that is the version in the &lt;a href="https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs" rel="noopener noreferrer"&gt;Azure architecture centre's CQRS pattern&lt;/a&gt;, usually paired with a &lt;a href="https://learn.microsoft.com/en-us/azure/architecture/patterns/materialized-view" rel="noopener noreferrer"&gt;materialised view&lt;/a&gt;. And one level up again, the read model becomes a separate deployable, with its own scaling and its own store.&lt;/p&gt;

&lt;p&gt;Those are three points on the same axis, not three competing schools. What follows is where I place each one and what it costs me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Depth 1 - layers inside one process
&lt;/h2&gt;

&lt;p&gt;Two layers touch the database, and only two. &lt;strong&gt;&lt;code&gt;repository&lt;/code&gt; reads and only reads. &lt;code&gt;manager&lt;/code&gt; writes and only writes&lt;/strong&gt;, find-or-create included, because that is a write with a read in front of it.&lt;/p&gt;

&lt;p&gt;There is no &lt;code&gt;store&lt;/code&gt;, no &lt;code&gt;writer&lt;/code&gt;, no &lt;code&gt;dao&lt;/code&gt;, no &lt;code&gt;service&lt;/code&gt; in between. Those names exist to avoid deciding which of the two things a type does, and every one of them ends up doing both. When a manager needs to read, it calls a repository; it does not carry its own copy of the SQL.&lt;/p&gt;

&lt;p&gt;The package layout follows the component type, with the domain nested inside it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  internal/repository/order/methods/     one method = one file = one type with Execute
  internal/manager/order/methods/
  internal/repository/account/methods/
  internal/manager/account/methods/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details make the split hold rather than erode:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One method is one file and one type with an &lt;code&gt;Execute&lt;/code&gt; method.&lt;/strong&gt; A package that reads eleven things has eleven files, not one file with eleven functions on a struct that slowly grows a write path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The transaction is a decorator on the outside, and only over mutations.&lt;/strong&gt; &lt;code&gt;tx pgx.Tx&lt;/code&gt; never appears in a signature; the executor comes from the context. A domain method does not know whether it is running inside a transaction, which is exactly why the read side can never quietly acquire one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Worth being precise about what this is &lt;strong&gt;not&lt;/strong&gt;. Two layers is not two models. There is one representation of the data, one schema, one transaction boundary; what is split is which code is allowed to write and which code is allowed to read, not what the data looks like on each side. The distinction matters because the word "CQRS" gets attached to both, and only the deeper version carries the consistency bill.&lt;/p&gt;

&lt;p&gt;The price of this depth is close to zero. It is a rule about where files live, not a piece of infrastructure. Nothing new runs, nothing new can fail, and there is no consistency question because it is all one process and one connection pool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Depth 2 - two daemons of one image
&lt;/h2&gt;

&lt;p&gt;One image, two binaries: &lt;strong&gt;&lt;code&gt;server&lt;/code&gt;&lt;/strong&gt; answers synchronous calls, &lt;strong&gt;&lt;code&gt;worker&lt;/code&gt;&lt;/strong&gt; drains the outgoing-event queue, runs background computation, and executes partition retention on the audit tables.&lt;/p&gt;

&lt;p&gt;Both are declared in the manifest and deployed by the same pipeline with a parameter saying which of them to bring up. That declaration is the whole mechanism - there is no second repository, no second image, no separate release.&lt;/p&gt;

&lt;p&gt;One rule about the composition of that list, and it is not a technical one: &lt;strong&gt;the set of daemons is decided by the owner of the service, and by nobody else.&lt;/strong&gt; New background work goes into an existing daemon. A new &lt;code&gt;cmd/*&lt;/code&gt; is not something an implementer introduces on the way past, because a binary that nobody declared is a binary that nothing deploys.&lt;/p&gt;

&lt;p&gt;What this depth buys is that a long job stops sharing a request budget with a synchronous handler. What it costs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A second process&lt;/strong&gt;, with its own resource configuration, its own restarts and its own place in every dashboard. Two processes is where "the service is up" stops being a single fact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One publisher per gauge.&lt;/strong&gt; Queue depth and partition age are published by &lt;code&gt;worker&lt;/code&gt; only, never by &lt;code&gt;server&lt;/code&gt;. Two processes publishing one gauge produce a graph that flips between their two versions of the truth, which is worse than having no graph, because it looks like data.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Depth 3 - separate services
&lt;/h2&gt;

&lt;p&gt;The third step is a write-side service and a stream of events, with the read side living somewhere else entirely, on its own store and its own schedule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We do not have this in production.&lt;/strong&gt; What we have is depth 1 and depth 2. I am describing the third because the mechanism is worth writing down and because its price is what keeps the decision honest - not because there is an installation behind it.&lt;/p&gt;

&lt;p&gt;Everything that was free at the first two depths becomes an explicit obligation here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Delivery is at-least-once.&lt;/strong&gt; The broker does not deduplicate. Idempotency by event identifier is the consumer's job, and it is not optional - it is the thing that makes the stream usable at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The relay is the single drainer.&lt;/strong&gt; Events leave in strict order by &lt;code&gt;id&lt;/code&gt;, and &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; is deliberately not used. Parallel draining is faster and it breaks order, and order is precisely what lets a consumer rebuild a correct replica.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redelivery is normal, a spike of it is the incident.&lt;/strong&gt; If the process dies between publishing a message and deleting its row, the same &lt;code&gt;message_id&lt;/code&gt; goes out again on the next pass. That is designed behaviour, not a fault. The alert is on the rate, not on the event.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4u1s6ftch1w8is87a1d8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4u1s6ftch1w8is87a1d8.png" alt="Top row: five queue rows numbered id 1 to 5 with a single arrow above them labelled relay, single drainer, strict order by id. Bottom row: the same five rows with two parallel drainers in vermilion, marked order lost. A strip below states that delivery is at-least-once and idempotency by event identifier belongs to the consumer" width="800" height="541"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read that list again as a cost sheet rather than a design. Three obligations, all of them permanent, all of them on code that did not previously need to exist. And note where they land: not on the service that made the split, but on every consumer downstream of it. That is the part that makes this depth expensive in a way the first two are not - you are spending somebody else's budget as well as your own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The depth is chosen by a line of the requirement
&lt;/h2&gt;

&lt;p&gt;This is the part I would defend hardest, and it is not about reads and writes specifically.&lt;/p&gt;

&lt;p&gt;An argument about "CQRS or not" is settled by a line of a non-functional requirement, not by preference. If no such line exists, there is nothing to argue about - the requirement has not been gathered yet, and two people are comparing instincts.&lt;/p&gt;

&lt;p&gt;For this particular decision the mapping is short enough to fit in four lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  read/write ratio near 1, small load        →  depth 1, and stop
  different load, different shape            →  depth 1, from the first table
  heavy background work beside handlers      →  depth 2
  different owner, different response target →  depth 3, with the bill below
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The read-to-write ratio is one of the four lines of the storage requirement from Part 5. That is where this decision is made - months before anybody opens an editor to make it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one conclusion
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Every step deeper is paid for in three currencies: consistency, latency and the number of moving parts. Until a line of the requirement shows there is something to pay with, the decision stays at the depth above.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is the whole rule. It is deliberately not "start at depth 1 and grow into depth 3" - growth is not the trigger, a named requirement is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each depth costs
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fel3zh2abqh57kbxo9oyo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fel3zh2abqh57kbxo9oyo.png" alt="A three-row table - depth, what it buys, what it costs - with layers in one process, daemons of one image, and separate services; the third row marked not in production" width="799" height="569"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;depth&lt;/th&gt;
&lt;th&gt;what it buys&lt;/th&gt;
&lt;th&gt;what it costs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;layers in one process&lt;/td&gt;
&lt;td&gt;a read is written once, not 44 times&lt;/td&gt;
&lt;td&gt;moving between two package trees to read one feature&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;daemons of one image&lt;/td&gt;
&lt;td&gt;background work stops sharing a request budget&lt;/td&gt;
&lt;td&gt;a second process, a second configuration, one gauge owner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;separate services&lt;/td&gt;
&lt;td&gt;read and write scale and fail separately&lt;/td&gt;
&lt;td&gt;eventual consistency, idempotent consumers, ordering by design&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The third row is the one worth staring at, and it has one more entry than the cell holds: debugging a problem across two systems instead of one. Nothing in that row is a one-off cost. Every line of it is a permanent obligation on everyone who touches that code afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a skipped depth looks like later
&lt;/h2&gt;

&lt;p&gt;The failure mode never announces itself as "we picked the wrong depth". It arrives wearing something else.&lt;/p&gt;

&lt;p&gt;A background recalculation that shares a process with request handlers is not reported as a topology problem. It is reported as latency, intermittently, on endpoints that have nothing to do with it - and it gets three separate investigations before anyone looks at what else is running in that process. That is a missing depth 2.&lt;/p&gt;

&lt;p&gt;The opposite direction is quieter and more expensive. A team goes to depth 3 for a domain whose read and write have the same owner and the same response-time target. Nothing breaks. There is simply a stream, a relay, an idempotency key and a second store to reason about forever, in exchange for a separation that nobody ever needed. Nothing about that shows up as an incident - it shows up as every subsequent change taking longer than it should.&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to go deeper
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;When the ratio is close to one and the load is small.&lt;/strong&gt; Depth 1 is the whole answer. Going further adds parts and removes no problem, and "we might need it later" is not a requirement line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When there is no separate response-time target for reads.&lt;/strong&gt; Without that, depth 3 buys nothing that depth 2 did not already buy, and it charges you eventual consistency for the privilege.&lt;/p&gt;

&lt;p&gt;And the honest boundary, stated once more because it is easy to read this piece as a report rather than an analysis: &lt;strong&gt;one domain split across separate read and write services does not exist in my production.&lt;/strong&gt; Layers exist. Daemons exist. The third depth is written up here as a mechanism and a price, and that is all it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The multiplier line
&lt;/h2&gt;

&lt;p&gt;Collapsing 44 copies into one reader is exactly the kind of work an automated executor does well - mechanical, well-specified, verifiable by a test that either passes or does not. What it did not do was notice that four shapes differing only by result type and dependency were a generic written by hand, or decide that the right answer was one cross-cutting primitive rather than a tidier copy in each domain. That judgement is the whole exercise, and speed applied before it is made produces forty-four beautifully refactored copies. Speed amplifies whoever set the boundary; it does not draw it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Services and commitments - Part 6.&lt;/strong&gt; That closes this block: a service exists when it is declared, its owner is a field, its call map is observed rather than claimed, its promises are metrics with targets on them, its volume is a requirement line, and that same line decides how deep the read/write boundary is cut.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you do this better&lt;/strong&gt;, tell me what made you go one depth further than you had to. If &lt;strong&gt;you have been through this&lt;/strong&gt;, what did the extra depth cost you that nobody had costed up front? If &lt;strong&gt;you see it differently&lt;/strong&gt;, say where separating reads and writes at the service level pays off earlier than I claim. How is it solved on your side, and what broke there?&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>go</category>
      <category>microservices</category>
      <category>cqrs</category>
    </item>
  </channel>
</rss>
