<?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>Context Is Not "More Context"</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Fri, 04 Sep 2026 06:34:51 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/context-is-not-more-context-1744</link>
      <guid>https://dev.to/anton_brilliantov/context-is-not-more-context-1744</guid>
      <description>&lt;p&gt;&lt;em&gt;An iteration that touched two files was burning ~350,000 tokens - not because the task was big, but because whoever executed it went looking around the repository first. What fixed it was not more context. It was writing down which context, once, and forbidding the rest.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go. Most of what I write about comes out of carving a live PHP monolith into Go services. This series is about a smaller, adjacent problem: how I hand work to an executor - human or model - so the result comes back correct without me re-explaining the codebase every time. 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;These are my working habits, not a methodology. They're shaped by one specific codebase with very rigid conventions. Take the parts that transfer.&lt;/p&gt;




&lt;h2&gt;
  
  
  The number that started this
&lt;/h2&gt;

&lt;p&gt;I was running a stage of work split into small iterations. Each iteration was supposed to be tiny: connect one file to an existing core, move its test, run the package's tests, done. Two files touched, maybe three.&lt;/p&gt;

&lt;p&gt;Each of those iterations cost roughly &lt;strong&gt;350,000 tokens&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I assumed the tasks were bigger than I thought. They weren't. I went back and looked at what was actually being read, and the answer was boring: the repository. Before writing a line, the executor was hunting for the type it had to embed, then for a similar file in a neighbouring package to copy the shape from, then for the error sentinel names, then for the test helper. Every single time. Nine-tenths of the spend was reconnaissance, and none of it landed in the diff.&lt;/p&gt;

&lt;p&gt;The reflex fix is to give more: "here's the architecture doc, here's the design, here's the other domain, go." That makes it worse. Reconnaissance you pay for once is a cost. Reconnaissance you hand over as background is a cost &lt;em&gt;and&lt;/em&gt; a hazard - the more neighbouring code is in view, the higher the chance the wrong neighbour becomes the template.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two different things get called "context"
&lt;/h2&gt;

&lt;p&gt;Separating these was the whole fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Task context&lt;/strong&gt; - what has to be done and how it will be checked. This must be &lt;em&gt;complete&lt;/em&gt;. Anything missing here gets invented.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Codebase context&lt;/strong&gt; - how things are built around here. This must be &lt;em&gt;minimal&lt;/em&gt;. Exactly the files named in the task, nothing else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Almost every bad task I've written failed by getting these backwards: vague about the task, generous about the codebase. "Connect the reads to the shared core, see how the neighbouring domain does it" is exactly that shape. It says nothing checkable, and it delegates a search.&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%2Fd4iqdfji47a1jtoddh9f.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%2Fd4iqdfji47a1jtoddh9f.png" alt="Two columns contrasting task context, which must be complete, with codebase context, which must be minimal - each row marked plus for required or minus for forbidden" width="800" height="407"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TASK CONTEXT — must be complete       CODEBASE CONTEXT — must be minimal
─────────────────────────────────     ─────────────────────────────────
files to create (exact paths)         only the files named, by path
files to change (exact paths)         no tree search, no grep
signatures it plugs into              no neighbouring packages
values: errors, env, tables           no discussion history
the acceptance command                no "how it's usually done"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What the executor must know
&lt;/h2&gt;

&lt;p&gt;Five things, and they're all boring to write down:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Exact paths&lt;/strong&gt; of every file to create and every file to change - a closed list. A file not on the list is not touched and not read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full signatures&lt;/strong&gt; of whatever it plugs into: name, type parameters, methods, package, import alias. Not "the reader type" - the actual declaration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The existing primitives it must reuse&lt;/strong&gt;, by path, each marked &lt;em&gt;do not write your own copy&lt;/em&gt;. This is the single highest-value line in any task I write, because the default failure of a fast executor is to write a fresh, locally-reasonable version of something that already exists twenty metres away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Values, as values&lt;/strong&gt;: error sentinel names, env var names, queue names, metric names, table names. Not "the usual naming" - the strings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The acceptance command&lt;/strong&gt;, runnable with no substitution: the exact test invocation, scoped to the package being touched.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What the executor must not know
&lt;/h2&gt;

&lt;p&gt;Neighbouring iterations. The discussion that led to the design. Packages outside the list. "How we generally do things."&lt;/p&gt;

&lt;p&gt;Every one of those is a real cost multiplied by the number of executors, and every one is a chance to copy the wrong template. A fact written into the task is paid for &lt;strong&gt;once&lt;/strong&gt;. The same fact discovered by the executor is paid for &lt;strong&gt;on every executor&lt;/strong&gt; - and it isn't even the same fact twice, because two searches don't land in the same place.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the spend actually goes
&lt;/h2&gt;

&lt;p&gt;After the split I started sorting the waste. Roughly in order of size:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;Share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Reconnaissance&lt;/td&gt;
&lt;td&gt;types, paths and signatures get re-discovered; neighbouring packages get read&lt;/td&gt;
&lt;td&gt;largest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full re-runs&lt;/td&gt;
&lt;td&gt;every iteration runs the whole test suite and the linter over the whole repo&lt;/td&gt;
&lt;td&gt;large&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rework&lt;/td&gt;
&lt;td&gt;an iteration fails acceptance and gets rewritten&lt;/td&gt;
&lt;td&gt;large&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Guard trips&lt;/td&gt;
&lt;td&gt;a copy of an existing generic gets written, a check blocks it, the cause gets hunted&lt;/td&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicated rules&lt;/td&gt;
&lt;td&gt;the shared style block is copy-pasted into every file of the set and rides along in every context&lt;/td&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Re-reading&lt;/td&gt;
&lt;td&gt;a file that was just written gets read back to "verify"&lt;/td&gt;
&lt;td&gt;small but constant&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;source              what happens                                   share
──────────────────  ─────────────────────────────────────────────  ───────
reconnaissance      types, paths, signatures re-discovered         largest
full re-runs        whole suite + linter over the whole repo       large
rework              an iteration fails acceptance, written twice   large
guard trips         a copy of an existing generic gets blocked     medium
duplicated rules    the style block pasted into every file         medium
re-reading          a just-written file is read back to verify     small
&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%2F3l4e2xorro08x5eqv76j.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%2F3l4e2xorro08x5eqv76j.png" alt="Table of where the token spend goes, from reconnaissance as the largest share down to reading files back as the smallest" width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two of those are worth calling out because they look like diligence:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full re-runs.&lt;/strong&gt; An iteration that touches one package does not need the whole suite. It needs &lt;code&gt;go test ./internal/&amp;lt;package&amp;gt;/... -race -count=1&lt;/code&gt;. The full run - formatting, vet, linter, everything with &lt;code&gt;-race&lt;/code&gt;, structure checks - happens &lt;strong&gt;once&lt;/strong&gt;, by me, at the end of the stage. Running it per iteration feels responsible and costs a fortune.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Re-reading.&lt;/strong&gt; Writing a file and then reading it back to check it landed. The edit tool reports its own failures; reading back is pure spend.&lt;/p&gt;




&lt;h2&gt;
  
  
  The mechanism: a facts section
&lt;/h2&gt;

&lt;p&gt;So the control document for a stage carries a section called &lt;em&gt;Facts&lt;/em&gt;, and its job is to make search unnecessary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;exact package paths, and the path of every file that will be created or changed;&lt;/li&gt;
&lt;li&gt;full signatures of the types and interfaces the iterations plug into, with import aliases;&lt;/li&gt;
&lt;li&gt;the list of existing generics and cross-cutting primitives that &lt;strong&gt;must&lt;/strong&gt; be reused, each with path and signature and the note &lt;em&gt;do not write a copy&lt;/em&gt;;&lt;/li&gt;
&lt;li&gt;names of error sentinels, env vars, queues, metrics and tables - as values;&lt;/li&gt;
&lt;li&gt;the acceptance command, one line, no substitution.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I fill it in before the first iteration runs. That is the one place where reading around the repository is allowed, and it's me doing it. When an executor asks a clarifying question, I don't answer in chat - I add the fact to the section. Answering in chat fixes one executor; adding the fact fixes every executor after it.&lt;/p&gt;

&lt;p&gt;The other half of the same rule: &lt;strong&gt;the shared style block lives in exactly one document.&lt;/strong&gt; Iterations reference it in one line instead of repeating it. When a rule changes, it changes in one file. I learned that one the hard way, having pasted the same block into fifteen files and then needing to change it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The same task, two ways
&lt;/h2&gt;

&lt;p&gt;Anonymised, but this is the real difference in shape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before&lt;/strong&gt; - three lines, and every one of them delegates a search:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Move the entity reads onto the shared read core.
Look at how the neighbouring domain does it and follow the same pattern.
Run the tests.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After&lt;/strong&gt; - the same work, nothing left to find:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;Files&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;closed&lt;/span&gt; &lt;span class="n"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
  &lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;internal&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;methods&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;list&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;go&lt;/span&gt;
  &lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;internal&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;methods&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;list_test&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;go&lt;/span&gt;

&lt;span class="n"&gt;Plug&lt;/span&gt; &lt;span class="n"&gt;into&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;do&lt;/span&gt; &lt;span class="n"&gt;not&lt;/span&gt; &lt;span class="n"&gt;write&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="nb"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
  &lt;span class="n"&gt;internal&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reader&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;In&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Out&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;statement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scanner&lt;/span&gt;
    &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;in&lt;/span&gt; &lt;span class="n"&gt;In&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="s"&gt;"&amp;lt;module&amp;gt;/internal/repository/rows"&lt;/span&gt;

&lt;span class="n"&gt;Values&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
  &lt;span class="n"&gt;sentinel&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="n"&gt;empty&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrNotFound&lt;/span&gt;
  &lt;span class="n"&gt;wrap&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;              &lt;span class="s"&gt;"list orders: %w"&lt;/span&gt;

&lt;span class="n"&gt;Read&lt;/span&gt; &lt;span class="n"&gt;only&lt;/span&gt; &lt;span class="n"&gt;these&lt;/span&gt; &lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;internal&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;methods&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;list&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;go&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                       &lt;span class="n"&gt;internal&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;methods&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;list_test&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;go&lt;/span&gt;

&lt;span class="n"&gt;Acceptance&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="n"&gt;test&lt;/span&gt; &lt;span class="o"&gt;./&lt;/span&gt;&lt;span class="n"&gt;internal&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;/...&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;race&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second version is longer to write and shorter to run. It is also checkable: I can tell whether it was followed without reading the diff carefully, because the file list and the acceptance command are both facts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BEFORE                                  AFTER
"move the reads onto the shared core"   closed list: 2 files + 1 signature
        │                                        │
        ├──▶ pkg a                               │        pkg a
        ├──▶ pkg b                               │        pkg b
        ├──▶ pkg order                           └──────▶ pkg order
        ├──▶ pkg d                                        pkg d
        ├──▶ pkg e                                        pkg e
        └──▶ pkg f                                        pkg f
  every executor pays the search again    the fact is written once
&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%2Fx0f36enjczcsje0z0iiq.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%2Fx0f36enjczcsje0z0iiq.png" alt="The same task written two ways: the vague version fans out to six packages the executor has to search, the closed-list version reaches one named package and leaves the rest untouched" width="800" height="786"&gt;&lt;/a&gt;&lt;/p&gt;




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

&lt;p&gt;Writing tasks this way is not free, and I'd rather say so than pretend.&lt;/p&gt;

&lt;p&gt;Someone has to collect the facts, and that someone is me, by reading the code. On a stage of thirteen iterations that's an hour of my time before anything starts. On a stage of forty it's more. If the work is a one-off fix in a file I already have open, the whole apparatus is pure overhead - I just do it.&lt;/p&gt;

&lt;p&gt;It also only works where the conventions are rigid enough to be written down. In this codebase the layout is fixed to the point of being boring: one method per file, reads and writes in separate layers, a hard file-size limit, no comments in code. That rigidity is what makes a task specifiable in a page. In a codebase where every module has its own shape, there's nothing stable to write down, and the executor genuinely does have to look around - which is a statement about the codebase, not about the executor.&lt;/p&gt;




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

&lt;p&gt;The uncomfortable thing is that none of this is specific to models. A task with exact paths, named signatures, forbidden shortcuts and one runnable acceptance command is a good task for a person too. The reason it shows up now is scale: a human executor absorbs a vague task and quietly does the reconnaissance for free, once, and remembers it. A fresh executor does it every time and hands you the bill.&lt;/p&gt;

&lt;p&gt;Speed only helps when correctness is visible without running the thing. That's the discipline that gets amplified - and the lack of it is what gets exposed.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Working with agents - Part 1.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Next: how far a task gets split before it stops being splittable - and the tell that says an iteration is still too big.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>go</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Controlled and Imperfect Beats Perfect and Foreign</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Thu, 03 Sep 2026 09:55:43 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/controlled-and-imperfect-beats-perfect-and-foreign-4pbo</link>
      <guid>https://dev.to/anton_brilliantov/controlled-and-imperfect-beats-perfect-and-foreign-4pbo</guid>
      <description>&lt;p&gt;&lt;em&gt;The code you can change today is worth more than the code you cannot.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving Go&lt;br&gt;
services out of a live PHP monolith. This is the last part of a block about the platform, the&lt;br&gt;
service template and generated skeletons, and I want to close it with the one idea that decided&lt;br&gt;
most of the calls in it. Maybe it is useful to you; maybe you look at this the other way round and&lt;br&gt;
I'd like to hear that. Notes: &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;As with every part of this series: this is what I do on one codebase, not advice for yours.&lt;/p&gt;


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

&lt;p&gt;Between a decision I can change today and a decision that is better but changes on someone else's&lt;br&gt;
release, I take the first one. Not because it is written better - it usually isn't - but because&lt;br&gt;
the cost of changing it is known in advance. "Foreign" here doesn't mean bad. It means &lt;em&gt;not moved&lt;br&gt;
by me&lt;/em&gt;: a shared library, a platform package, a dependency with its own release cadence. Those are&lt;br&gt;
often the better piece of code. They are also the piece whose change window I don't own.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where the rule shows up in the layout
&lt;/h2&gt;

&lt;p&gt;Universal code is born in a service, because that's where you can see it is needed, and it lives&lt;br&gt;
in the platform. That gives three phases, and they are deliberately two different bodies of work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Phase A - preparation, inside the service.&lt;/strong&gt; No imports of any domain package, the public API
frozen, tests moved into the concern's own subfolder, a context-cancellation test present.
Closed by a green run in the service's own repository.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase B - the move into the platform.&lt;/strong&gt; Files relocate, the package name becomes the target
folder's name, service imports are cleaned out. Only on a direct instruction from the platform's
owner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase C - the service switches to the platform version.&lt;/strong&gt; Exactly the given tag goes into the
modules, the local package is deleted, imports are replaced. No pseudo-versions, no &lt;code&gt;replace&lt;/code&gt;.
No tag - the work doesn't start.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Phase C is a separate set for one reason: it is only executable after the tag exists, and tags are&lt;br&gt;
placed by one person. So the rule falls out of the layout on its own. While the package is still&lt;br&gt;
ours, a fix is minutes: edit the file, run that package's tests. After the move, the same fix waits&lt;br&gt;
for a tag.&lt;/p&gt;

&lt;p&gt;That is the whole trade, and it is why a package stays in the service until it has &lt;em&gt;proven&lt;/em&gt; it is&lt;br&gt;
universal - not until it looks universal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌────────────────────────────┐   ┌────────────────────────────┐
│ own package in the service │   │ moved to the platform      │
│                            │   │                            │
│ ├────────────────────────┤ │   │ ├────────────────────────┤ │
│ change: minutes            │   │ change: waits for a tag    │
│                            │   │              │             │
│                            │   │              ▼             │
│                            │   │ ┌────────────────────────┐ │
│                            │   │ │ tag — one person       │ │
│                            │   │ └────────────────────────┘ │
└────────────────────────────┘   └────────────────────────────┘
              same code, different change window
&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%2F9xbpjatuanb283aes8hn.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%2F9xbpjatuanb283aes8hn.png" alt="Two panels comparing the cost of a change: an own package inside the service changes in minutes; the same package moved to the platform waits for a tag, which one person places." width="799" height="315"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How the control is actually held
&lt;/h2&gt;

&lt;p&gt;Control at that scale is not a principle, it's a unit of work. The unit is an &lt;strong&gt;iteration&lt;/strong&gt;: one&lt;br&gt;
concern, which in practice means one code file - sometimes two of the same shape - plus the test&lt;br&gt;
that covers it. If it doesn't fit in one pass, it becomes two iterations. It does not become one&lt;br&gt;
long iteration.&lt;/p&gt;

&lt;p&gt;The tell that an iteration is still too big is not its size:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The executor had to go looking for something.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Which rules out a whole family of tasks that read as perfectly reasonable: &lt;em&gt;"walk the path and fix&lt;br&gt;
every step where the value is lost"&lt;/em&gt;, &lt;em&gt;"find where it breaks"&lt;/em&gt;, &lt;em&gt;"work out how it's done here and&lt;br&gt;
follow it"&lt;/em&gt;, &lt;em&gt;"by analogy with the neighbouring domain"&lt;/em&gt;. Each one makes the executor discover&lt;br&gt;
something first. So the path gets walked &lt;strong&gt;before&lt;/strong&gt; the task is written, and what goes into the&lt;br&gt;
task is the finished list - files, values, the command to run.&lt;/p&gt;
&lt;h2&gt;
  
  
  Batches, and stopping at any point
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;batch&lt;/strong&gt; is one executor, one pass, and the iterations inside it run strictly in order. Batches&lt;br&gt;
inside a wave touch no shared files, so they run at the same time. The set that moved one service&lt;br&gt;
onto the platform runtime was 13 iterations:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Batch&lt;/th&gt;
&lt;th&gt;Iterations&lt;/th&gt;
&lt;th&gt;Wave&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;02 → 03 → 04 → 05&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;06 → 07 → 08&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;09 → 11 → 12&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D&lt;/td&gt;
&lt;td&gt;10 → 13&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E&lt;/td&gt;
&lt;td&gt;01&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Wave 2 starts once both batches of wave 1 are closed; wave 3 after wave 2, because iteration 01&lt;br&gt;
leans on the results of 05 and 07.&lt;/p&gt;

&lt;p&gt;The commit goes in &lt;strong&gt;at the batch boundary&lt;/strong&gt;. No batch is left uncommitted - which is the part that&lt;br&gt;
matters here: the work can be cut off at any boundary without losses, and nothing important is&lt;br&gt;
sitting in my head.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WAVE 1  ┌───────────────────────┐  ┌───────────────────────┐
        │ A  02 → 03 → 04 → 05  │  │ B  06 → 07 → 08       │
        └───────────────────────┘  └───────────────────────┘
                    │                          │
                    ▼                          ▼
WAVE 2  ┌───────────────────────┐  ┌───────────────────────┐
        │ C  09 → 11 → 12       │  │ D  10 → 13            │
        └───────────────────────┘  └───────────────────────┘
                    │
                    ▼
WAVE 3  ┌───────────────────────┐
        │ E  01                 │
        └───────────────────────┘

        commit at the batch boundary
&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%2Fntneo7wf18g1u1csq6so.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%2Fntneo7wf18g1u1csq6so.png" alt="Three stacked waves of batches: wave one holds batches A (02 to 05) and B (06 to 08), wave two holds C (09, 11, 12) and D (10, 13), wave three holds E (01); vertical arrows run between waves and a side note reads commit at the batch boundary." width="799" height="362"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like in numbers
&lt;/h2&gt;

&lt;p&gt;Real set sizes from the last few stages: a set of &lt;strong&gt;13&lt;/strong&gt; iterations (moving off hand-written&lt;br&gt;
runtime), a set of &lt;strong&gt;12&lt;/strong&gt; (a skeleton generator), and a set of &lt;strong&gt;40&lt;/strong&gt; - that one converts&lt;br&gt;
forty-four copies onto a single generic core, one file per iteration.&lt;/p&gt;

&lt;p&gt;A typical iteration is &lt;strong&gt;15 to 35 minutes&lt;/strong&gt; of executor work: 15 minutes for a schedule read from&lt;br&gt;
config, 35 for a typed subscription. That's the whole reason the boundary is cheap. Losing an&lt;br&gt;
iteration costs half an hour, not a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest boundary
&lt;/h2&gt;

&lt;p&gt;Part of what I just described is a plan, not a result, and control includes being able to say which&lt;br&gt;
part:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;skeleton generator is not written&lt;/strong&gt;. There is a written set of 12 iterations for it and a
rule not to start without a direct instruction.&lt;/li&gt;
&lt;li&gt;The sets &lt;strong&gt;"move to the platform"&lt;/strong&gt; and &lt;strong&gt;"apply the platform"&lt;/strong&gt; are written and queued, not
executed. Their phases are a plan; their times are estimates.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;coverage gate is at &lt;code&gt;0&lt;/code&gt;&lt;/strong&gt;. The ratchet exists and can only go up, but the bar has not been
raised yet. Actual coverage is 86.7%.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DONE                             PLANNED
─────────────────────────        ┌──────────────────────────────┐
 the layout                      │ skeleton generator           │
 the generic cores               │ move to platform             │
 the audit                       │ apply platform               │
 the snapshots                   │ coverage gate                │
                                 │   threshold 0 · actual 86.7% │
                                 └──────────────────────────────┘
&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%2Fy26dwlfkj7htlwcr7ja5.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%2Fy26dwlfkj7htlwcr7ja5.png" alt="Two columns: done - the layout, the generic cores, the audit, the snapshots; planned - skeleton generator, move to platform, apply platform, coverage gate at threshold zero against actual coverage 86.7 percent; the planned column is boxed in a vermilion hairline." width="799" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;More writing. Forty iterations means forty written tasks, and on a simple stage writing the set&lt;br&gt;
takes longer than the code it produces. All of that happens before anything runs.&lt;/p&gt;

&lt;p&gt;A slower start. The first visible result arrives later than it would if I just went at the code in&lt;br&gt;
sweeps - and for a while that feels like the wrong call, right up to the first interruption.&lt;/p&gt;

&lt;p&gt;And it doesn't pay at all on a one-off fix. One file, one change, one afternoon: writing a&lt;br&gt;
specification for that is pure overhead, and I don't.&lt;/p&gt;

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

&lt;p&gt;Choose by the cost of changing a decision, not by the quality of the decision.&lt;/p&gt;




&lt;p&gt;That's my experience and my price for it, on one codebase, over a few months. If you do this&lt;br&gt;
better, if you've already been through it, or if you look at it the other way round - I'd like to&lt;br&gt;
hear how it's solved on your side, and what broke when you tried.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Platform and generation - Part 7.&lt;/strong&gt; That closes this block: the layout, what the platform owns,&lt;br&gt;
the audit, the generic cores, code moving both ways, the generator, and now the rule underneath all&lt;br&gt;
of them.&lt;/p&gt;

&lt;p&gt;Next block - operations out of the box: what changes when the manifest is the only place a service&lt;br&gt;
declares itself.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>go</category>
      <category>engineering</category>
      <category>backend</category>
    </item>
    <item>
      <title>Generating the Service Skeleton</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Wed, 02 Sep 2026 12:02:57 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/generating-the-service-skeleton-514e</link>
      <guid>https://dev.to/anton_brilliantov/generating-the-service-skeleton-514e</guid>
      <description>&lt;p&gt;&lt;em&gt;The generator is written once, in code - everything but the business logic comes out of it.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This part is about one tool I have specified: a generator that produces the skeleton of a new domain. Maybe the idea is useful to you, maybe you look at it differently - both are interesting to me. Notes: &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;&lt;strong&gt;The generator does not exist yet.&lt;/strong&gt; What exists is a specification split into 12 iterations and a standing rule that none of them starts without an explicit go-ahead. So this is a piece of designed work and the shape I chose for it, not a report on a working tool. Everything below is in the future tense on purpose: there is no "we generate" here, only "it will generate".&lt;/p&gt;




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

&lt;p&gt;Basic things should be generated by code, not by a model. A model writes the generator once; from&lt;br&gt;
then on it is an ordinary binary - no tokens, no session, and no chance that the layout comes out&lt;br&gt;
slightly different on the fifth domain than it did on the first.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this is possible here at all
&lt;/h2&gt;

&lt;p&gt;The layout and the style are already fixed, hard. Packages go by component type with the domain as&lt;br&gt;
a nested package; reads and writes live in two separate layers; a concern is expressed by a folder,&lt;br&gt;
never by a prefix in a file name; a file stays under 100 lines. As of the last count that is&lt;br&gt;
&lt;strong&gt;2733&lt;/strong&gt; Go files across &lt;strong&gt;252&lt;/strong&gt; packages, average file &lt;strong&gt;39&lt;/strong&gt; lines.&lt;/p&gt;

&lt;p&gt;That rigidity is the whole precondition. A formalised layout is the only thing that makes&lt;br&gt;
generation possible: the generator does not guess the style, it lays files out by rule. Where the&lt;br&gt;
rule is a matter of taste, nothing can be generated - only suggested.&lt;/p&gt;

&lt;p&gt;A second effect comes from the same order: the more behaviour has been pulled into generic cores,&lt;br&gt;
the less there is left to generate. The generator writes wiring; the behaviour is already in the&lt;br&gt;
core it wires to.&lt;/p&gt;
&lt;h2&gt;
  
  
  The shape of the tool
&lt;/h2&gt;

&lt;p&gt;A library with two functions, plus a thin CLI on top:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;Generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;CheckDrift&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not a script, not a folder of templates you copy and rename. A library, because the second&lt;br&gt;
function - "tell me whether what is on disk still matches" - has to run in CI.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two inputs, and neither covers the skeleton alone
&lt;/h2&gt;

&lt;p&gt;The first input is a &lt;strong&gt;YAML description of the domain&lt;/strong&gt;: domain name and plural, Go types, the&lt;br&gt;
account-scope flag, whether there is a slug, whether there is a batch, the header table, the&lt;br&gt;
revision table, the link column, the label column, table aliases, the payload columns (Go type, SQL&lt;br&gt;
type, NULL, comment), the domain event types, the set of error sentinels, and the number of the&lt;br&gt;
first migration. The second is the &lt;strong&gt;&lt;code&gt;.proto&lt;/code&gt; descriptor&lt;/strong&gt;: the full gRPC service name, the list of&lt;br&gt;
RPCs with their request and response types, the entity message, the batch messages.&lt;/p&gt;

&lt;p&gt;Neither is enough on its own. The descriptor knows the wire contract and nothing about storage; the&lt;br&gt;
YAML knows the tables and nothing about which RPCs exist.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌────────────────────────┐      ┌────────────────────────┐
│ domain.yaml            │      │ proto descriptor       │
│                        │      │                        │
│  name + plural         │      │  service name          │
│  Go types              │      │  RPC list              │
│  account scope         │      │  request types         │
│  slug / batch          │      │  response types        │
│  header table          │      │  entity message        │
│  revision table        │      │  batch messages        │
│  link column           │      └────────────────────────┘
│  label column          │                   │
│  table aliases         │                   │
│  payload columns       │                   │
│  event types           │                   │
│  error sentinels       │                   │
│  first migration       │                   │
└────────────────────────┘                   │
             │                               │
             └───────────────┬───────────────┘
                             ▼
                   ┌───────────────────┐
                   │     generator     │
                   └───────────────────┘
                             │
                             ▼
         ┌───────────────────────────────────────┐
         │            service skeleton           │
         └───────────────────────────────────────┘
&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%2F07o8vqrkrwjwaf193vfb.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%2F07o8vqrkrwjwaf193vfb.png" alt="Two input boxes, a domain YAML file and a proto descriptor, both feeding down into one generator box, which produces the service skeleton" width="800" height="593"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Rendering is string concatenation
&lt;/h2&gt;

&lt;p&gt;Constant fragments, a string builder, token replacement - &lt;code&gt;$domain$&lt;/code&gt;, &lt;code&gt;$Domain$&lt;/code&gt;, &lt;code&gt;$plural$&lt;/code&gt;.&lt;br&gt;
Template engines are explicitly ruled out. Every &lt;code&gt;.go&lt;/code&gt; output is then run through the formatter, so&lt;br&gt;
alignment and import grouping are not the renderer's problem at all.&lt;/p&gt;

&lt;p&gt;The reason is narrow: a template hides the structure of its output behind its own syntax, and here&lt;br&gt;
the output &lt;em&gt;is&lt;/em&gt; the thing under review. When a generated file looks wrong, I want to read the code&lt;br&gt;
that produced it as code.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fully generated versus a manual body
&lt;/h2&gt;

&lt;p&gt;Not everything can be generated, and pretending otherwise is how generators get abandoned. Each&lt;br&gt;
layer is split in advance into what comes out complete and what comes out as a signature:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Fully generated&lt;/th&gt;
&lt;th&gt;Manual body&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;domain handlers&lt;/td&gt;
&lt;td&gt;all 5 files&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;domain codecs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;codec.go&lt;/code&gt;, &lt;code&gt;mapping.go&lt;/code&gt;, &lt;code&gt;request.go&lt;/code&gt;, &lt;code&gt;request_patch.go&lt;/code&gt;, &lt;code&gt;suite.go&lt;/code&gt;, &lt;code&gt;base.go&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;payload.go&lt;/code&gt;, &lt;code&gt;filter.go&lt;/code&gt;, &lt;code&gt;errors.go&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;domain repository&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;calls.go&lt;/code&gt;, &lt;code&gt;repository.go&lt;/code&gt;, &lt;code&gt;revision.go&lt;/code&gt;, &lt;code&gt;deps.go&lt;/code&gt;, &lt;code&gt;methods/search.go&lt;/code&gt;, &lt;code&gt;methods/find_all_by_ids.go&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;spec.go&lt;/code&gt;, &lt;code&gt;methods/header.go&lt;/code&gt;, &lt;code&gt;methods/code_taken.go&lt;/code&gt;, &lt;code&gt;methods/slug_taken.go&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;domain manager&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;calls.go&lt;/code&gt;, &lt;code&gt;deps.go&lt;/code&gt;, &lt;code&gt;manager.go&lt;/code&gt;, &lt;code&gt;methods/{create,update,correct,archive}.go&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;spec.go&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;domain model&lt;/td&gt;
&lt;td&gt;9 files&lt;/td&gt;
&lt;td&gt;6 files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;domain wiring in the daemon&lt;/td&gt;
&lt;td&gt;all 4 files&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;domain test rig&lt;/td&gt;
&lt;td&gt;all 3 files&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;migrations&lt;/td&gt;
&lt;td&gt;all&lt;/td&gt;
&lt;td&gt;none&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%2Fzf89nucs8qs9feerhvxh.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%2Fzf89nucs8qs9feerhvxh.png" alt="Table of eight layers with the files that are generated in full on one side and the files created with a manual body on the other" width="800" height="588"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A "manual body" file is still generated once: correct &lt;code&gt;package&lt;/code&gt;, correct imports, correct&lt;br&gt;
signatures, bodies marked with a panic stub. On a second run without &lt;code&gt;-force&lt;/code&gt; it is not&lt;br&gt;
overwritten. The generator owns the shape, a person owns the decisions, and it never eats the&lt;br&gt;
second one.&lt;/p&gt;
&lt;h2&gt;
  
  
  Drift
&lt;/h2&gt;

&lt;p&gt;Every generated file will carry a &lt;code&gt;Code generated by … DO NOT EDIT.&lt;/code&gt; header. That header is a&lt;br&gt;
contract, not a courtesy. The comparison mode regenerates into memory, compares against what is in&lt;br&gt;
the repository, and exits non-zero on any difference. Editing a generated file by hand is&lt;br&gt;
forbidden; if the generated shape is wrong, the generator changes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;         ┌──────────────────────────────────────┐
         │   Code generated by … DO NOT EDIT.   │
         └──────────────────────────────────────┘

┌────────────────────────┐  =?  ┌────────────────────────┐
│ generated in memory    │      │ file in the repository │
└────────────────────────┘      └────────────────────────┘
                             │
              ┌──────────────┴──────────────┐
              ▼                             ▼
    ┌────────────────────┐       ┌─────────────────────────┐
    │   equal → exit 0   │       │ differs → non-zero exit │
    └────────────────────┘       └─────────────────────────┘
&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%2Feqvysiak2gyi6l2dvlzp.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%2Feqvysiak2gyi6l2dvlzp.png" alt="Generated-in-memory box compared against the file in the repository, branching into equal with exit zero and differs with a non-zero exit" width="800" height="358"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it will not touch
&lt;/h2&gt;

&lt;p&gt;The generic cores, and anything that exists once per service rather than once per domain -&lt;br&gt;
publishers, the cache-invalidation consumer. They are not a unit of generation, and a generator&lt;br&gt;
that tries to own singletons ends up owning the whole service.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order of work
&lt;/h2&gt;

&lt;p&gt;Twelve iterations: the YAML format first, then reading the proto descriptor, then the generator&lt;br&gt;
frame, then one iteration per layer, then the comparison mode last - there is nothing to compare&lt;br&gt;
until something is produced.&lt;/p&gt;

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

&lt;p&gt;A generator is another product: its own bugs, its own tests, its own review, and it has to keep up&lt;br&gt;
with every change to the layout it encodes - which means the layout stops being free to change&lt;br&gt;
casually.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;DO NOT EDIT&lt;/code&gt; header means a quick fix by hand is off the table. Every exception costs a new&lt;br&gt;
iteration of the generator, and the first time that happens under time pressure is when the&lt;br&gt;
decision gets tested for real.&lt;/p&gt;

&lt;p&gt;And the double input has to be kept in agreement by someone. Two sources of truth for one skeleton&lt;br&gt;
is a real cost; I took it because collapsing them would mean either inventing storage facts from&lt;br&gt;
the wire contract or duplicating the contract in YAML.&lt;/p&gt;

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

&lt;p&gt;Generate the things that are already decided; write by hand the things that are still decisions.&lt;br&gt;
The dividing line is not "how complex is this file" - it is "does this file contain a choice".&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Platform and generation - Part 6.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Next: controlled versus ideal - why I keep choosing the version I can predict over the version that&lt;br&gt;
is better on paper.&lt;/p&gt;




&lt;p&gt;That's my case and my price for it. If you do this better, if you've already been through it, or if&lt;br&gt;
you look at it differently - I'd like to hear how it's solved on your side, and what broke when you&lt;br&gt;
tried.&lt;/p&gt;

</description>
      <category>go</category>
      <category>codegen</category>
      <category>architecture</category>
      <category>backend</category>
    </item>
    <item>
      <title>Code Moves Both Ways</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Tue, 01 Sep 2026 14:11:57 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/code-moves-both-ways-1pc9</link>
      <guid>https://dev.to/anton_brilliantov/code-moves-both-ways-1pc9</guid>
      <description>&lt;p&gt;&lt;em&gt;Generic code is born in a service, lives in the platform, and comes back as a tag.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live&lt;br&gt;
PHP monolith into Go services. This part is about one small piece of that: how code that turns out&lt;br&gt;
to be generic gets from a single service into the shared platform library, and how it comes back.&lt;br&gt;
Maybe it's useful to you, maybe you already do it better, maybe you'd cut it differently. Notes:&lt;br&gt;
&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;As usual: this is what I'm doing on one codebase right now, with the reasons and the price - not a&lt;br&gt;
recommendation for yours.&lt;/p&gt;


&lt;h2&gt;
  
  
  The claim
&lt;/h2&gt;

&lt;p&gt;You cannot design generic code in the platform up front. Sitting in the platform, you can't see&lt;br&gt;
whether anyone needs it: there's no second caller, no second shape, nothing to generalise from. So&lt;br&gt;
the generic thing is born where the need is obvious - inside a service, solving one concrete&lt;br&gt;
problem - and only later moves out.&lt;/p&gt;

&lt;p&gt;The move back is the part that surprised me. A service dropping its local copy and importing the&lt;br&gt;
platform version is &lt;strong&gt;not the tail end of the move out&lt;/strong&gt;. It is separate work, and the reason is&lt;br&gt;
the tag: until the platform release exists, that work cannot start at all.&lt;/p&gt;
&lt;h2&gt;
  
  
  Phase A — prepared in the service
&lt;/h2&gt;

&lt;p&gt;Nothing leaves the service yet. The package stays where it is and keeps working; what changes is&lt;br&gt;
that it stops being local in every way except its address:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;not a single import of a domain package;&lt;/li&gt;
&lt;li&gt;the public API is fixed - the names it will keep after the move;&lt;/li&gt;
&lt;li&gt;tests moved into the concern's own subfolder;&lt;/li&gt;
&lt;li&gt;there is a context-cancellation test.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Phase A closes on a green run in the service repository. Nothing about the platform is touched, and&lt;br&gt;
if the move never happens, the service is still better off.&lt;/p&gt;
&lt;h2&gt;
  
  
  Phase B — moved to the platform
&lt;/h2&gt;

&lt;p&gt;The files move. The package name changes to the name of the target folder, and any leftover imports&lt;br&gt;
of the service are cleaned out. That's the whole of it - a relocation, not a redesign, because the&lt;br&gt;
design was already frozen in phase A.&lt;/p&gt;

&lt;p&gt;One condition on top: this happens &lt;strong&gt;only on the direct instruction of the platform owner&lt;/strong&gt;. The&lt;br&gt;
platform is not a place anyone drops code into because it looked reusable.&lt;/p&gt;
&lt;h2&gt;
  
  
  Phase C — the service switches to the tag
&lt;/h2&gt;

&lt;p&gt;The service now has two copies of the same thing - its own, and the one in the platform. Phase C&lt;br&gt;
removes that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the module file gets &lt;strong&gt;exactly the tag that was given&lt;/strong&gt; - not a nearby one, not a resolved one;&lt;/li&gt;
&lt;li&gt;pseudo-versions and &lt;code&gt;replace&lt;/code&gt; directives are forbidden;&lt;/li&gt;
&lt;li&gt;the local package is deleted;&lt;/li&gt;
&lt;li&gt;the imports are swapped.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the tag doesn't exist yet, the work doesn't begin. The answer is one line: waiting for the tag.&lt;br&gt;
Not "let me pin a pseudo-version so the build is green in the meantime" - that turns a blocked task&lt;br&gt;
into a silently wrong one, and the difference stops being visible in the diff.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why C is a separate set, not the tail of B
&lt;/h2&gt;

&lt;p&gt;Because C is executable only after the tag is cut, and tags are cut by one person.&lt;/p&gt;

&lt;p&gt;That's a different kind of dependency from "this iteration needs that iteration". A set that stops&lt;br&gt;
halfway and waits for someone else's action is in a bad state: half-applied, half-reviewable,&lt;br&gt;
occupying attention. A set that hasn't started because its precondition doesn't exist is in a fine&lt;br&gt;
state - it's just queued. Same waiting, very different cost, and the only way to get the second one&lt;br&gt;
is to draw the boundary exactly where the external dependency sits.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;◀── set 1 ────────────────────────────────▶ ╎ ◀── set 2 ─────────▶
┌──────────────────┐   ┌──────────────────┐ ╎ ┌──────────────────┐
│ A — prepared in  │   │ B — moved to the │ ╎ │ C — switches to  │
│     the service  │──▶│     platform     │ ╎ │     the tag      │
└──────────────────┘   └──────────────────┘ ╎ └──────────────────┘
 no domain imports      files relocate      ╎  exactly that tag
 public API frozen      package renamed     ╎  no pseudo-version
 tests in own folder    service imports out ╎  no replace
 cancellation test      owner's go-ahead    ╎  local copy deleted
 green run in repo                          ╎  imports swapped
                                            ╎
                                     waits for a tag
&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%2F0z35s8tyfkvmz0cdv6ir.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%2F0z35s8tyfkvmz0cdv6ir.png" alt="Three phases left to right - prepared in the service, moved to the platform, service switches to the tag - with a dashed boundary between the second and third marked " width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;Seven units. None of them were written to be generic; each one earned it by being needed twice.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;What moves&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;01&lt;/td&gt;
&lt;td&gt;task running&lt;/td&gt;
&lt;td&gt;serial and parallel &lt;code&gt;Runner[T]&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;02&lt;/td&gt;
&lt;td&gt;pagination&lt;/td&gt;
&lt;td&gt;keyset cursor + page-size clamp&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;03&lt;/td&gt;
&lt;td&gt;row reading&lt;/td&gt;
&lt;td&gt;generic reads of arbitrary projections + collectors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;04&lt;/td&gt;
&lt;td&gt;checks&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Rule[In]&lt;/code&gt; / &lt;code&gt;Guard[In,Out]&lt;/code&gt; / &lt;code&gt;Each[In]&lt;/code&gt; / &lt;code&gt;Nullable[T]&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;05&lt;/td&gt;
&lt;td&gt;metric ports&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Factory&lt;/code&gt; / &lt;code&gt;Gauge&lt;/code&gt; / &lt;code&gt;Counter&lt;/code&gt; / &lt;code&gt;Histogram&lt;/code&gt; / &lt;code&gt;Spec&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;06&lt;/td&gt;
&lt;td&gt;migration source merging&lt;/td&gt;
&lt;td&gt;several embedded filesystems into one source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;07&lt;/td&gt;
&lt;td&gt;publish-target selection&lt;/td&gt;
&lt;td&gt;exchange or queue, by the suffix of the name&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%2Fuvl3vod73y7p0n06i089.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%2Fuvl3vod73y7p0n06i089.png" alt="Table of the seven packages that move to the platform, numbered 01 to 07, each with a short description" width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read the list from the other end and it's a decent description of what a platform library is for:&lt;br&gt;
none of these are business decisions. Not one of them knows what the service does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Estimates, not a report
&lt;/h2&gt;

&lt;p&gt;To be explicit before the numbers: &lt;strong&gt;both sets - moving out, and switching over - are written and&lt;br&gt;
queued. Neither has been executed.&lt;/strong&gt; What follows is an estimate, not a measurement.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Set&lt;/th&gt;
&lt;th&gt;Estimated work&lt;/th&gt;
&lt;th&gt;Calendar with two executors&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;phase A, 7 units&lt;/td&gt;
&lt;td&gt;2 h 50 min (20–30 min per unit)&lt;/td&gt;
&lt;td&gt;≈1 h 30 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;phase B, 7 units&lt;/td&gt;
&lt;td&gt;1 h 25 min&lt;/td&gt;
&lt;td&gt;—&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%2Fr9dilpjbq02g6he7zj55.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%2Fr9dilpjbq02g6he7zj55.png" alt="Table of estimates - phase A two hours fifty minutes across seven units, phase B one hour twenty-five minutes, phase A calendar with two executors about one hour thirty minutes - marked " width="800" height="341"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Phase C has no estimate here, and that's the honest version: it can't be scheduled against a tag&lt;br&gt;
that hasn't been cut.&lt;/p&gt;

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

&lt;p&gt;Three things, and I'd rather name them than discover them later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The package lives in two places for a while.&lt;/strong&gt; Between B and C the service still runs its local&lt;br&gt;
copy while the platform carries the moved one. Any fix in that window has to be made with both in&lt;br&gt;
mind, or made twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The switch depends on someone else's release.&lt;/strong&gt; That's the whole point of the split, but it's&lt;br&gt;
still a cost: the finish line of the work isn't mine to cross.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Generic" is checked by hand.&lt;/strong&gt; The rule I use - it has to have been written twice before it&lt;br&gt;
counts - is a judgement call, not a test. Until a package has genuinely been needed by a second&lt;br&gt;
caller, moving it is a guess, and a guess in the platform is more expensive than a guess in a&lt;br&gt;
service.&lt;/p&gt;

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

&lt;p&gt;The platform gets only what has already proved useful in a service; everything else stays in the&lt;br&gt;
service until it has.&lt;/p&gt;




&lt;p&gt;That's my current setup and my current price for it. If you do this better, if you've already been&lt;br&gt;
through it, or if you look at it differently - I'd like to hear how it's solved on your side, and&lt;br&gt;
what broke when you did it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Platform and generation - Part 5.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Next: the skeleton generator - why the boring parts of a new service should be produced by code&lt;br&gt;
rather than written by hand each time.&lt;/p&gt;

</description>
      <category>go</category>
      <category>platform</category>
      <category>architecture</category>
      <category>backend</category>
    </item>
    <item>
      <title>One Generic Core Instead of Many Copies</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Mon, 31 Aug 2026 09:57:02 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/one-generic-core-instead-of-many-copies-2j3a</link>
      <guid>https://dev.to/anton_brilliantov/one-generic-core-instead-of-many-copies-2j3a</guid>
      <description>&lt;p&gt;&lt;em&gt;If only the type parameter differs, it is one generic — and the numbers show it.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 I'm Anton — a software engineer working mostly in PHP/Symfony and Go, currently carving a live&lt;br&gt;
PHP monolith into Go services. This part is about one rule I now apply before writing any new type,&lt;br&gt;
and what happened to the line counts when I applied it to a service that was already working.&lt;br&gt;
Notes: &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;. None of it is advice&lt;br&gt;
for your codebase — one service, one audit, one set of measurements: possibly useful, possibly wrong&lt;br&gt;
for your situation.&lt;/p&gt;


&lt;h2&gt;
  
  
  What the audit actually counted
&lt;/h2&gt;

&lt;p&gt;I audited one of the Go services with a single question: where does it carry code that already exists&lt;br&gt;
somewhere else in the same tree? The largest finding wasn't a bug — it was a count. &lt;strong&gt;44 copies of&lt;br&gt;
four row-reading shapes&lt;/strong&gt;, on 2026-08-13:&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;Copies&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;loop over many rows (&lt;code&gt;for rows.Next()&lt;/code&gt; → &lt;code&gt;rows.Err()&lt;/code&gt; → error wrapper)&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;read a single row&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;COUNT(*)&lt;/code&gt; counter&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;existence probe (one &lt;code&gt;rows.Next()&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;44&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Of those 44, &lt;strong&gt;12 differed only in the text of the error wrapper&lt;/strong&gt; — not in behaviour, not in the&lt;br&gt;
query. None of them was bad code: every one was correct, reviewed, tested and running. They were&lt;br&gt;
identical, and being identical is not something a reviewer flags, because each arrives alone, in its&lt;br&gt;
own change, next to its own domain. Lined up, they differ by the result type and the dependency.&lt;br&gt;
That's a generic, written by copy-paste.&lt;/p&gt;
&lt;h2&gt;
  
  
  The rule, in one line
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;If only the type parameter differs, it is one generic.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The corollary is what actually holds the line: &lt;strong&gt;a domain-local copy next to an existing generic is&lt;br&gt;
forbidden&lt;/strong&gt; — not discouraged, and enforced by tooling checks plus tests whose only job is to fail&lt;br&gt;
when the copy comes back. For the read core the closing criterion is mechanical, so a test can own&lt;br&gt;
it: &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 &lt;strong&gt;one&lt;/strong&gt; package&lt;br&gt;
in the tree. Either the grep is clean or the build is red.&lt;/p&gt;

&lt;p&gt;Why a test and not an agreement: the correct shape &lt;em&gt;was already in the tree&lt;/em&gt;. A &lt;code&gt;Reader[In, Out]&lt;/code&gt;&lt;br&gt;
existed, with the right fields and the right method — inside one domain package, where nobody outside&lt;br&gt;
that domain found it, so everyone outside it wrote their own. A generic living in a domain package&lt;br&gt;
is, for practical purposes, not a generic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Four shapes, one core

  loop over many rows    18 ──┐
  single-row read         6 ──┤     ┌───────────────────────────┐
  COUNT(*) counter        8 ──┼──▶  │ Reader[In, Out]           │
  existence probe        12 ──┘     │ executor                  │
                                    │ statement                 │
  44 copies · 12 differ only        │ scanner                   │
  in an error string                └───────────────────────────┘
&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%2Ftorcinfkdc27iz8ypkf0.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%2Ftorcinfkdc27iz8ypkf0.png" alt="Four boxes labelled 18, 6, 8 and 12, summing to 44, with four arrows converging into a single box labelled Reader of In and Out" width="800" height="379"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the core looks like
&lt;/h2&gt;

&lt;p&gt;The core is deliberately small. Three fields and one method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Reader&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;In&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Out&lt;/span&gt; &lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;executor&lt;/span&gt;  &lt;span class="n"&gt;Executor&lt;/span&gt;
    &lt;span class="n"&gt;statement&lt;/span&gt; &lt;span class="n"&gt;Statement&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;In&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;scanner&lt;/span&gt;   &lt;span class="n"&gt;Scanner&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Out&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="n"&gt;Reader&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;In&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Out&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;in&lt;/span&gt; &lt;span class="n"&gt;In&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Cursor&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Out&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;executor&lt;/code&gt; runs the query — a pool or a transaction-scoped handle. &lt;code&gt;statement&lt;/code&gt; turns typed input into&lt;br&gt;
SQL plus arguments. &lt;code&gt;scanner&lt;/code&gt; turns one row into one &lt;code&gt;Out&lt;/code&gt;. Around that sit the collectors: a set, a&lt;br&gt;
counter, an existence probe. Before, each domain owned its own loop instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// one of eighteen&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;entityRepository&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ListInput&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="n"&gt;Entity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;listEntitiesSQL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;in&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AccountID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;in&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Limit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"query entities: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="n"&gt;Entity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;in&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Limit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="n"&gt;Entity&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"scan entity: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;order&lt;/code&gt; version was the same function with &lt;code&gt;Order&lt;/code&gt; in place of &lt;code&gt;Entity&lt;/code&gt; and a different string in&lt;br&gt;
the error wrappers. After, both domains are two calls into one core:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;entities&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;reading&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;listEntities&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scanEntity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;reading&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;listOrders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scanOrder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;es&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;entities&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ListInput&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;AccountID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Limit&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;ords&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ListInput&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;AccountID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Limit&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each domain still owns what is genuinely its own — the statement and the scanner. What it no longer&lt;br&gt;
owns is the loop, the &lt;code&gt;defer&lt;/code&gt;, the &lt;code&gt;rows.Err()&lt;/code&gt; check and the error wrappers that were being retyped&lt;br&gt;
by hand every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cores that exist besides reading
&lt;/h2&gt;

&lt;p&gt;Reading is one row in a longer list. The current set in that service — twenty generic RPCs reads as&lt;br&gt;
a lot until you notice that adding an entity means naming its spec, not writing twenty handlers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Core&lt;/th&gt;
&lt;th&gt;What it gives&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;generic RPC (20 of them)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;archive&lt;/code&gt;, &lt;code&gt;attach&lt;/code&gt;, &lt;code&gt;close&lt;/code&gt;, &lt;code&gt;correct&lt;/code&gt;, &lt;code&gt;create&lt;/code&gt;, &lt;code&gt;create_batch&lt;/code&gt;, &lt;code&gt;detach&lt;/code&gt;, &lt;code&gt;find_all_by_ids&lt;/code&gt;, &lt;code&gt;get&lt;/code&gt;, &lt;code&gt;get_by_key&lt;/code&gt;, &lt;code&gt;list_revisions&lt;/code&gt;, &lt;code&gt;lookup&lt;/code&gt;, &lt;code&gt;lookup_pair&lt;/code&gt;, &lt;code&gt;move&lt;/code&gt;, &lt;code&gt;mutation&lt;/code&gt;, &lt;code&gt;save&lt;/code&gt;, &lt;code&gt;search&lt;/code&gt;, &lt;code&gt;set&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, &lt;code&gt;walk&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;codecs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Shape&lt;/code&gt;, &lt;code&gt;Selector&lt;/code&gt;, &lt;code&gt;NewUnary&lt;/code&gt;, &lt;code&gt;NewCollection&lt;/code&gt;, &lt;code&gt;NewPaging&lt;/code&gt;, &lt;code&gt;NewPairing&lt;/code&gt;, &lt;code&gt;Suite&lt;/code&gt;, &lt;code&gt;Spec&lt;/code&gt;, &lt;code&gt;BatchSuite&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;entity repository&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Base&lt;/code&gt;, &lt;code&gt;Spec[H, P, V]&lt;/code&gt;, &lt;code&gt;Calls&lt;/code&gt;, &lt;code&gt;ScopedCalls&lt;/code&gt;, &lt;code&gt;Search&lt;/code&gt;, &lt;code&gt;FindAllByIds&lt;/code&gt;, &lt;code&gt;Reader[In, Out]&lt;/code&gt;, cursors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;entity manager&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Base&lt;/code&gt;, &lt;code&gt;Spec&lt;/code&gt;, &lt;code&gt;Calls&lt;/code&gt;, &lt;code&gt;ScopedCalls&lt;/code&gt;, &lt;code&gt;Manager&lt;/code&gt;, &lt;code&gt;methods/&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;history&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Revisioned[T]&lt;/code&gt;: &lt;code&gt;Current&lt;/code&gt; / &lt;code&gt;AsOf&lt;/code&gt; / &lt;code&gt;KnownAsOf&lt;/code&gt; / &lt;code&gt;Append&lt;/code&gt; / &lt;code&gt;Correct&lt;/code&gt; / &lt;code&gt;Close&lt;/code&gt; — one shape for the whole service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;row reading&lt;/td&gt;
&lt;td&gt;set, counter, existence probe, &lt;code&gt;Reader&lt;/code&gt; with collectors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pagination&lt;/td&gt;
&lt;td&gt;keyset cursor over &lt;code&gt;int64&lt;/code&gt; plus a clamp on page size&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;domain assembly&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Builder[Repo, Mgr]&lt;/code&gt;, &lt;code&gt;Domain[Repo, Mgr]&lt;/code&gt;, &lt;code&gt;New&lt;/code&gt;, &lt;code&gt;Input&lt;/code&gt;, &lt;code&gt;ManagerInput[Repo]&lt;/code&gt;, &lt;code&gt;HandlerInput[Repo, Mgr]&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;task running&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Runner[T]&lt;/code&gt;: serial and parallel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;checks&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Rule[In]&lt;/code&gt;, &lt;code&gt;Guard[In, Out]&lt;/code&gt;, &lt;code&gt;Each[In]&lt;/code&gt;, &lt;code&gt;Nullable[T]&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ports&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Scoped[T]&lt;/code&gt;, &lt;code&gt;Self[T]&lt;/code&gt;, &lt;code&gt;Creator&lt;/code&gt;, &lt;code&gt;Updater&lt;/code&gt;, &lt;code&gt;Corrector&lt;/code&gt;, &lt;code&gt;Archiver&lt;/code&gt;, &lt;code&gt;Viewer&lt;/code&gt;, &lt;code&gt;Finder&lt;/code&gt;, &lt;code&gt;Locator&lt;/code&gt;, &lt;code&gt;Searcher&lt;/code&gt;, &lt;code&gt;Historian&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;test rig&lt;/td&gt;
&lt;td&gt;scoped &lt;code&gt;Rig&lt;/code&gt;, &lt;code&gt;Spec&lt;/code&gt;, &lt;code&gt;StubManager&lt;/code&gt;, &lt;code&gt;StubRepository&lt;/code&gt;, &lt;code&gt;Port&lt;/code&gt;, &lt;code&gt;NewItem&lt;/code&gt;, &lt;code&gt;NewPatch&lt;/code&gt;, &lt;code&gt;KeepData&lt;/code&gt;
&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%2Fg9tfq84csbqoe48pmjbo.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%2Fg9tfq84csbqoe48pmjbo.png" alt="Table of twelve generic cores with a short description of what each one provides" width="800" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;I measure line counts at every tag by walking the tag history, generated code excluded. The move&lt;br&gt;
onto generic cores lands on one tag, &lt;code&gt;v1.0.0&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;v0.4.0&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;v1.0.0&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;lines of code&lt;/td&gt;
&lt;td&gt;28095&lt;/td&gt;
&lt;td&gt;25097&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;lines of tests&lt;/td&gt;
&lt;td&gt;31952&lt;/td&gt;
&lt;td&gt;35368&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tests / code&lt;/td&gt;
&lt;td&gt;1.14&lt;/td&gt;
&lt;td&gt;1.41&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;code files · packages&lt;/td&gt;
&lt;td&gt;640 · 85&lt;/td&gt;
&lt;td&gt;683 · 94&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;average file&lt;/td&gt;
&lt;td&gt;43 lines&lt;/td&gt;
&lt;td&gt;36 lines&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%2Fbaf7yk28qxlhmjlnqfos.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%2Fbaf7yk28qxlhmjlnqfos.png" alt="Two panels comparing tag v0.4.0 with tag v1.0.0: code bars 28095 falling to 25097, test bars 31952 rising to 35368, and the ratio 1.14 rising to 1.41" width="800" height="367"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code went down while the service went up.&lt;/strong&gt; Three thousand lines left, and both the file count and&lt;br&gt;
the package count rose — no feature dropped, the same behaviour in fewer lines over more files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tests went up&lt;/strong&gt; — the part I'd have got wrong if I'd guessed. Collapsing 44 copies onto one core&lt;br&gt;
does not collapse 44 test files with them: the core earns a suite of its own, and each domain keeps&lt;br&gt;
the tests describing its statement and scanner. The ratio went 1.14 → 1.41, and across the whole tag&lt;br&gt;
series it has moved from 0.45 at the first tag to 1.25 at head, never below 1.14 after &lt;code&gt;v1.0.0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Average file size dropped to 36 lines&lt;/strong&gt;, and from there the service grows by files and packages&lt;br&gt;
rather than by files getting longer. The most recent release added 1392 lines of code, 1767 lines of&lt;br&gt;
tests and 20 packages — the read core plus the SQL catalogue engine, with their tests. Nothing here&lt;br&gt;
measures speed, latency or defect rate; I didn't measure those, so I'm not claiming them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Telling real commonality from a shape that merely rhymes
&lt;/h2&gt;

&lt;p&gt;The rule is easy to over-apply, and over-applying it is worse than the copies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real commonality:&lt;/strong&gt; the difference reduces to a type parameter and a dependency, and the behaviour&lt;br&gt;
matches literally — not "roughly", not "with one flag". Line the two up and the only thing your eye catches&lt;br&gt;
is the type name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accidental similarity:&lt;/strong&gt; two places share a shape today and diverge on the first new requirement.&lt;br&gt;
The tell is that you can already name the requirement that would split them — if you can, leave them&lt;br&gt;
alone, or you'll be adding a boolean to the core within a month, and a core with behaviour flags is&lt;br&gt;
worse than the two copies it replaced. The 44 gave an unusually clean signal here: &lt;strong&gt;12 differed only&lt;br&gt;
in error-wrapper text.&lt;/strong&gt; When the delta between two implementations is a string constant, no&lt;br&gt;
requirement is waiting to split them.&lt;/p&gt;

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

&lt;p&gt;None of this is free, and the costs are all paid in readability:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Signatures get longer and read worse.&lt;/strong&gt; &lt;code&gt;Reader[In, Out]&lt;/code&gt; with three typed dependencies is more
to take in than &lt;code&gt;func List(ctx, in) ([]Entity, error)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compiler errors on generics are verbose&lt;/strong&gt; — a mismatch three layers into a parameterised core
produces a message you sit and parse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tooling handles type parameters less well than concrete types&lt;/strong&gt;, and some of it degrades quietly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jump-to-definition lands in the core, not the domain.&lt;/strong&gt; You can no longer understand one behaviour
by reading one file — a real, permanent loss. The copies were worse in aggregate, better in isolation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'd take that trade again on this service. On a service with three read sites instead of forty-four,&lt;br&gt;
I wouldn't. That's my experience on one codebase and my price for it, and I'd like to hear the other&lt;br&gt;
side: if you do this better than I do, if you've been through it already, or if you look at it&lt;br&gt;
differently — how is it solved in your codebase, and what broke when you tried?&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Platform and generation — Part 4.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Next: what happens when the universal code stops living in the service — how it moves into the&lt;br&gt;
shared platform library and comes back as a tag.&lt;/p&gt;

</description>
      <category>go</category>
      <category>generics</category>
      <category>architecture</category>
      <category>refactoring</category>
    </item>
    <item>
      <title>Auditing Your Own Runtime</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Sun, 30 Aug 2026 05:20:05 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/auditing-your-own-runtime-l57</link>
      <guid>https://dev.to/anton_brilliantov/auditing-your-own-runtime-l57</guid>
      <description>&lt;p&gt;&lt;em&gt;Ten findings from one question: where does this service still ship its own runtime.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live&lt;br&gt;
PHP monolith into Go services. Part 2 of this series was the list of what the shared platform&lt;br&gt;
library hands a service for free. This part is what happened when I turned that list into a single&lt;br&gt;
question and walked the tree with it. Notes:&lt;br&gt;
&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;Some of this may be useful to you. Some of it you may cut differently, and I'd be glad to hear how.&lt;br&gt;
These are findings from one codebase, not advice for yours.&lt;/p&gt;


&lt;h2&gt;
  
  
  The question
&lt;/h2&gt;

&lt;p&gt;I did not go looking for bad code. I asked one question of the whole tree - 2733 Go files across&lt;br&gt;
252 packages - and wrote down every place that answered yes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Where does this service still carry code that the platform already provides?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No "good" or "bad" attached to any answer. Just a list. Ten entries came back, plus one that turned&lt;br&gt;
out to be big enough to get its own section.&lt;/p&gt;

&lt;p&gt;Two of the ten were not about style. They were about behaviour that was already wrong in&lt;br&gt;
production, in a service whose tests were green.&lt;/p&gt;
&lt;h2&gt;
  
  
  The two that were behaviour
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. A consumer that is written and never started.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The cache-invalidation consumer exists. It has a package, it has logic, it has unit tests, and&lt;br&gt;
those tests pass. What it does not have is a subscription registered in any daemon. Nothing ever&lt;br&gt;
starts it. In production, invalidation does not run at all.&lt;/p&gt;

&lt;p&gt;Nothing caught this. The package compiles - that's what a compiler checks. The unit tests exercise&lt;br&gt;
its logic in isolation - that's what a unit test checks. Neither of them asks whether the component&lt;br&gt;
is wired into a running process.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  DAEMONS
  ┌────────────────────────┐     ┌────────────────────────┐
  │ server                 │     │ worker                 │
  │ grpc handlers          │     │ relay · retention      │
  └────────────────────────┘     └────────────────────────┘


              ┌──────────────────────────────────────┐
              │ cache invalidation consumer          │
              └──────────────────────────────────────┘
              compiles · unit tests green · never registered
&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%2Fgd9ic1lc0b7pg9351v04.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%2Fgd9ic1lc0b7pg9351v04.png" alt="Two daemon boxes, server and worker, with a separate consumer box below them and no arrow connecting it to either daemon" width="800" height="334"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Publishing with empty headers.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The service publishes messages without setting headers. The consuming side picks its codec from a&lt;br&gt;
header, so with no header there is nothing to dispatch on: the platform's typed receive path is&lt;br&gt;
physically inapplicable. The consumer is left calling JSON decoding by hand, which is exactly the&lt;br&gt;
hand-rolled runtime the audit was looking for - except here it wasn't a preference, it was forced&lt;br&gt;
by the publisher.&lt;/p&gt;

&lt;p&gt;What these two share: the code exists, the tests are green, the behaviour is absent. Compilation&lt;br&gt;
and unit tests do not check that a component is connected to anything.&lt;/p&gt;
&lt;h2&gt;
  
  
  The other eight
&lt;/h2&gt;

&lt;p&gt;The rest of the list, one line each, no verdicts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The subscription sets no redelivery limit, no backoff and no dead-letter queue; a decode error is
returned as an ordinary error, so the driver redelivers it - a poison loop.&lt;/li&gt;
&lt;li&gt;Message deduplication is a hand-written ring of 1024 ids, instead of the platform cache with TTL
and metrics.&lt;/li&gt;
&lt;li&gt;Three hand-written caches, each a &lt;code&gt;map&lt;/code&gt; plus an &lt;code&gt;RWMutex&lt;/code&gt;: no TTL, no capacity bound, no metrics.&lt;/li&gt;
&lt;li&gt;Bulk insert issues one &lt;code&gt;INSERT&lt;/code&gt; per row instead of a multi-row &lt;code&gt;VALUES&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Two different loggers, in two packages of the same service.&lt;/li&gt;
&lt;li&gt;One repository runs its own transaction loop: &lt;code&gt;Begin&lt;/code&gt; by hand, &lt;code&gt;pgx.Tx&lt;/code&gt; held as a field, around
the registry rather than through it.&lt;/li&gt;
&lt;li&gt;The retention schedule is a constant in the code instead of a value from configuration.&lt;/li&gt;
&lt;li&gt;The scheduler initialises lazily through &lt;code&gt;sync.Once&lt;/code&gt; over captured variables, instead of the
platform's init-state store.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these has a platform package that does the same job with metrics attached. None of&lt;br&gt;
them was written out of disagreement with that package. They were written because writing them was&lt;br&gt;
the shortest path at the moment, and nothing later asked the question.&lt;/p&gt;
&lt;h2&gt;
  
  
  Forty-four copies of four shapes
&lt;/h2&gt;

&lt;p&gt;Then there was the finding that didn't fit on one line. Counted on 2026-08-13, the tree contained&lt;br&gt;
four shapes of "read rows from the database", copied by hand:&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;Copies&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;loop over many rows (&lt;code&gt;for rows.Next()&lt;/code&gt; → &lt;code&gt;rows.Err()&lt;/code&gt; → wrap the error)&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fetch a single row&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;COUNT(*)&lt;/code&gt; counter&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;existence probe (a single &lt;code&gt;rows.Next()&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;44&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Of those 44, twelve differ from one another &lt;strong&gt;only&lt;/strong&gt; in the text of the error wrapper.&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%2Fxitksrdksy372jzieuvi.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%2Fxitksrdksy372jzieuvi.png" alt="Table of four row-reading shapes with copy counts eighteen, six, eight and twelve, totalling forty-four" width="800" height="405"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this is a generic, written by copy-paste
&lt;/h2&gt;

&lt;p&gt;Look at what actually varies between the copies. The result type varies. The wrapper text varies.&lt;br&gt;
That is it.&lt;/p&gt;

&lt;p&gt;A varying result type is a type parameter. A varying error wrapper is a dependency. Forty-four&lt;br&gt;
copies that differ only in a type parameter and a dependency are not forty-four pieces of code -&lt;br&gt;
they are one generic, typed out forty-four times.&lt;/p&gt;

&lt;p&gt;The uncomfortable part: the correct shape was already in the tree. &lt;code&gt;Reader[In, Out]&lt;/code&gt;, with fields&lt;br&gt;
&lt;code&gt;executor&lt;/code&gt;, &lt;code&gt;statement&lt;/code&gt; and &lt;code&gt;scanner&lt;/code&gt;, and a single method &lt;code&gt;Query(ctx, in)&lt;/code&gt;. It existed, it worked,&lt;br&gt;
and it was sitting inside one domain package - where nobody writing the forty-fifth copy would go&lt;br&gt;
looking for it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  many rows loop     18 ──┐
  single row          6 ──┤     ┌──────────────────────────┐
  count               8 ──┼──▶  │ Reader[In, Out]          │
  existence probe    12 ──┘     │ executor                 │
                                │ statement                │
  44 copies in the tree         │ scanner                  │
  12 differ only in the         │ Query(ctx, in)           │
  error wrapper text            └──────────────────────────┘
&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%2F0qsjrytyvni8qmcuc7yi.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%2F0qsjrytyvni8qmcuc7yi.png" alt="Four boxes labelled with counts eighteen, six, eight and twelve converging into a single box named Reader with fields executor, statement, scanner and method Query" width="800" height="358"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A generic that lives inside a domain package is invisible. Being written is not the same as being&lt;br&gt;
reachable.&lt;/p&gt;
&lt;h2&gt;
  
  
  The closing criterion
&lt;/h2&gt;

&lt;p&gt;"Rewrite it properly" is not a criterion. It has no state in which it is finished, so it never is.&lt;/p&gt;

&lt;p&gt;The criterion I used instead is a grep with a fixed answer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&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 &lt;strong&gt;exactly one package&lt;/strong&gt;&lt;br&gt;
in the tree.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is checkable by anyone, at any moment, without knowing the history. And it is not checked by&lt;br&gt;
hand: the work is closed by a forbidding test that stays in the repository and fails the moment a&lt;br&gt;
forty-fifth copy appears anywhere else.&lt;/p&gt;
&lt;h2&gt;
  
  
  The rule this leaves me with
&lt;/h2&gt;

&lt;p&gt;There is a ladder, and only the top rung holds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reminder  → lives for one session
rule      → works while someone is reading it
check     → works whether or not anyone remembers it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A reminder in a chat thread is gone when the thread is. A rule in a style document works as long as&lt;br&gt;
people read the document, which is a decaying function. What actually holds is a check: a linter, a&lt;br&gt;
forbidding test, a structure test, a hook that blocks the write.&lt;/p&gt;

&lt;p&gt;Which turns the audit itself into a much smaller claim than it looks. An audit without a forbidding&lt;br&gt;
test at the end is a one-off tidy-up. The copies come back, because the conditions that produced&lt;br&gt;
them - a shortest path, no question asked - are unchanged.&lt;/p&gt;

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

&lt;p&gt;Two things, and neither is small.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The audit is manual.&lt;/strong&gt; There is no tool that finds "runtime this service should not own", because&lt;br&gt;
hand-rolled runtime does not look like a defect. It looks like ordinary working code: readable,&lt;br&gt;
tested, doing its job. The signal isn't in any one file - it's that the same shape appears in&lt;br&gt;
places that never talked to each other. Finding that means walking the tree with the question in&lt;br&gt;
your head.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forbidding tests add noise.&lt;/strong&gt; Every one of them is a test that fails for reasons unrelated to&lt;br&gt;
what the code does, and sometimes it catches a legitimate exception - a place where the pattern&lt;br&gt;
genuinely belongs. Then you either widen the test or argue with it, and both cost time. I still&lt;br&gt;
keep them, because a rule that nobody enforces costs more, later, and quietly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;That is what one question found in one service tree, and what I chose to pay to keep the answer&lt;br&gt;
from drifting back.&lt;/p&gt;

&lt;p&gt;If you do this better than I do - a check that catches it earlier, a cheaper way to spot the&lt;br&gt;
copies - I'd like to hear it. If you've been through this and it went differently, that's the more&lt;br&gt;
interesting story. And if you look at it differently and think the forbidding tests are the wrong&lt;br&gt;
trade, say so.&lt;/p&gt;

&lt;p&gt;How is this solved on your side, and what broke while you were solving it?&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Platform and generation - Part 3.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Next: the read core that replaced all forty-four copies - one generic instead of four shapes, and&lt;br&gt;
what the conversion actually took.&lt;/p&gt;

</description>
      <category>go</category>
      <category>architecture</category>
      <category>refactoring</category>
      <category>backend</category>
    </item>
    <item>
      <title>The Platform Owns the Runtime</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Thu, 27 Aug 2026 09:46:42 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/the-platform-owns-the-runtime-4lld</link>
      <guid>https://dev.to/anton_brilliantov/the-platform-owns-the-runtime-4lld</guid>
      <description>&lt;p&gt;&lt;em&gt;The service writes input, processing and output - everything else is an import.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This part is about one rule I now apply to every new service: where the line runs between what the shared platform gives me and what the service is allowed to write. Maybe it's useful to you; maybe you draw that line somewhere else. Notes: &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;As always: this is what I'm doing right now on one codebase, with the reasons and the price - not advice for yours.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the line actually runs
&lt;/h2&gt;

&lt;p&gt;For a long time I drew the line in the wrong place: our code on one side, third-party libraries on&lt;br&gt;
the other. That split explains nothing, because "our code" ends up containing a logger, a cache, a&lt;br&gt;
retry loop and a migration runner - none of which have anything to do with what the service is&lt;br&gt;
for.&lt;/p&gt;

&lt;p&gt;The line that holds is between &lt;strong&gt;runtime&lt;/strong&gt; and &lt;strong&gt;domain&lt;/strong&gt;. Process startup, connections,&lt;br&gt;
transport, retries, observability - that's runtime, and it belongs to the platform. The service&lt;br&gt;
writes three things: &lt;strong&gt;input&lt;/strong&gt; (the contract, the codecs, validation), &lt;strong&gt;processing&lt;/strong&gt; (domain&lt;br&gt;
rules and invariants), and &lt;strong&gt;output&lt;/strong&gt; (the storage model and domain events). Everything else is an&lt;br&gt;
import.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the platform hands over
&lt;/h2&gt;

&lt;p&gt;Fourteen things arrive with the platform dependency, before a line of domain code exists:&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;Where it lives&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;application startup, graceful shutdown, &lt;code&gt;/health&lt;/code&gt;, &lt;code&gt;/ready&lt;/code&gt;, &lt;code&gt;/metrics&lt;/code&gt;, platform info&lt;/td&gt;
&lt;td&gt;the app package&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;migrations as a declared resource&lt;/td&gt;
&lt;td&gt;the migration resource&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;audit - written inside the same transaction, partitioned schema, build table&lt;/td&gt;
&lt;td&gt;the audit package&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the outbox runner for outgoing messages&lt;/td&gt;
&lt;td&gt;the outbox package&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;workers and their initialisation state&lt;/td&gt;
&lt;td&gt;the worker package and its init-state store&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the broker and its driver&lt;/td&gt;
&lt;td&gt;the messaging package plus the RabbitMQ driver&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;transactions - a registry, an executor taken from the context&lt;/td&gt;
&lt;td&gt;the transaction package&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;listing and pagination&lt;/td&gt;
&lt;td&gt;the listing package and its result type&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cache with LRU eviction, TTL and metrics&lt;/td&gt;
&lt;td&gt;the cache package&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bulk insert - multi-row &lt;code&gt;VALUES&lt;/code&gt;, chunked to fit 65535 bind parameters&lt;/td&gt;
&lt;td&gt;the statement preparer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;typed message consumption&lt;/td&gt;
&lt;td&gt;typed dispatch plus a serializer registry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Snowflake identifiers&lt;/td&gt;
&lt;td&gt;the id package&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;test infrastructure - one container per run, a fresh database&lt;/td&gt;
&lt;td&gt;the test-infra package&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;metric registry, tracing, connection-pool instrumentation&lt;/td&gt;
&lt;td&gt;the observability packages&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two more things come from the same dependency without being packages the service imports: the&lt;br&gt;
environment-variable and metric snapshots, taken by standalone snapshot binaries, and the build&lt;br&gt;
version and commit, injected into platform symbols by the linker.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;            ┌───────────────────────────────────────────────┐
   SERVICE  │   input   →   processing   →   output         │
            └───────────────────────────────────────────────┘
                              │
                              │  everything below is an import
                              ▼
            ┌───────────────────────────────────────────────┐
  PLATFORM  │  startup · shutdown · health · ready · info   │
            │  migrations · audit · outbox · workers        │
            │  messaging + driver · transactions · cache    │
            │  listing · bulk insert · ids · test infra     │
            │  metrics · tracing · pool instrumentation     │
            └───────────────────────────────────────────────┘
&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%2Fiv8skbhgulutjjcllx70.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%2Fiv8skbhgulutjjcllx70.png" alt="Two horizontal layers: a narrow service layer holding input, processing and output over a wide platform layer holding the runtime concerns, with a single line between them" width="800" height="358"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;None of that is a suggestion the service may take or leave. It's what a service is made of before&lt;br&gt;
it has a domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three of them, spelled out
&lt;/h2&gt;

&lt;p&gt;The table above reads like a list of conveniences. Three entries aren't conveniences, and they're&lt;br&gt;
the ones that convinced me the line is in the right place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bulk insert, chunked to fit 65535 bind parameters.&lt;/strong&gt; A multi-row &lt;code&gt;INSERT ... VALUES&lt;/code&gt; is faster&lt;br&gt;
than a loop of single-row inserts, and everyone knows that much. What everyone doesn't carry in&lt;br&gt;
their head is that the wire protocol caps a statement at 65535 bind parameters, so the chunk size&lt;br&gt;
isn't a tuning knob — it's &lt;code&gt;65535 / columns&lt;/code&gt;, recomputed per table. Hand-rolled bulk insert is&lt;br&gt;
therefore correct on the table it was written for and quietly broken on a wider one, at a row&lt;br&gt;
count nobody tests. That's not a performance detail; it's a correctness detail wearing a&lt;br&gt;
performance costume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transactions as a registry plus an executor taken from the context.&lt;/strong&gt; The consequence is a&lt;br&gt;
shape, not a feature: a transaction never appears in a function signature. No &lt;code&gt;tx pgx.Tx&lt;/code&gt;&lt;br&gt;
threaded through five call layers, no repository method that only works if the caller remembered&lt;br&gt;
to begin something first. The executor is a field, scoped when the transaction is opened, and the&lt;br&gt;
code that reads and writes doesn't know whether it's inside one. Hand-rolling that is easy;&lt;br&gt;
hand-rolling it &lt;em&gt;consistently across a whole service&lt;/em&gt; is where it fails, and the audit of my own&lt;br&gt;
service found exactly one repository that had gone its own way with a manual &lt;code&gt;Begin&lt;/code&gt; and a&lt;br&gt;
transaction held as a field, bypassing the registry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Typed message consumption with a serializer registry.&lt;/strong&gt; The consumer says which type it wants&lt;br&gt;
and gets it; the codec is chosen from a message header. The property that matters is what happens&lt;br&gt;
when the header is missing — the mechanism doesn't degrade gracefully, it simply cannot be used,&lt;br&gt;
and the consumer falls back to parsing JSON by hand. So a publisher that ships empty headers&lt;br&gt;
doesn't break loudly on its own side; it disables a platform capability on the far side of the&lt;br&gt;
broker, in someone else's code. That's the kind of coupling that only becomes visible when both&lt;br&gt;
ends are owned by the same platform contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you don't write by hand
&lt;/h2&gt;

&lt;p&gt;There is a list, and it's short enough to remember:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;cache&lt;/li&gt;
&lt;li&gt;retries&lt;/li&gt;
&lt;li&gt;typed message consumption&lt;/li&gt;
&lt;li&gt;the outbox&lt;/li&gt;
&lt;li&gt;configuration&lt;/li&gt;
&lt;li&gt;the logger&lt;/li&gt;
&lt;li&gt;metrics&lt;/li&gt;
&lt;li&gt;migrations&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%2Fiqpdur7sh79xuqr46i6n.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%2Fiqpdur7sh79xuqr46i6n.png" alt="List titled " width="800" height="558"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The rule sits in every control prompt I write, in those words: &lt;strong&gt;a hand-rolled equivalent of a&lt;br&gt;
platform layer is forbidden; business code, the minimum.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Each line of that list has a specific thing it loses when it's written by hand, and the loss is&lt;br&gt;
never the happy path:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cache&lt;/strong&gt; — a map behind a mutex has no TTL, no capacity bound and no hit-rate metric. It works
until the working set grows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retries&lt;/strong&gt; — a hand-written loop has no backoff and no redelivery limit, so a message that
can't be decoded is retried forever. A decode error returned as an ordinary error is a poison
loop with a friendly name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Typed consumption&lt;/strong&gt; — replaced by hand-rolled JSON parsing at every consumer, which means the
message shape is asserted in as many places as there are consumers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The outbox&lt;/strong&gt; — a hand-rolled version usually publishes and deletes without a durable record
in the same commit, which is the failure mode the outbox exists to prevent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configuration&lt;/strong&gt; — values become constants. The audit found a retention schedule compiled into
the binary, which makes changing it a deploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logger&lt;/strong&gt; — two different loggers in two packages of the same service, so half the events are
shaped one way and half another, and neither half is complete.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metrics&lt;/strong&gt; — no registry means no consistent names, and no consistent names means no dashboard
that survives a rename.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migrations&lt;/strong&gt; — as a declared resource they run in a known order at a known moment; by hand,
they run wherever someone remembered to call them.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;written by hand      what is lost
───────────────────  ──────────────────────────────────────────
cache                no TTL, no capacity, no hit rate
retries              no backoff, no redelivery limit
typed consumption    the shape asserted at every consumer
outbox               publish and delete outside one commit
configuration        the value becomes a constant
logger               two shapes of event in one service
metrics              no registry, so no name survives a rename
migrations           they run wherever someone called them
&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%2Fa01pczuwmjcpuw0t3c74.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%2Fa01pczuwmjcpuw0t3c74.png" alt="Table of eight layers written by hand and what each one loses: cache without TTL capacity or hit rate, retries without backoff or redelivery limit, typed consumption replaced by per-consumer parsing, an outbox that publishes outside one commit, configuration frozen into a constant, two loggers in one service, metrics with no registry, and migrations that run wherever they were called" width="799" height="536"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's a ban and not a recommendation
&lt;/h2&gt;

&lt;p&gt;The interesting part is that a hand-rolled layer is usually not &lt;em&gt;worse at its job&lt;/em&gt;. A map behind a&lt;br&gt;
mutex caches things. A hand-written loop retries. Functionally you can't tell.&lt;/p&gt;

&lt;p&gt;It's worse at two other things: observability, and the number of places one fix has to be applied.&lt;/p&gt;

&lt;p&gt;The audit of my own service found &lt;strong&gt;three&lt;/strong&gt; hand-rolled caches - a map plus a read-write mutex, no&lt;br&gt;
TTL, no capacity bound, no metrics. Three places to change when eviction policy changes, and zero&lt;br&gt;
answers to "what is the hit rate right now". The platform cache gives TTL, capacity and metrics on&lt;br&gt;
the first line of use. Nobody would have argued that the hand-rolled ones were better - they were&lt;br&gt;
written because writing one was faster than looking one up, and each was written by someone who&lt;br&gt;
didn't know the other two existed.&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%2Fn1xsqoi8lbpi064jilr0.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%2Fn1xsqoi8lbpi064jilr0.png" alt="Left: three boxes labelled " width="799" height="296"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You find out on production, which is the worst possible place to find out that a cache has no&lt;br&gt;
upper bound.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the line isn't obvious
&lt;/h2&gt;

&lt;p&gt;It's not a clean cut, and pretending otherwise would be dishonest.&lt;/p&gt;

&lt;p&gt;Input, processing and output all lean on platform primitives themselves. Writing goes through the&lt;br&gt;
transaction registry. Listing uses the platform's pagination. Every identifier comes from the&lt;br&gt;
platform's id package. So the service isn't "platform-free" on its own side of the line either.&lt;/p&gt;

&lt;p&gt;What stays in the service is the &lt;em&gt;decision&lt;/em&gt;: what counts as a domain invariant, what a valid&lt;br&gt;
transition is, which events matter to anyone else. The platform doesn't know the subject area, and&lt;br&gt;
it shouldn't. The moment a platform package starts knowing what a valid order looks like, it stops&lt;br&gt;
being a platform.&lt;/p&gt;

&lt;p&gt;There's a second blurry edge, and it's the one that actually costs time: &lt;strong&gt;test infrastructure&lt;/strong&gt;.&lt;br&gt;
It looks like service code — it's in the service's repository, it knows the service's schema — but&lt;br&gt;
the expensive part of it is generic. One container for the whole run rather than one per package,&lt;br&gt;
a fresh database per test rather than a fresh container, explicit teardown rather than trust in a&lt;br&gt;
reaper. Every one of those is a decision nobody wants to re-litigate per service, and every one of&lt;br&gt;
them is invisible until a laptop is on its knees running the suite. So it lives on the platform&lt;br&gt;
side, and what the service supplies is the fixtures.&lt;/p&gt;

&lt;p&gt;The way I decide, when it isn't obvious: ask whether the next service would need the same thing&lt;br&gt;
written again. If yes, it's runtime, even if it currently sits in a file that looks domain-shaped.&lt;/p&gt;

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

&lt;p&gt;Three prices, all of them real:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You depend on someone else's release cycle.&lt;/strong&gt; If the primitive you need isn't in the platform&lt;br&gt;
yet, the work stops until the tag exists. Not "we'll pin a commit and clean it up later" - the&lt;br&gt;
service carries exactly the tag it was given, so the wait is a real wait.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The platform dictates the shape.&lt;/strong&gt; Its interface decides how your code is arranged. Bending to&lt;br&gt;
it is almost always cheaper than arguing with it, and "almost always" is doing some work in that&lt;br&gt;
sentence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There's a second place to look during an incident.&lt;/strong&gt; The behaviour you're debugging may be in&lt;br&gt;
your service or in the layer under it, and the first minutes go into deciding which.&lt;/p&gt;

&lt;p&gt;I take those three over the alternative, which is a service that contains its own private&lt;br&gt;
half-observable copy of everything.&lt;/p&gt;

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

&lt;p&gt;The only code that should be left in the service is the code you couldn't reuse in the next&lt;br&gt;
service.&lt;/p&gt;




&lt;p&gt;That's my line and my price. If you do this better, if you've already been through it, or if you&lt;br&gt;
look at it differently - I'd like to hear how it's solved on your side, and what broke when you&lt;br&gt;
tried it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Platform and generation - Part 2.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Next: how to find the hand-rolled runtime still sitting in your own service - and what turned up&lt;br&gt;
when I ran that audit on mine.&lt;/p&gt;

</description>
      <category>go</category>
      <category>architecture</category>
      <category>platform</category>
      <category>backend</category>
    </item>
    <item>
      <title>Every Service Should Be Copy-Paste</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Wed, 26 Aug 2026 09:13:29 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/every-service-should-be-copy-paste-55d8</link>
      <guid>https://dev.to/anton_brilliantov/every-service-should-be-copy-paste-55d8</guid>
      <description>&lt;p&gt;&lt;em&gt;Sameness beats elegance when a machine has to reproduce the layout.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This is the first part of a series about the platform, the service template and what can be generated instead of typed. It starts with the least glamorous decision I've made: every service is laid out identically, on purpose. Notes: &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;Maybe it's useful to you. Maybe you look at this differently - that's a conversation I'd take.&lt;/p&gt;




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

&lt;p&gt;A service should read like a copy of the previous one. The value isn't that any single arrangement&lt;br&gt;
is beautiful; it's that a reader knows where the next line lives &lt;strong&gt;without opening the file&lt;/strong&gt;. Once&lt;br&gt;
that's true, the layout stops being taste and becomes a property you can check - and, later, one a&lt;br&gt;
program can reproduce. Variety has a price, and it isn't paid once: it's paid by every service&lt;br&gt;
after the first, and by every domain inside them.&lt;/p&gt;

&lt;p&gt;Everything below is from one service I keep the samples from, measured on 2026-08-16. Domains are&lt;br&gt;
renamed to neutral &lt;code&gt;entity&lt;/code&gt; / &lt;code&gt;order&lt;/code&gt;; the shapes and the numbers are real.&lt;/p&gt;
&lt;h2&gt;
  
  
  Layout is a rule, not taste
&lt;/h2&gt;

&lt;p&gt;Packages are laid out &lt;strong&gt;by component type first&lt;/strong&gt;, with the domain as a nested package. This is&lt;br&gt;
the actual tree, not a simplified excerpt of 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/entity           read only
internal/repository/entity/methods   one method = one file = one type with Execute
internal/manager/entity              write only, including find-or-create
internal/manager/entity/methods
internal/model/entity
internal/grpc/codec/entity           + /methods
internal/grpc/entity                 entity-first: one folder per entity…
internal/grpc/entity/create.go       …and one file per method inside it
internal/grpc/service/report         service-first: one folder per gRPC service…
internal/grpc/service/report/build.go   …and one file per method, same shape
internal/grpc/method                 20 generic RPCs - the method bodies live here
internal/daemon/server/entity        assembles the domain's Calls
internal/daemon/worker/...           relay, retention, derived, observe
internal/publisher/entity, internal/consumer/entity
internal/job/&amp;lt;name&amp;gt;, internal/recorder/&amp;lt;name&amp;gt;
internal/port                        cross-cutting ports, one interface per file
internal/testsupport/...             stands, no asserts
&amp;lt;package&amp;gt;/test/&amp;lt;concern&amp;gt;/            package &amp;lt;concern&amp;gt;test, black box only
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Substitute &lt;code&gt;order&lt;/code&gt; for &lt;code&gt;entity&lt;/code&gt; and that is the next entity - in this service and in the next one.&lt;br&gt;
The inverse (&lt;code&gt;internal/domain/&amp;lt;domain&amp;gt;/repository&lt;/code&gt;, &lt;code&gt;internal/domain/&amp;lt;domain&amp;gt;/manager&lt;/code&gt;, one package&lt;br&gt;
per business area holding every role) is not allowed &lt;strong&gt;in my codebase&lt;/strong&gt;. Both layouts are&lt;br&gt;
navigable; they differ in what stays the same when you open the next service. I put the&lt;br&gt;
domain-first model on the table properly a few paragraphs down, with what it wins as well as what&lt;br&gt;
it costs.&lt;/p&gt;

&lt;p&gt;Two caveats before the picture, because the tree above is not four clean rows. &lt;strong&gt;The transport&lt;br&gt;
layer has two shapes on purpose&lt;/strong&gt; - &lt;code&gt;grpc/&amp;lt;entity&amp;gt;/&amp;lt;method&amp;gt;.go&lt;/code&gt; for operations on one entity,&lt;br&gt;
&lt;code&gt;grpc/service/&amp;lt;service&amp;gt;/&amp;lt;method&amp;gt;.go&lt;/code&gt; for compositions that aren't about one entity - and both live&lt;br&gt;
in the same service. &lt;strong&gt;Some component types need a grouping level before the entity&lt;/strong&gt; (&lt;code&gt;grpc/codec&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;daemon/server&lt;/code&gt;, &lt;code&gt;daemon/worker&lt;/code&gt;), and one of them holds no entity at all: &lt;code&gt;grpc/method&lt;/code&gt; is where&lt;br&gt;
the 20 generic RPC bodies live. Type-first is the rule; these are the places the rule has more than&lt;br&gt;
two levels, and pretending otherwise would make the rule look tidier than it is.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  type first  (what I use)          domain first  (also works)
  ──────────────────────────────    ──────────────────────────────
  internal/                         internal/
  ├── repository/entity             └── domain/ordering/
  ├── manager/entity                    ├── model
  ├── model/entity                      ├── repository
  ├── grpc/entity/create.go             ├── manager
  ├── grpc/service/report/build.go      └── grpc/create.go
  └── grpc/method                   internal/domain/billing/…

  + one shape in every service      + one business area, one folder
  - one feature, four directories   - the shape moves service to 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%2F7ccya0j2tkelr85nriu9.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%2F7ccya0j2tkelr85nriu9.png" alt="Two directory trees side by side: on the left the type-first layout - repository, manager and model each holding an entity package, two transport shapes, and one transport type that holds no entity at all; on the right the domain-first alternative, where the top level is a business area such as ordering and the roles sit inside it, with a one-line trade-off under each" width="800" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The path is a chain, not two levels
&lt;/h3&gt;

&lt;p&gt;"Type first, domain nested" is the short version, and it undersells the rule. The path is a chain,&lt;br&gt;
and only its two ends are fixed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;internal/ → component type → (whatever grouping that type needs) → entity → the entity's own pieces
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;type comes first&lt;/strong&gt;, the &lt;strong&gt;entity comes last&lt;/strong&gt;, and how many segments sit between them is&lt;br&gt;
whatever that type needs. Some types need nothing in the middle. The transport types need one&lt;br&gt;
level, for the entity, the service or the resource they serve. And the entity folder is not a leaf&lt;br&gt;
either -&lt;br&gt;
it holds the entity's own pieces, which sit next to each other because they belong to the same&lt;br&gt;
thing:&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/entity/methods/search.go      read side: one method, one file
internal/manager/entity/methods/create.go         write side, the same shape
internal/manager/entity/processor/normalize.go    the entity's own processors
internal/manager/entity/mapper/row.go             and its own mappers, right beside them
internal/model/entity/patch.go
internal/grpc/entity/search.go                    entity-first: grpc → entity → method
internal/grpc/service/report/build.go             service-first: grpc → service → method
internal/http/entity/list.go                      http → resource → route
internal/http/entity/create.go                    handlers are not a special case
internal/publisher/entity, internal/consumer/entity
internal/daemon/server/entity                     the assembly of the same entity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things follow from that, and both are worth saying plainly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The list of component types is open.&lt;/strong&gt; It's plus or minus the set above - &lt;code&gt;repository&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;manager&lt;/code&gt;, &lt;code&gt;model&lt;/code&gt;, &lt;code&gt;grpc&lt;/code&gt;, &lt;code&gt;http&lt;/code&gt;, &lt;code&gt;publisher&lt;/code&gt;, &lt;code&gt;consumer&lt;/code&gt;, &lt;code&gt;daemon&lt;/code&gt;, &lt;code&gt;job&lt;/code&gt;, &lt;code&gt;recorder&lt;/code&gt;, &lt;code&gt;port&lt;/code&gt;&lt;br&gt;
happen to be the ones my services have. A service that genuinely has another kind of component&lt;br&gt;
adds it as a new top-level type rather than smuggling it into an existing one, and a service that&lt;br&gt;
has no HTTP surface simply has no &lt;code&gt;internal/http&lt;/code&gt;. Some types aren't per-entity at all (&lt;code&gt;job&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;recorder&lt;/code&gt;) and name their folder after the thing itself; that's fine - the chain says the last&lt;br&gt;
segment is the unit, not that the unit is always an entity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The invariant is the ends, not the depth.&lt;/strong&gt; Nothing here says "exactly two segments". It says&lt;br&gt;
the first segment answers &lt;em&gt;what kind of component this is&lt;/em&gt; and the last answers &lt;em&gt;what it is&lt;br&gt;
about&lt;/em&gt; - and everything in between exists because that particular type needed it. That's what&lt;br&gt;
makes the shape describable in one sentence and still able to hold a transport layer, a processor&lt;br&gt;
and a mapper without special cases.&lt;/p&gt;
&lt;h3&gt;
  
  
  On the gRPC side there are two nestings, and both are right
&lt;/h3&gt;

&lt;p&gt;The transport types are where the middle of the chain earns its keep - and it's the one place where&lt;br&gt;
I deliberately don't have a single answer. There are &lt;strong&gt;two&lt;/strong&gt; shapes. They exist for two different&lt;br&gt;
kinds of call, and they sit side by side in the same service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Entity-first&lt;/strong&gt; - one folder per entity, one file per method inside it - for the calls that are&lt;br&gt;
&lt;em&gt;about a thing&lt;/em&gt;: create it, read it, search it, update 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/grpc/entity/create.go
internal/grpc/entity/search.go
internal/grpc/order/create.go        the next entity, same two levels
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Service-first&lt;/strong&gt; - a folder named literally after the gRPC service, one file per method inside it -&lt;br&gt;
for the calls that are &lt;em&gt;about a composition&lt;/em&gt;: an operation that orchestrates several things and&lt;br&gt;
doesn't belong to any single entity.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;internal/grpc/service/report/build.go
internal/grpc/service/report/export.go
internal/grpc/service/transfer/run.go
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  entity-first                      service-first
  "I want to create an entity"      "I want that composite operation"
  ────────────────────────────      ────────────────────────────────
  internal/grpc/                    internal/grpc/service/
  └── entity/                       └── report/
      ├── create.go                     ├── build.go
      └── search.go                     └── export.go

  both shapes live in one service; the path says which one you are in
&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%2F3lmnwl04ghmbd46ah616.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%2F3lmnwl04ghmbd46ah616.png" alt="Two directory trees side by side under the heading two gRPC nestings: on the left entity-first, internal slash grpc slash entity with create and search files, reason I want to create an entity; on the right service-first, internal slash grpc slash service slash report with build and export files, reason I want that composite operation; a line underneath saying both live in one service" width="800" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What stays fixed across both is the pair of units underneath. A &lt;strong&gt;method&lt;/strong&gt; is the unit that changes:&lt;br&gt;
a change request is never "touch the handler package", it's "&lt;code&gt;Search&lt;/code&gt; now takes a cursor" - so the&lt;br&gt;
method gets its own file, and a diff that touches &lt;code&gt;Search&lt;/code&gt; touches exactly &lt;code&gt;search.go&lt;/code&gt;. A &lt;strong&gt;service&lt;/strong&gt;&lt;br&gt;
is the unit that shares a contract: the files in one folder are precisely the RPCs declared in one&lt;br&gt;
&lt;code&gt;.proto&lt;/code&gt; service block, so the folder listing and the contract can be read against each other line&lt;br&gt;
by line, and a method that exists in one and not the other is visible without a tool. Anything&lt;br&gt;
flatter - &lt;code&gt;grpc/handler/&amp;lt;domain&amp;gt;&lt;/code&gt; with the methods mixed inside - loses both properties in either&lt;br&gt;
shape: the file boundary no longer matches the change boundary, and the folder no longer matches the&lt;br&gt;
contract.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Both in one codebase is a fit, not a compromise.&lt;/strong&gt; The two shapes cover different kinds of&lt;br&gt;
operation, so a service that carries both isn't being inconsistent - it's being specific. The&lt;br&gt;
invariant that has to hold is the copy-paste one: whichever shape a given call belongs to, it looks&lt;br&gt;
the same in every service, and a reader can tell from the path alone which of the two they're in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The deciding criterion is human perception, not purity&lt;/strong&gt; - and this is my reading of it rather&lt;br&gt;
than a law. The split is binary in the reader's head: "I want to create an entity" is one thought,&lt;br&gt;
"I want that composite operation" is a different one, and I'd rather the tree match that split than&lt;br&gt;
force every call into one tree for the sake of a tidier rule. Picking a single shape and using it&lt;br&gt;
everywhere is defensible in both directions - all-entity-first keeps the tree flat and the domain&lt;br&gt;
obvious, all-service-first keeps the folder listing and the &lt;code&gt;.proto&lt;/code&gt; in exact correspondence&lt;br&gt;
everywhere. I find the two-shape version easier to navigate because the path answers a question I&lt;br&gt;
actually ask, but I wouldn't argue anyone out of the other two.&lt;/p&gt;

&lt;p&gt;The bodies aren't there, though. The RPC flow lives in one of the 20 generic methods, which has a&lt;br&gt;
single execution method, &lt;code&gt;Handle(ctx, req)&lt;/code&gt;. What either folder contains is assembly only: a&lt;br&gt;
type with a &lt;code&gt;deps&lt;/code&gt; field, a &lt;code&gt;NewXxx(deps)&lt;/code&gt; constructor, and a &lt;code&gt;Call()&lt;/code&gt; that returns the lambda the&lt;br&gt;
transport wires in. Five method files per service, and not one hand-written body among them.&lt;/p&gt;

&lt;p&gt;The practical consequence is that reviewing a new domain's transport layer is not reading logic.&lt;br&gt;
It's checking that five files were filled in with the right names - which is exactly the kind of&lt;br&gt;
work worth taking away from a person.&lt;/p&gt;
&lt;h3&gt;
  
  
  One method, one file, one type
&lt;/h3&gt;

&lt;p&gt;Under &lt;code&gt;repository/&amp;lt;domain&amp;gt;/methods&lt;/code&gt; and &lt;code&gt;manager/&amp;lt;domain&amp;gt;/methods&lt;/code&gt; the unit is not a function but a&lt;br&gt;
type: one method per file, one type per method, with an &lt;code&gt;Execute&lt;/code&gt; on it. So "where does this query&lt;br&gt;
live" has a mechanical answer - a path - rather than a search.&lt;/p&gt;
&lt;h3&gt;
  
  
  A concern is a folder, never a filename prefix
&lt;/h3&gt;

&lt;p&gt;This is the rule I'd keep if I had to drop all the others:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;internal/manager/entity/entity_create.go       ✗ concern as a filename prefix
internal/manager/entity/create_helpers.go      ✗ helpers file
internal/manager/entity/methods/create.go      ✓ concern as a folder
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the root of a package there are only contracts and &lt;code&gt;Base&lt;/code&gt;; the implementation of a concern lives&lt;br&gt;
in that concern's subfolder, and in &lt;code&gt;methods/&lt;/code&gt; there are &lt;strong&gt;only&lt;/strong&gt; methods. The moment a concern is&lt;br&gt;
allowed to be a filename prefix, the tree stops being predictable and becomes a naming convention&lt;br&gt;
people remember with varying accuracy.&lt;/p&gt;
&lt;h2&gt;
  
  
  The other model, and it's a real one
&lt;/h2&gt;

&lt;p&gt;Type-first is not the only layout that works, and the honest way to argue for it is to describe the&lt;br&gt;
main alternative the way someone who likes it would.&lt;/p&gt;

&lt;p&gt;First, a distinction that gets lost every time this argument is had: &lt;strong&gt;a domain is not an entity.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;order&lt;/code&gt; is an entity - a thing with a table, an id and a lifecycle. &lt;code&gt;ordering&lt;/code&gt; is a domain - a&lt;br&gt;
business area that owns &lt;code&gt;order&lt;/code&gt;, &lt;code&gt;order_line&lt;/code&gt; and &lt;code&gt;discount&lt;/code&gt;, plus the rules that only make sense&lt;br&gt;
across all three. Putting one entity at the top of the tree isn't domain-first; it's entity-first&lt;br&gt;
with the roles turned inside out, and it inherits the costs of both. Domain-first means the tree's&lt;br&gt;
top level is the &lt;em&gt;business area&lt;/em&gt;, and the entity is a level underneath 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/domain/ordering/                 the business area, not a table
internal/domain/ordering/model            order, order_line, discount
internal/domain/ordering/repository       reads for everything in the domain
internal/domain/ordering/manager          writes for everything in the domain
internal/domain/ordering/grpc/create.go   the domain's transport
internal/domain/billing/…                 the next area, same five rows
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That distinction is what makes the alternative worth taking seriously. A tree whose top level is&lt;br&gt;
&lt;code&gt;order&lt;/code&gt;, &lt;code&gt;order_line&lt;/code&gt;, &lt;code&gt;discount&lt;/code&gt; is just a flat list of tables with folders around them. A tree&lt;br&gt;
whose top level is &lt;code&gt;ordering&lt;/code&gt;, &lt;code&gt;billing&lt;/code&gt;, &lt;code&gt;catalog&lt;/code&gt; is a map of what the service is &lt;em&gt;for&lt;/em&gt;, and&lt;br&gt;
that's a real claim about readability, not a stylistic one.&lt;/p&gt;

&lt;p&gt;What that layout is genuinely good at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Everything about one domain sits in one place.&lt;/strong&gt; Adding a field is one directory, one listing,
one mental context. Nothing about &lt;code&gt;ordering&lt;/code&gt; is anywhere except under &lt;code&gt;ordering&lt;/code&gt; - including the
three entities it owns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A domain lifts out cleanly.&lt;/strong&gt; If &lt;code&gt;ordering&lt;/code&gt; later has to become its own service, the seam is
already cut - the move is close to a directory copy instead of an archaeology exercise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It matches the way people talk.&lt;/strong&gt; Nobody says "I'm in the manager layer today"; they say "I'm
on ordering". A tree that agrees with that sentence is easier to hold in your head, and easier to
explain to somebody on their first day - and it names areas of the business, not tables.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It scales down well.&lt;/strong&gt; In a service with two domains and a handful of files each, type-first is
ceremony wrapped around almost nothing, and domain-first is just the shorter path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It keeps a change local.&lt;/strong&gt; One feature usually lands inside one folder rather than across four.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What it costs, in the situation I'm actually in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The same file lands in a different place in each service.&lt;/strong&gt; Nothing in domain-first says where
the write path goes, so one service grows &lt;code&gt;internal/domain/ordering/manager&lt;/code&gt;, the next one
&lt;code&gt;internal/domain/ordering/service&lt;/code&gt;, the third puts the writes in the repository. Each is defensible on its
own; together they're a fleet with no shared map.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The shape stops being checkable.&lt;/strong&gt; With roles at the top I can state a rule a program can
verify - "&lt;code&gt;repository&lt;/code&gt; reads, &lt;code&gt;manager&lt;/code&gt; writes, &lt;code&gt;for rows.Next()&lt;/code&gt; appears in exactly one
package". With the domain at the top the same rule has to be restated per domain per service,
which in practice means it's restated nowhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A reader has to learn each service, not just the first.&lt;/strong&gt; The cost isn't paid once; it's paid
by every service after the first, and by every new person on every one of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There's nothing to generate against.&lt;/strong&gt; A generator places files by rule. If the rule is "the
way this service's author preferred", there's no rule to place by and no baseline to diff drift
against.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither column is a knockout. The choice comes down to which cost you actually pay: if you have one&lt;br&gt;
service and a small team, the top list is real and the bottom list is theoretical, and domain-first&lt;br&gt;
is the better answer. I have services that are supposed to be copies of each other and a generator&lt;br&gt;
that has to place files by rule, so for me the bottom list is the one that bites - and I pay the&lt;br&gt;
top one, including the part where following one feature means opening four directories.&lt;/p&gt;
&lt;h2&gt;
  
  
  This is my structure, not a rule for anyone else
&lt;/h2&gt;

&lt;p&gt;I want to be blunt about the status of all of this: it's the layout &lt;strong&gt;I&lt;/strong&gt; picked, for my services,&lt;br&gt;
and the reason is readability in one narrow sense - I can find the next line without opening the&lt;br&gt;
file. That's the whole justification. It isn't a standard, I'm not proposing it as one, and a tree&lt;br&gt;
that looks nothing like mine can be the better tree in your codebase.&lt;/p&gt;

&lt;p&gt;So lay your services out however you like. The property I'd actually argue for is the one&lt;br&gt;
underneath the shape rather than the shape itself: &lt;strong&gt;a service should be a copy of the previous&lt;br&gt;
one.&lt;/strong&gt; Choose type-first, choose domain-first, choose something I haven't thought of - as long as&lt;br&gt;
the second service is the first one with the names changed, you get the thing that matters here.&lt;br&gt;
The tree above is my answer; the sameness is the point.&lt;/p&gt;
&lt;h2&gt;
  
  
  Exactly two layers over the database
&lt;/h2&gt;

&lt;p&gt;There are &lt;strong&gt;two&lt;/strong&gt; layers that touch the database, and there is no third:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;repository&lt;/code&gt; - &lt;strong&gt;read only&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;manager&lt;/code&gt; - &lt;strong&gt;write only&lt;/strong&gt;, including find-or-create.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Types and packages named &lt;code&gt;store&lt;/code&gt;, &lt;code&gt;writer&lt;/code&gt;, &lt;code&gt;dao&lt;/code&gt; or &lt;code&gt;service&lt;/code&gt; for database access do not exist. If&lt;br&gt;
a &lt;code&gt;Store&lt;/code&gt; shows up in a diff, that's the error, not a naming preference - it gets split into a&lt;br&gt;
repository and a manager. When a manager needs to read, it calls the repository instead of&lt;br&gt;
duplicating the SQL, which is what keeps "where is this query" answerable at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ┌──────────────────────────┐                 ┌──────────────────────┐
  │ manager                  │  needs to read  │ repository           │
  │ write only               │ ──────────────▶ │ read only            │
  │ incl. find-or-create     │                 │                      │
  └──────────────────────────┘                 └──────────────────────┘

  there is no third layer:  store · writer · dao · 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%2Fxzqr20vw2qovp5fd7w2f.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%2Fxzqr20vw2qovp5fd7w2f.png" alt="Two boxes, repository read only and manager write only, with a one-way arrow from manager to repository labelled needs to read, calls it; beside them a struck-through list of store, writer, dao, service" width="799" height="289"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Transactions are a decorator, so the domain never knows
&lt;/h3&gt;

&lt;p&gt;Transactions sit outside that split: a decorator applied from the outside, only on mutations, with&lt;br&gt;
the executor taken from the context. &lt;code&gt;tx pgx.Tx&lt;/code&gt; in a signature is forbidden, and the domain code&lt;br&gt;
never learns it is running inside a transaction.&lt;/p&gt;

&lt;p&gt;One package in my own service didn't follow it: a repository running its own &lt;code&gt;Begin&lt;/code&gt; by hand with&lt;br&gt;
&lt;code&gt;pgx.Tx&lt;/code&gt; as a struct field, straight past the platform's transaction registry. Nothing broke and no&lt;br&gt;
test went red. It was just a different shape from everything around it - which is the exact failure&lt;br&gt;
mode the rule exists to make visible. In a codebase where every repository is allowed to look&lt;br&gt;
different, that one is invisible; in this one, it stands out in a directory listing.&lt;/p&gt;
&lt;h2&gt;
  
  
  The rest of the mechanical rules
&lt;/h2&gt;

&lt;p&gt;A layout rule that runs on good intentions decays. These are the constraints that keep it&lt;br&gt;
mechanical, and each one exists because its opposite produces variety:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;What it removes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;a file is at most &lt;strong&gt;100 lines&lt;/strong&gt;, a line at most &lt;strong&gt;80 characters&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;files that grow into their own little architectures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;no comments in &lt;code&gt;.go&lt;/code&gt; files&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;prose drifting away from the code it describes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;helpers.go&lt;/code&gt;, &lt;code&gt;utils.go&lt;/code&gt;, &lt;code&gt;common.go&lt;/code&gt;, &lt;code&gt;funcs.go&lt;/code&gt; are forbidden&lt;/td&gt;
&lt;td&gt;the junk drawer every service otherwise grows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;every operation is a &lt;strong&gt;method on a struct&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;package-level procedures nobody can locate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;all SQL goes through a query builder; raw SQL in Go is forbidden (only migration DDL and test fixtures are exempt)&lt;/td&gt;
&lt;td&gt;the same query hand-written twice, differently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;errors are matched with &lt;code&gt;errors.Is&lt;/code&gt; / &lt;code&gt;errors.As&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;direct comparisons that silently stop matching once a wrap is added&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;no &lt;code&gt;switch&lt;/code&gt; on types in shared code - polymorphism instead&lt;/td&gt;
&lt;td&gt;a routing table that grows a case per domain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;initialisms read as ordinary words (&lt;code&gt;HttpClient&lt;/code&gt;, &lt;code&gt;JsonBody&lt;/code&gt;); only &lt;code&gt;ID&lt;/code&gt; is uppercase&lt;/td&gt;
&lt;td&gt;two spellings of the same field name&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these are interesting on their own. Together they're the difference between a layout you&lt;br&gt;
can describe and a layout a program can reproduce.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two binaries, one image - and the layout mirrors it
&lt;/h2&gt;

&lt;p&gt;The service ships as one image with two binaries selected at build time&lt;br&gt;
(&lt;code&gt;--build-arg BINARY=&lt;/code&gt;): &lt;code&gt;server&lt;/code&gt;, which serves gRPC, and &lt;code&gt;worker&lt;/code&gt;, which relays outgoing events,&lt;br&gt;
runs background calculations and drops old audit partitions. The tree says so out loud:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;internal/daemon/server/entity      assembly of the domain's Calls
internal/daemon/worker/relay       outgoing events
internal/daemon/worker/retention   audit partitions
internal/daemon/worker/derived     background calculations
internal/daemon/worker/observe     gauges
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The count of binaries is a decision of the service owner and nothing else: new background work goes&lt;br&gt;
into an existing daemon, not into a new &lt;code&gt;cmd/*&lt;/code&gt;. That's another sameness rule that reads as&lt;br&gt;
bureaucracy until you picture the alternative - a fleet where every service has a different number&lt;br&gt;
of things to deploy.&lt;/p&gt;
&lt;h2&gt;
  
  
  Generic cores: why there's so little left to copy
&lt;/h2&gt;

&lt;p&gt;Sameness is what makes deduplication possible, and deduplication is what keeps the sameness cheap.&lt;br&gt;
Most behaviour lives in generic cores that every domain instantiates rather than reimplements.&lt;/p&gt;

&lt;p&gt;The RPC flow, for instance, is 20 generic methods - and that list &lt;em&gt;is&lt;/em&gt; the service's write and read&lt;br&gt;
surface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;archive   attach    close     correct   create
create_batch        detach    find_all_by_ids
get       get_by_key          list_revisions
lookup    lookup_pair         move      mutation
save      search    set       update    walk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Around them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Core&lt;/th&gt;
&lt;th&gt;What it gives every domain&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;codecs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Shape&lt;/code&gt;, &lt;code&gt;Selector&lt;/code&gt;, &lt;code&gt;NewUnary&lt;/code&gt;, &lt;code&gt;NewCollection&lt;/code&gt;, &lt;code&gt;NewPaging&lt;/code&gt;, &lt;code&gt;NewPairing&lt;/code&gt;, &lt;code&gt;Suite&lt;/code&gt;, &lt;code&gt;Spec&lt;/code&gt;, &lt;code&gt;BatchSuite&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;entity repository&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Base&lt;/code&gt;, &lt;code&gt;Spec[H, P, V]&lt;/code&gt;, &lt;code&gt;Calls&lt;/code&gt;, &lt;code&gt;ScopedCalls&lt;/code&gt;, &lt;code&gt;Search&lt;/code&gt;, &lt;code&gt;FindAllByIds&lt;/code&gt;, &lt;code&gt;Reader[In, Out]&lt;/code&gt;, cursors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;entity manager&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Base&lt;/code&gt;, &lt;code&gt;Spec&lt;/code&gt;, &lt;code&gt;Calls&lt;/code&gt;, &lt;code&gt;ScopedCalls&lt;/code&gt;, &lt;code&gt;Manager&lt;/code&gt;, &lt;code&gt;methods/&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;history&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Revisioned[T]&lt;/code&gt;: &lt;code&gt;Current&lt;/code&gt; / &lt;code&gt;AsOf&lt;/code&gt; / &lt;code&gt;KnownAsOf&lt;/code&gt; / &lt;code&gt;Append&lt;/code&gt; / &lt;code&gt;Correct&lt;/code&gt; / &lt;code&gt;Close&lt;/code&gt; - one form for the whole service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;row reading&lt;/td&gt;
&lt;td&gt;set, counter, existence probe, &lt;code&gt;Reader&lt;/code&gt; with collectors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pagination&lt;/td&gt;
&lt;td&gt;keyset cursor over &lt;code&gt;int64&lt;/code&gt; plus a page-size clamp&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;domain assembly&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Builder[Repo, Mgr]&lt;/code&gt;, &lt;code&gt;Domain[Repo, Mgr]&lt;/code&gt;, &lt;code&gt;New&lt;/code&gt;, &lt;code&gt;Input&lt;/code&gt;, &lt;code&gt;ManagerInput[Repo]&lt;/code&gt;, &lt;code&gt;HandlerInput[Repo, Mgr]&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;task runs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Runner[T]&lt;/code&gt;, serial and parallel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;validation&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Rule[In]&lt;/code&gt;, &lt;code&gt;Guard[In, Out]&lt;/code&gt;, &lt;code&gt;Each[In]&lt;/code&gt;, &lt;code&gt;Nullable[T]&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ports&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Scoped[T]&lt;/code&gt;, &lt;code&gt;Self[T]&lt;/code&gt;, &lt;code&gt;Creator&lt;/code&gt;, &lt;code&gt;Updater&lt;/code&gt;, &lt;code&gt;Corrector&lt;/code&gt;, &lt;code&gt;Archiver&lt;/code&gt;, &lt;code&gt;Viewer&lt;/code&gt;, &lt;code&gt;Finder&lt;/code&gt;, &lt;code&gt;Locator&lt;/code&gt;, &lt;code&gt;Searcher&lt;/code&gt;, &lt;code&gt;Historian&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;test rig&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;TenantRig&lt;/code&gt;, &lt;code&gt;Spec&lt;/code&gt;, &lt;code&gt;StubManager&lt;/code&gt;, &lt;code&gt;StubRepository&lt;/code&gt;, &lt;code&gt;Port&lt;/code&gt;, &lt;code&gt;NewItem&lt;/code&gt;, &lt;code&gt;NewPatch&lt;/code&gt;, &lt;code&gt;KeepData&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rule in one line: &lt;strong&gt;if two things differ only in a type parameter, they are one generic.&lt;/strong&gt; A&lt;br&gt;
domain-local copy sitting next to an existing generic is forbidden, and that ban is held by tooling&lt;br&gt;
checks and forbidding tests rather than by review etiquette.&lt;/p&gt;

&lt;h2&gt;
  
  
  What variety actually costs: 44 copies of four loops
&lt;/h2&gt;

&lt;p&gt;The best argument for sameness I have isn't the tree - it's an audit I ran over my own service on&lt;br&gt;
2026-08-13, hunting hand-written ways to read rows out of Postgres. It found &lt;strong&gt;44 copies of four&lt;br&gt;
forms&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Form&lt;/th&gt;
&lt;th&gt;Copies&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;many-row loop (&lt;code&gt;for rows.Next()&lt;/code&gt; → &lt;code&gt;rows.Err()&lt;/code&gt; → wrap the error)&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;single row&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;COUNT(*)&lt;/code&gt; counter&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;existence probe (one &lt;code&gt;rows.Next()&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;They differed in exactly two things: the result type and the wording of the error wrapper - and 12&lt;br&gt;
of them differed &lt;em&gt;only&lt;/em&gt; in the wording. A type parameter and a dependency: that is a generic, typed&lt;br&gt;
out by hand 44 times.&lt;/p&gt;

&lt;p&gt;The part that stings is that the correct form was &lt;strong&gt;already in the tree&lt;/strong&gt;: &lt;code&gt;Reader[In, Out]&lt;/code&gt;, with&lt;br&gt;
&lt;code&gt;executor&lt;/code&gt;, &lt;code&gt;statement&lt;/code&gt; and &lt;code&gt;scanner&lt;/code&gt; fields and a &lt;code&gt;Query(ctx, in)&lt;/code&gt; method. It was just sitting&lt;br&gt;
inside one domain's package instead of a shared one, so nobody reusing it was possible. Collapsing&lt;br&gt;
the 44 copies onto it took a set of 40 iterations, one file each, and the closing condition is a&lt;br&gt;
forbidding test rather than a promise: &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;br&gt;
&lt;code&gt;QueryRow(&lt;/code&gt; may appear in &lt;strong&gt;exactly one package&lt;/strong&gt; in the whole tree.&lt;/p&gt;

&lt;h3&gt;
  
  
  The same audit found the rest of the drift
&lt;/h3&gt;

&lt;p&gt;Once you go looking for hand-written versions of things that already exist, they come in a batch.&lt;br&gt;
From the same pass, each one a small deviation in shape that nothing was failing on:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What was there&lt;/th&gt;
&lt;th&gt;What it should have been&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;three hand-rolled caches: &lt;code&gt;map&lt;/code&gt; + &lt;code&gt;RWMutex&lt;/code&gt;, no TTL, no capacity, no metrics&lt;/td&gt;
&lt;td&gt;one cache with TTL, capacity and metrics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;message dedup as a hand-written ring of 1024 ids&lt;/td&gt;
&lt;td&gt;the same cache, with a TTL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bulk insert as one &lt;code&gt;INSERT&lt;/code&gt; per row&lt;/td&gt;
&lt;td&gt;a multi-row &lt;code&gt;VALUES&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;two different loggers in two packages of one service&lt;/td&gt;
&lt;td&gt;one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the retention schedule hard-wired as a constant&lt;/td&gt;
&lt;td&gt;a config value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a lazy scheduler init on &lt;code&gt;sync.Once&lt;/code&gt; with captured variables&lt;/td&gt;
&lt;td&gt;the platform's init-state storage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a cache-invalidation consumer written but registered in &lt;strong&gt;no&lt;/strong&gt; daemon&lt;/td&gt;
&lt;td&gt;registered, or deleted&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last one is the sharpest. The package compiled, its unit tests were green, and invalidation&lt;br&gt;
simply did not run in production, because nothing anywhere said "a consumer must be mounted&lt;br&gt;
somewhere". Sameness is not an aesthetic in that story - it's the only reason the gap is findable&lt;br&gt;
by looking rather than by an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;None of this is a declaration. As of 2026-08-16, in the service I take these samples from:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.go&lt;/code&gt; files (excluding generated)&lt;/td&gt;
&lt;td&gt;2733&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;packages&lt;/td&gt;
&lt;td&gt;252&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;average file&lt;/td&gt;
&lt;td&gt;39 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;files over 100 lines&lt;/td&gt;
&lt;td&gt;44 - of which &lt;strong&gt;2&lt;/strong&gt; are non-test (105 and 102 lines)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;migration files / lines&lt;/td&gt;
&lt;td&gt;66 / 1990&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;line coverage&lt;/td&gt;
&lt;td&gt;86.7%&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%2Fx2a6a9jo2oyqrtdvdrsp.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%2Fx2a6a9jo2oyqrtdvdrsp.png" alt="Four rows of numbers: files 2733, packages 252, average file 39 lines, files over 100 lines 44 of which 2 non-test" width="799" height="289"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The interesting one is the average, and it's clearer over the tag history (generated code excluded&lt;br&gt;
throughout):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tag&lt;/th&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;Tests&lt;/th&gt;
&lt;th&gt;Tests/code&lt;/th&gt;
&lt;th&gt;Files&lt;/th&gt;
&lt;th&gt;Packages&lt;/th&gt;
&lt;th&gt;Average file&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;v0.1.0&lt;/td&gt;
&lt;td&gt;1751&lt;/td&gt;
&lt;td&gt;781&lt;/td&gt;
&lt;td&gt;0.45&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v0.2.0&lt;/td&gt;
&lt;td&gt;5054&lt;/td&gt;
&lt;td&gt;2817&lt;/td&gt;
&lt;td&gt;0.56&lt;/td&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v0.3.0&lt;/td&gt;
&lt;td&gt;22765&lt;/td&gt;
&lt;td&gt;26924&lt;/td&gt;
&lt;td&gt;1.18&lt;/td&gt;
&lt;td&gt;520&lt;/td&gt;
&lt;td&gt;74&lt;/td&gt;
&lt;td&gt;43&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v0.4.0&lt;/td&gt;
&lt;td&gt;28095&lt;/td&gt;
&lt;td&gt;31952&lt;/td&gt;
&lt;td&gt;1.14&lt;/td&gt;
&lt;td&gt;640&lt;/td&gt;
&lt;td&gt;85&lt;/td&gt;
&lt;td&gt;43&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v1.0.0&lt;/td&gt;
&lt;td&gt;25097&lt;/td&gt;
&lt;td&gt;35368&lt;/td&gt;
&lt;td&gt;1.41&lt;/td&gt;
&lt;td&gt;683&lt;/td&gt;
&lt;td&gt;94&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v1.1.0&lt;/td&gt;
&lt;td&gt;29443&lt;/td&gt;
&lt;td&gt;36272&lt;/td&gt;
&lt;td&gt;1.23&lt;/td&gt;
&lt;td&gt;804&lt;/td&gt;
&lt;td&gt;109&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v1.2.0&lt;/td&gt;
&lt;td&gt;34523&lt;/td&gt;
&lt;td&gt;39861&lt;/td&gt;
&lt;td&gt;1.15&lt;/td&gt;
&lt;td&gt;933&lt;/td&gt;
&lt;td&gt;119&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v1.3.0&lt;/td&gt;
&lt;td&gt;42461&lt;/td&gt;
&lt;td&gt;49564&lt;/td&gt;
&lt;td&gt;1.17&lt;/td&gt;
&lt;td&gt;1116&lt;/td&gt;
&lt;td&gt;135&lt;/td&gt;
&lt;td&gt;38&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v1.4.0&lt;/td&gt;
&lt;td&gt;50755&lt;/td&gt;
&lt;td&gt;62986&lt;/td&gt;
&lt;td&gt;1.24&lt;/td&gt;
&lt;td&gt;1300&lt;/td&gt;
&lt;td&gt;175&lt;/td&gt;
&lt;td&gt;39&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v1.5.0&lt;/td&gt;
&lt;td&gt;55979&lt;/td&gt;
&lt;td&gt;68437&lt;/td&gt;
&lt;td&gt;1.22&lt;/td&gt;
&lt;td&gt;1403&lt;/td&gt;
&lt;td&gt;187&lt;/td&gt;
&lt;td&gt;39&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v1.8.0&lt;/td&gt;
&lt;td&gt;58372&lt;/td&gt;
&lt;td&gt;72758&lt;/td&gt;
&lt;td&gt;1.25&lt;/td&gt;
&lt;td&gt;1476&lt;/td&gt;
&lt;td&gt;205&lt;/td&gt;
&lt;td&gt;39&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v1.10.0&lt;/td&gt;
&lt;td&gt;60019&lt;/td&gt;
&lt;td&gt;74701&lt;/td&gt;
&lt;td&gt;1.24&lt;/td&gt;
&lt;td&gt;1514&lt;/td&gt;
&lt;td&gt;232&lt;/td&gt;
&lt;td&gt;39&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HEAD&lt;/td&gt;
&lt;td&gt;61411&lt;/td&gt;
&lt;td&gt;76468&lt;/td&gt;
&lt;td&gt;1.25&lt;/td&gt;
&lt;td&gt;1540&lt;/td&gt;
&lt;td&gt;252&lt;/td&gt;
&lt;td&gt;39&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things I read off that table:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The service grows by files and packages, not by files getting fatter.&lt;/strong&gt; 13 → 252 packages while
the average file went 50 → 39 lines and then stopped moving. 2733 files averaging 39 lines is a
different codebase from 700 files averaging 150, even at the same total size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deduplication is visible as a dip.&lt;/strong&gt; At &lt;code&gt;v1.0.0&lt;/code&gt; lines of code &lt;strong&gt;dropped&lt;/strong&gt; from 28095 to 25097
while test lines grew: repeated domain files going into generic cores. The tests-to-code ratio
went 1.14 → 1.41 in that step, and hasn't gone below 1.14 since.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New cores arrive as packages, not as sprawl.&lt;/strong&gt; The latest release increment - the row-reading
core above, together with an SQL catalog engine - was +1392 lines of code, +1767 lines of tests
and +20 packages.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this is actually for
&lt;/h2&gt;

&lt;p&gt;The honest reason isn't readability. It's that a formalised layout is the only thing that makes&lt;br&gt;
generation possible.&lt;/p&gt;

&lt;p&gt;A generator doesn't guess style; it places files by rule. As long as the layout is "whatever the&lt;br&gt;
author preferred", there is nothing to generate - and nothing to check drift against. You can't&lt;br&gt;
diff a tree against a convention that exists only in people's heads.&lt;/p&gt;

&lt;h3&gt;
  
  
  The census: how much of a domain is shape
&lt;/h3&gt;

&lt;p&gt;Writing the spec for a skeleton generator (12 iterations, &lt;strong&gt;specified and not built yet&lt;/strong&gt; - I'm&lt;br&gt;
describing designed work, not a shipped tool) forced me to inventory a domain file by file. That&lt;br&gt;
inventory is the most concrete answer to "how copy-paste is it, really":&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Fully mechanical&lt;/th&gt;
&lt;th&gt;Hand-written body&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;domain gRPC methods (&lt;code&gt;internal/grpc/&amp;lt;entity&amp;gt;/&lt;/code&gt;, or &lt;code&gt;internal/grpc/service/&amp;lt;service&amp;gt;/&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;all 5 files&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;domain codecs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;codec.go&lt;/code&gt;, &lt;code&gt;mapping.go&lt;/code&gt;, &lt;code&gt;request.go&lt;/code&gt;, &lt;code&gt;request_patch.go&lt;/code&gt;, &lt;code&gt;suite.go&lt;/code&gt;, &lt;code&gt;base.go&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;payload.go&lt;/code&gt;, &lt;code&gt;filter.go&lt;/code&gt;, &lt;code&gt;errors.go&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;domain repository&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;calls.go&lt;/code&gt;, &lt;code&gt;repository.go&lt;/code&gt;, &lt;code&gt;revision.go&lt;/code&gt;, &lt;code&gt;deps.go&lt;/code&gt;, &lt;code&gt;methods/search.go&lt;/code&gt;, &lt;code&gt;methods/find_all_by_ids.go&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;spec.go&lt;/code&gt;, &lt;code&gt;methods/header.go&lt;/code&gt;, &lt;code&gt;methods/code_taken.go&lt;/code&gt;, &lt;code&gt;methods/slug_taken.go&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;domain manager&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;calls.go&lt;/code&gt;, &lt;code&gt;deps.go&lt;/code&gt;, &lt;code&gt;manager.go&lt;/code&gt;, &lt;code&gt;methods/{create,update,correct,archive}.go&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;spec.go&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;domain model&lt;/td&gt;
&lt;td&gt;9 files&lt;/td&gt;
&lt;td&gt;6 files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;domain assembly in the daemon&lt;/td&gt;
&lt;td&gt;all 4 files&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;domain test stand&lt;/td&gt;
&lt;td&gt;all 3 files&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;migrations&lt;/td&gt;
&lt;td&gt;all&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the right-hand column top to bottom and you have the whole of what a new domain actually&lt;br&gt;
writes itself: two &lt;code&gt;spec.go&lt;/code&gt; files, three codec files, three repository methods carrying its own key&lt;br&gt;
rules, and 6 of its 15 model files. &lt;strong&gt;That list is the domain. Everything else is the shape&lt;/strong&gt; -&lt;br&gt;
which is another way of saying the copy-paste was already happening, and formalising the layout&lt;br&gt;
only makes it honest.&lt;/p&gt;

&lt;h3&gt;
  
  
  The generator's form follows from the same idea
&lt;/h3&gt;

&lt;p&gt;Two things about the design are worth naming, because both are consequences of sameness rather than&lt;br&gt;
preferences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's a library, not a script&lt;/strong&gt;: &lt;code&gt;Generate(cfg) (Result, error)&lt;/code&gt; and
&lt;code&gt;CheckDrift(cfg) ([]string, error)&lt;/code&gt;, with a thin CLI over them. Drift-checking is the same
code path as generating, so the two can't disagree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It renders by concatenating strings&lt;/strong&gt; - prepared constants, a string builder, token
substitution (&lt;code&gt;$domain$&lt;/code&gt;, &lt;code&gt;$Domain$&lt;/code&gt;, &lt;code&gt;$plural$&lt;/code&gt;) - and templating engines are explicitly
forbidden. Every &lt;code&gt;.go&lt;/code&gt; output goes through the formatter, so import grouping and alignment are
not the renderer's problem.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The input is deliberately double, because neither source describes a domain on its own: a YAML&lt;br&gt;
description (names and plural, Go types, tenant-scope flag, slug and batch flags, header and&lt;br&gt;
revision tables, payload columns with SQL type, nullability and comment, event types, error&lt;br&gt;
sentinels, first migration number) plus the &lt;code&gt;.proto&lt;/code&gt; descriptor (service name, RPCs with request&lt;br&gt;
and response types, entity and batch messages).&lt;/p&gt;

&lt;p&gt;Files that need a hand-written body still get generated - correct &lt;code&gt;package&lt;/code&gt;, imports and signatures,&lt;br&gt;
bodies marked with a panic stub - and a second run without &lt;code&gt;-force&lt;/code&gt; won't overwrite them. Generated&lt;br&gt;
files carry a &lt;code&gt;Code generated by … DO NOT EDIT.&lt;/code&gt; header, which is a contract: editing one by hand is&lt;br&gt;
forbidden, and a check mode compares what would be generated against what's in the repository and&lt;br&gt;
exits non-zero on a difference.&lt;/p&gt;

&lt;p&gt;And what the generator deliberately doesn't touch: the generic cores above, and one-off pieces like&lt;br&gt;
publishers or the invalidation consumer. They aren't "per domain", so they aren't a unit of&lt;br&gt;
generation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Even the migrations have one shape
&lt;/h2&gt;

&lt;p&gt;Schema work is where "each author their own way" usually wins, so it gets the same treatment: goose,&lt;br&gt;
embedded, &lt;strong&gt;forward-only&lt;/strong&gt; (&lt;code&gt;-- +goose Up&lt;/code&gt;, no &lt;code&gt;Down&lt;/code&gt;), file names as &lt;code&gt;NNN_description.sql&lt;/code&gt;, one&lt;br&gt;
table per migration, a rollback expressed as a new migration forward. &lt;code&gt;COMMENT ON COLUMN&lt;/code&gt; on&lt;br&gt;
&lt;strong&gt;every&lt;/strong&gt; column. &lt;code&gt;VARCHAR&lt;/code&gt; rather than &lt;code&gt;TEXT&lt;/code&gt;. A migration file that has already shipped is&lt;br&gt;
immutable.&lt;/p&gt;

&lt;p&gt;66 files, 1990 lines, and the reason they're the one layer the generator can emit in full: there is&lt;br&gt;
nothing in them that is a matter of judgement about &lt;em&gt;form&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the tests live
&lt;/h2&gt;

&lt;p&gt;The layout rule applies to tests too, and this is the part that surprises people most: tests are not&lt;br&gt;
next to the code. They live in &lt;code&gt;&amp;lt;package&amp;gt;/test/&amp;lt;concern&amp;gt;/&lt;/code&gt; as package &lt;code&gt;&amp;lt;concern&amp;gt;test&lt;/code&gt;, black-box&lt;br&gt;
only - the package under test is imported from outside. Stands and fixtures live in a shared&lt;br&gt;
support tree with &lt;strong&gt;no asserts&lt;/strong&gt;, take &lt;code&gt;testing.TB&lt;/code&gt; as the first parameter, call &lt;code&gt;t.Helper()&lt;/code&gt; on the&lt;br&gt;
first line, and release resources through &lt;code&gt;t.Cleanup&lt;/code&gt;. Golden files go in &lt;code&gt;testdata/&lt;/code&gt; next to the&lt;br&gt;
test that reads them.&lt;/p&gt;

&lt;p&gt;The price is real: longer paths, no access to unexported identifiers, and some tooling expects tests&lt;br&gt;
to sit beside the code. What it buys is that a test file has exactly one possible location, and a&lt;br&gt;
"test helper" can't quietly become a second implementation of the thing it helps.&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes it stick: checks, not promises
&lt;/h2&gt;

&lt;p&gt;Every rule above is either enforced by something mechanical or slowly decaying. The escalation I've&lt;br&gt;
converged on is &lt;strong&gt;reminder → rule → check&lt;/strong&gt;, and only the last one holds:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Instead of a rule&lt;/th&gt;
&lt;th&gt;The check that replaces it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"don't hand-write row loops"&lt;/td&gt;
&lt;td&gt;a forbidding test: &lt;code&gt;for rows.Next()&lt;/code&gt; appears in exactly one package&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"keep the generated tree in sync"&lt;/td&gt;
&lt;td&gt;a check mode that diffs generated output against the repository and exits non-zero&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"don't let coverage slide"&lt;/td&gt;
&lt;td&gt;a ratchet: the threshold can only go up, and an attempt to lower it fails the PR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"don't use an empty context in tests"&lt;/td&gt;
&lt;td&gt;a tool hook that blocks the write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"don't leave open questions in a task spec"&lt;/td&gt;
&lt;td&gt;a tool hook that blocks the write when a forbidden phrase appears&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two honest footnotes. The coverage ratchet currently sits at &lt;code&gt;0&lt;/code&gt; - the mechanism is in place, the&lt;br&gt;
bar hasn't been raised yet, while actual coverage is 86.7%. And the empty-context rule exists&lt;br&gt;
because the scale of that particular drift was &lt;strong&gt;421 empty-context calls across 184 test files&lt;/strong&gt;;&lt;br&gt;
that is what a convention nobody checks looks like after a few months.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;It's &lt;strong&gt;unfamiliar&lt;/strong&gt;: people arrive looking for &lt;code&gt;internal/domain/&amp;lt;area&amp;gt;/&lt;/code&gt; and it isn't there.&lt;/li&gt;
&lt;li&gt;It's &lt;strong&gt;boring&lt;/strong&gt;. Nothing to argue about, and an author's taste never lands in the layout. Some
engineers experience that as a loss.&lt;/li&gt;
&lt;li&gt;Following one feature means &lt;strong&gt;jumping between directories&lt;/strong&gt; - repository, manager, model, the
gRPC folder, codec, plus the daemon assembly, instead of one folder. That is exactly the
thing domain-first gets right, and I give it up on purpose.&lt;/li&gt;
&lt;li&gt;There are &lt;strong&gt;a lot of files&lt;/strong&gt; (2733), and search-by-name matters more than scrolling one.&lt;/li&gt;
&lt;li&gt;Tests are &lt;strong&gt;not next to the code&lt;/strong&gt;, which costs unexported access and some tooling friction.&lt;/li&gt;
&lt;li&gt;The rules only hold while the checks do. Every one of them is work to build and work to keep
green.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are real costs, and they're the price of a choice rather than the proof of a law. I pay them&lt;br&gt;
because in my situation the other bill - a fleet of services each with its own shape - is larger,&lt;br&gt;
and it comes again on every service and every domain inside it. Your bill may be arranged&lt;br&gt;
differently.&lt;/p&gt;

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

&lt;p&gt;Predictability beats elegance exactly when the code has to be reproduced by a machine rather than&lt;br&gt;
rewritten by a person. Which shape you make predictable is yours to pick - mine is type-first&lt;br&gt;
because I can find the next line without opening the file. The part I'd defend in anyone's&lt;br&gt;
codebase is only this: the next service should be a copy of the last one.&lt;/p&gt;




&lt;p&gt;That's my experience and my price for it. If you do this better, if you've already been through&lt;br&gt;
it, or if you look at it differently - I'd like to hear how it's solved on your side, and what&lt;br&gt;
broke when you tried.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Platform and generation - Part 1.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Next: what the platform hands you for free, and the list of things you're not allowed to write by&lt;br&gt;
hand because of it.&lt;/p&gt;

</description>
      <category>go</category>
      <category>architecture</category>
      <category>codegen</category>
      <category>backend</category>
    </item>
    <item>
      <title>One New Order Status, One Full Table Rebuild - and the Years of Magic Numbers After It</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Mon, 24 Aug 2026 09:42:20 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/one-new-order-status-one-full-table-rebuild-and-the-years-of-magic-numbers-after-it-439i</link>
      <guid>https://dev.to/anton_brilliantov/one-new-order-status-one-full-table-rebuild-and-the-years-of-magic-numbers-after-it-439i</guid>
      <description>&lt;p&gt;&lt;em&gt;A new order status is a one-word change to a spec and, on MySQL, potentially a full table rewrite. The team's reaction to that scar is almost always the same: drop enums, store a &lt;code&gt;tinyint&lt;/code&gt;, decode it in the app. That reaction costs more than the rebuild did. This is the situation, why it happens, what Postgres does instead, and the price of each option - told honestly, because Postgres enums have real limits too.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, usually writing about breaking a monolith into services. This one is smaller and self-contained: a database-design decision people get wrong because they're carrying a MySQL scar into Postgres, and I carried it too for a while. 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;. Maybe you landed somewhere else on this - I'd like to hear where.&lt;/p&gt;




&lt;h2&gt;
  
  
  What broke
&lt;/h2&gt;

&lt;p&gt;The change request is boring: orders need one more status. &lt;code&gt;partially_refunded&lt;/code&gt;, say, and it belongs next to &lt;code&gt;refunded&lt;/code&gt;, not tacked on at the end - the status column is ordered and people read it in order.&lt;/p&gt;

&lt;p&gt;On MySQL, that "belongs next to" is the expensive word.&lt;/p&gt;

&lt;p&gt;An enum in MySQL is welded to the column. Its members are stored by position, so putting a new one in the middle &lt;strong&gt;renumbers the existing members&lt;/strong&gt;. Renumbering means every row has to be written again, which means the change is not metadata - it's a &lt;strong&gt;full table &lt;code&gt;COPY&lt;/code&gt;&lt;/strong&gt;. The table gets rebuilt. It locks. And it needs as much free disk as the table itself, which is its own kind of surprise at 2 a.m.&lt;/p&gt;

&lt;p&gt;The variant that &lt;em&gt;is&lt;/em&gt; cheap - appending at the end - is cheap only under conditions: &lt;code&gt;INSTANT&lt;/code&gt;, metadata-only in modern MySQL 8, &lt;strong&gt;if&lt;/strong&gt; the storage size doesn't change. Cross a storage-size boundary and you get a copy anyway. And historically, before 5.6.16, even the "fast" append &lt;a href="https://bugs.mysql.com/bug.php?id=72997" rel="noopener noreferrer"&gt;rebuilt the whole table&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So the real shape of the incident is not "one migration was slow". It's that an enum change in MySQL is &lt;strong&gt;position-, size-, and version-dependent&lt;/strong&gt;: three conditions someone has to reason about correctly, every time, forever.&lt;/p&gt;




&lt;h2&gt;
  
  
  What it cost - the part that outlives the incident
&lt;/h2&gt;

&lt;p&gt;Nobody wants to run that reasoning twice. So the team does what teams do: gives up on enums entirely and stores an &lt;code&gt;int&lt;/code&gt;/&lt;code&gt;tinyint&lt;/code&gt; plus a code map.&lt;/p&gt;

&lt;p&gt;That's the expensive decision, and it's paid every day afterwards:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The database holds &lt;code&gt;3&lt;/code&gt;. To know that &lt;code&gt;3&lt;/code&gt; means &lt;code&gt;partially_refunded&lt;/code&gt; you either keep a lookup table - a JOIN on every read - or an app-side map you must keep in lockstep with the DB.&lt;/li&gt;
&lt;li&gt;Every ad-hoc query, every support ticket, every dashboard goes through a decoding step that lives in someone's head.&lt;/li&gt;
&lt;li&gt;When the map drifts from the database, nothing breaks loudly. It just becomes quietly wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Magic numbers in every row. &lt;strong&gt;Remembering ints is no fun&lt;/strong&gt;, and the drift is a source of quiet bugs.&lt;/p&gt;

&lt;p&gt;The rebuild was one bad night. The &lt;code&gt;tinyint&lt;/code&gt; is a tax with no end date.&lt;/p&gt;

&lt;p&gt;Which is where my own preference comes from, and I'd rather name it than let it leak: I like it when a set of values is typed, because then the type checks the thing a person would otherwise have to check in review - and in that story nobody was checking, which is why the map drifted. It's a preference, not a law. What follows is what it costs to hold it, on each database, with the price attached.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Postgres doesn't have this failure mode
&lt;/h2&gt;

&lt;p&gt;Postgres doesn't weld the enum to a column. An enum is an &lt;strong&gt;independent type&lt;/strong&gt; living in the catalog (&lt;code&gt;pg_type&lt;/code&gt; / &lt;code&gt;pg_enum&lt;/code&gt;), and your column just references it. That single design difference is the whole story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MYSQL                                   POSTGRES
enum is part of the COLUMN              enum is an independent TYPE

  ALTER TABLE ... MODIFY enum             ALTER TYPE order_status
        │                                       ADD VALUE 'refunded'
        ▼                                       │
  middle/reorder/size-cross                     ▼
        │                                 catalog change - a row in pg_enum
        ▼                                       │
  full table COPY  →  rewrite + LOCK            ▼
  (as big as the table, downtime)         no table rewrite, no table lock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ALTER TYPE ... ADD VALUE&lt;/code&gt; writes one row to the catalog. It does &lt;strong&gt;not&lt;/strong&gt; rewrite the table the column lives in, and it doesn't lock that table's data. Since &lt;strong&gt;Postgres 12&lt;/strong&gt; it's even &lt;a href="https://www.postgresql.org/docs/current/sql-altertype.html" rel="noopener noreferrer"&gt;transactional&lt;/a&gt; - you can run it inside a transaction, with one caveat: the new value can't be &lt;em&gt;used&lt;/em&gt; until the transaction commits. On PG &amp;lt; 12 you couldn't &lt;code&gt;ADD VALUE&lt;/code&gt; inside a transaction at all.&lt;/p&gt;

&lt;p&gt;And the part that makes the &lt;code&gt;tinyint&lt;/code&gt; escape pointless here: a Postgres enum value is stored as a &lt;strong&gt;4-byte OID - exactly as compact as an &lt;code&gt;integer&lt;/code&gt;&lt;/strong&gt; - but type-safe, human-readable, and with no JOIN to decode. The int's footprint, the string's readability, the type system's guarantee, in one column.&lt;/p&gt;

&lt;p&gt;None of that asks you to take my word for it. The enum being its own catalog object, the 4 bytes read straight out of the row, the rollback that takes the added value back out since 12 - each one is a statement you can check on your own instance in a few minutes. That's why the preference survives here and didn't survive on the other side of the table.&lt;/p&gt;




&lt;h2&gt;
  
  
  Adding a value is transactional - and that's the whole point
&lt;/h2&gt;

&lt;p&gt;The catalog row is the cheap part. The part that removes a whole class of deploy anxiety is &lt;em&gt;when&lt;/em&gt; that row becomes visible.&lt;/p&gt;

&lt;p&gt;Before Postgres 12, &lt;code&gt;ALTER TYPE ... ADD VALUE&lt;/code&gt; could not run inside a transaction block at all - the command simply failed. Since Postgres 12 it can, and that changes what you can guarantee: &lt;strong&gt;roll the transaction back, and the added value vanishes with everything else in it&lt;/strong&gt;. There is no half state left behind - no "the type gained the value, but the dictionary row never landed".&lt;/p&gt;

&lt;p&gt;That matters because of a very ordinary setup. A system keeps a dictionary of values and an enum type that are required to agree with each other; the dictionary is what people and other services read, the type is what the column is constrained by. Drift between the two is the bug you find weeks later. So you add the dictionary row and run &lt;code&gt;ALTER TYPE ... ADD VALUE&lt;/code&gt; &lt;strong&gt;in the same transaction&lt;/strong&gt;. Commit - both exist. Roll back - neither does. The agreement is held by the transaction itself, not by the ordering of deploy steps and not by a retry loop that someone has to get right.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="k"&gt;dictionary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_status&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'partially_refunded'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Partially refunded'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TYPE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_status&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="n"&gt;VALUE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="s1"&gt;'partially_refunded'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- the value can be written into data from the next transaction onward&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ADD VALUE IF NOT EXISTS&lt;/code&gt; makes the whole thing idempotent, so a migration that runs twice - or a message that gets redelivered - changes nothing the second time.&lt;/p&gt;

&lt;p&gt;Now the caveat, said out loud rather than buried: &lt;strong&gt;a new value cannot be used in the same transaction that added it&lt;/strong&gt;, unless the type itself was created in that transaction. Try to insert a row carrying the new value before the commit and Postgres refuses with an error of the form &lt;em&gt;unsafe use of new value of enum type&lt;/em&gt;. So the working order is two transactions: the first adds the value (and the dictionary row, if there is one), the second writes data with it. That's a sequencing rule, not a blocker - but you want to know it before you write the migration, not during it.&lt;/p&gt;

&lt;p&gt;MySQL has no equivalent path. Changing an enum there can rebuild the table, which is exactly why the projects that got burned reach for an &lt;code&gt;int&lt;/code&gt;. It works - and it leaves the database full of numbers that mean nothing on their own. To learn what &lt;code&gt;3&lt;/code&gt; stands for you need either a JOIN to a dictionary or an app-side map kept in sync by hand. A Postgres enum reads straight out of the row, no JOIN. That's the difference in one line.&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%2Fm6fls792izyc1ws1pn7p.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%2Fm6fls792izyc1ws1pn7p.png" alt="Two columns: MySQL, where the enum is welded to the column so a change walks down to a full table copy - rewrite plus lock - versus Postgres, where the enum is an independent type and ALTER TYPE ADD VALUE is one row in pg_enum, no rewrite, no lock" width="800" height="405"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The honest caveats - what an enum still costs you
&lt;/h2&gt;

&lt;p&gt;I'd be selling you something if I stopped at the good news. Over a long stretch since that night this has held up on my side and I haven't been burned by it - which is an observation from one system, not a promise about your next incident. The limits are real, they're what decide fit, and they belong here in the middle of the text rather than in a footnote under it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You can't easily remove a value.&lt;/strong&gt; There is no &lt;code&gt;DROP VALUE&lt;/code&gt;. Retiring one means recreating the type (rename old → create new → migrate the column → drop old) or living with a dead value. Enums are &lt;strong&gt;add-mostly&lt;/strong&gt; by nature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-end inserts can be slightly slower.&lt;/strong&gt; Placing a value with &lt;code&gt;BEFORE&lt;/code&gt;/&lt;code&gt;AFTER&lt;/code&gt; rather than at the end can make comparisons on that type marginally slower than on the original members.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A new value can't be used in the transaction that added it&lt;/strong&gt; - unless the type was created there too. One transaction adds it, the next writes data with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They're for smallish, stable-ish vocabularies.&lt;/strong&gt; Status, kind, channel - not a high-churn set, not something unbounded. There's a practical ceiling on how many values stay sensible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They don't travel.&lt;/strong&gt; A Postgres enum is Postgres-specific; an int moves to another engine and an enum doesn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One sentence of trade-off: an enum is &lt;strong&gt;lightweight (4 bytes) and readable&lt;/strong&gt;, and for a small, mostly-append vocabulary I take that over the "can't easily remove" limits. When the set churns hard, needs deletion, or carries metadata, I don't - and there I reach for a lookup table or an int. On purpose, not by reflex, and with the scar accounted for rather than driving.&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%2Fcd6tur3dip4hmvi81nx6.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%2Fcd6tur3dip4hmvi81nx6.png" alt="Price of three enum changes side by side: append a value, insert a value in the middle, remove a value" width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The fix, and the tool that came out of it
&lt;/h2&gt;

&lt;p&gt;Even on Postgres, &lt;em&gt;managing&lt;/em&gt; enum values by hand is fiddly: &lt;code&gt;ALTER TYPE … ADD VALUE IF NOT EXISTS&lt;/code&gt;, getting idempotency right, handling schema-qualified type names, keeping the set in sync between migrations and the values the app actually produces. Small, repetitive, easy to get slightly wrong - exactly the kind of thing that deserves a helper rather than a convention.&lt;/p&gt;

&lt;p&gt;So I wrote one: &lt;strong&gt;&lt;a href="https://github.com/brilliant-almazov/pgenum" rel="noopener noreferrer"&gt;pgenum&lt;/a&gt;&lt;/strong&gt; - a &lt;strong&gt;zero-dependency&lt;/strong&gt; Go library (only &lt;code&gt;database/sql&lt;/code&gt;; works with pgx, lib/pq, anything). It's small on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Idempotent&lt;/strong&gt; - every call is &lt;code&gt;ADD VALUE IF NOT EXISTS&lt;/code&gt;, safe to repeat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrent-safe&lt;/strong&gt; - no shared state; Postgres serialises &lt;code&gt;ALTER TYPE&lt;/code&gt; itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema-qualified&lt;/strong&gt; - &lt;code&gt;orders.order_status&lt;/code&gt; / &lt;code&gt;audit.event_type&lt;/code&gt; work out of the box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Injection-safe&lt;/strong&gt; - identifier validation + quoting on every name and value.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="s"&gt;"github.com/brilliant-almazov/pgenum"&lt;/span&gt;

&lt;span class="c"&gt;// the whole surface you usually need:&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Ensurer&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;EnsureValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;typeName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;
    &lt;span class="n"&gt;EnsureValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;typeName&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;values&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Ensurer&lt;/span&gt;
&lt;span class="c"&gt;// plus helpers: SyncEnumFromColumn(...), EnumValues(...), HasValue(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Deploy-time (static):&lt;/strong&gt; declare the vocabulary in YAML, apply it once at startup, right after migrations. Deterministic and reviewable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Config&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;][]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="n"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ApplyDB&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;seedEnums&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt; &lt;span class="n"&gt;pgenum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Config&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;yaml&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ApplyDB&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// ensures every declared value exists&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Runtime (event-driven):&lt;/strong&gt; inject the &lt;code&gt;Ensurer&lt;/code&gt;, and when an entity introduces a value, make sure the type knows it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;OrderService&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DB&lt;/span&gt;
    &lt;span class="n"&gt;enums&lt;/span&gt; &lt;span class="n"&gt;pgenum&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ensurer&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;OrderService&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OnOrderCreated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enums&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EnsureValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"orders.order_status"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sync enum: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c"&gt;// ... rest of the handler&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;Ensurer&lt;/code&gt; is a one-method-ish interface, it mocks trivially in tests - no database needed to assert "the handler tried to ensure this value."&lt;/p&gt;




&lt;h2&gt;
  
  
  The decision the library refuses to make for you
&lt;/h2&gt;

&lt;p&gt;Runtime, deploy-time, or both - there's no single right answer, and that's the interesting part.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deploy-time&lt;/strong&gt; fits when the vocabulary is known ahead of time and you want it in version control, reviewed in a PR, applied deterministically on release. Most enums are like this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime&lt;/strong&gt; fits when values genuinely &lt;em&gt;emerge&lt;/em&gt; - a third-party integration, a partner's status codes, user-defined-ish categories - and you don't want to ship a deploy just to accept a new one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I run &lt;strong&gt;both&lt;/strong&gt; on the same system, and each mode was built for one thing. The static dictionary gets applied at startup, right after migrations, because after that incident I wanted the set of values to live in version control and go through review like any other change - adding a status should be a diff someone reads, not a statement someone runs at 2 a.m. The event-driven &lt;code&gt;EnsureValue&lt;/code&gt; is there for the handful of values that genuinely arrive from data; running it again is harmless, since every call is &lt;code&gt;ADD VALUE IF NOT EXISTS&lt;/code&gt;. And on the hot path I don't call it at all - it's idempotent but still a catalog round-trip, so it's gated: ensure only on a value not seen before, or cache the known set (&lt;code&gt;EnumValues&lt;/code&gt; / &lt;code&gt;HasValue&lt;/code&gt;) and ensure once. How aggressively to cache follows from write load, which is exactly why it stays a judgment call and not a rule.&lt;/p&gt;

&lt;p&gt;Your scenarios are the part I can't see from here. Which of your value sets sit in a dictionary, which sit in a type, and what pushed each one to that side?&lt;/p&gt;




&lt;h2&gt;
  
  
  enum vs int vs lookup table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Postgres enum&lt;/th&gt;
&lt;th&gt;int / tinyint&lt;/th&gt;
&lt;th&gt;lookup table&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;4 bytes (OID)&lt;/td&gt;
&lt;td&gt;1-4 bytes&lt;/td&gt;
&lt;td&gt;int FK + the table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Readable in the DB&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no (magic number)&lt;/td&gt;
&lt;td&gt;via a JOIN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type-safe&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;FK only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read cost&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;none (opaque)&lt;/td&gt;
&lt;td&gt;a JOIN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add a value&lt;/td&gt;
&lt;td&gt;cheap catalog change&lt;/td&gt;
&lt;td&gt;trivial&lt;/td&gt;
&lt;td&gt;one INSERT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remove a value&lt;/td&gt;
&lt;td&gt;hard (no DROP VALUE)&lt;/td&gt;
&lt;td&gt;trivial&lt;/td&gt;
&lt;td&gt;one DELETE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Values carry metadata&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes (label, i18n, flags)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-database&lt;/td&gt;
&lt;td&gt;no (PG-specific)&lt;/td&gt;
&lt;td&gt;yes&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;&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%2Ftze23y7zaawjawxrkb64.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%2Ftze23y7zaawjawxrkb64.png" alt="Decision fork between a Postgres enum, a lookup table and an int, with the condition written on each edge" width="800" height="424"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read it as a fit test, not a winner - and here's how I read it for myself. I take a &lt;strong&gt;Postgres enum&lt;/strong&gt; when I'm on Postgres, the set is smallish and mostly-append, and I want compact + readable + type-safe with no JOIN. I take a &lt;strong&gt;lookup table&lt;/strong&gt; when the values carry their own metadata (labels, translations, feature flags). I take an &lt;strong&gt;int&lt;/strong&gt; when I have no choice - cross-database portability, or a set that churns and needs real deletion. That's my reading of the same rows; yours can come out different.&lt;/p&gt;




&lt;p&gt;That's my scar, that's the choice I made after it, and those limits are the price I pay for the choice. The bit I'm least sure about is where the line sits between "stable enough for an enum" and "churny enough for a lookup table" - that line moved for me more than once.&lt;/p&gt;

&lt;p&gt;So: if &lt;strong&gt;you do this better&lt;/strong&gt;, especially the retire-a-value path, I want to see how. If &lt;strong&gt;you've been through this&lt;/strong&gt; - a middle-of-the-enum change on a big MySQL table, or years of decoding &lt;code&gt;tinyint&lt;/code&gt;s - the details are the useful part. And if &lt;strong&gt;you look at it differently&lt;/strong&gt; and think native enums are never worth the add-mostly constraint, that's a reading I'd take seriously. How is it solved on your side, and what actually broke when you changed it?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Sources: &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-online-ddl-operations.html" rel="noopener noreferrer"&gt;MySQL online DDL operations&lt;/a&gt; · &lt;a href="https://bugs.mysql.com/bug.php?id=72997" rel="noopener noreferrer"&gt;MySQL bug #72997 (enum ALTER full rebuild)&lt;/a&gt; · &lt;a href="https://www.postgresql.org/docs/current/sql-altertype.html" rel="noopener noreferrer"&gt;Postgres ALTER TYPE&lt;/a&gt; · &lt;a href="https://github.com/brilliant-almazov/pgenum" rel="noopener noreferrer"&gt;pgenum&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>go</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Snowflake IDs - Part 3: A Typed Prefix, and Two Doors</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Sun, 23 Aug 2026 05:49:02 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/snowflake-ids-part-3-a-typed-prefix-and-two-doors-go7</link>
      <guid>https://dev.to/anton_brilliantov/snowflake-ids-part-3-a-typed-prefix-and-two-doors-go7</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 1 settled the primary key. Part 2 got it across the wire, the URL and the browser. This part is about the thing that fell out of both and turned out to be the most useful of the three: the id is a seam. Inside the mesh it's a raw &lt;code&gt;int64&lt;/code&gt;; on the public surface it's an opaque token. That difference isn't cosmetic - it's a line between two surfaces with different threat models, and the id is the cheapest place in the whole system to draw it. Here's what a typed prefix on the public token buys you: routing before decoding, a public surface that says nothing about your internals, and - if you want it - two front doors that never share an entry point.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi again, I'm Anton - PHP/Symfony and Go, mostly writing about breaking a monolith into services without breaking the business. &lt;a href="https://github.com/brilliant-almazov" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt; argued for int8 + Snowflake as the primary key; &lt;a href="https://github.com/brilliant-almazov" rel="noopener noreferrer"&gt;Part 2&lt;/a&gt; took that id out of the building - hashids at the edge, composite tokens, tenant-controlled salt rotation, and every int64 as a string before it touches a browser. This part is the structural consequence of Part 2, and it's the one I actually think about most: &lt;strong&gt;once the public id and the internal id are different values, you have a seam, and a seam is something you can build on.&lt;/strong&gt; Running notes: &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;As always: these are my habits on one codebase, with the trade-offs I've actually paid. Not advice for yours.&lt;/p&gt;




&lt;h2&gt;
  
  
  The seam nobody planned
&lt;/h2&gt;

&lt;p&gt;Part 2 ended with a boundary drawn for defensive reasons: raw ints don't leave the building, so the gateway encodes on the way out and decodes on the way in. That was framed as a chore - a translation layer you maintain because browsers and attackers exist.&lt;/p&gt;

&lt;p&gt;Look at it again and it's not a chore. It's the only place in the system where &lt;strong&gt;every&lt;/strong&gt; request has to stop and be interpreted. Every public request carries an id. Every id has to be translated. So the encoding of the id is a checkpoint that nothing can route around - not a new endpoint someone added in a hurry, not a webhook, not a legacy path.&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%2Fgibdp15zbzwxibph0c3a.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%2Fgibdp15zbzwxibph0c3a.png" alt="Two surfaces separated by the identifier: the public side carries an opaque token, the internal side carries a raw int64" width="800" height="518"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's worth saying precisely, because "public API" and "internal API" get used as if they were two halves of one thing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Public surface&lt;/th&gt;
&lt;th&gt;Internal surface&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Callers&lt;/td&gt;
&lt;td&gt;browsers, third parties, anyone with the URL&lt;/td&gt;
&lt;td&gt;services you deploy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identifier&lt;/td&gt;
&lt;td&gt;opaque token&lt;/td&gt;
&lt;td&gt;raw &lt;code&gt;int64&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Threat model&lt;/td&gt;
&lt;td&gt;hostile by default&lt;/td&gt;
&lt;td&gt;trusted, network-isolated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shape leakage&lt;/td&gt;
&lt;td&gt;must reveal nothing&lt;/td&gt;
&lt;td&gt;reveals everything, that's the point&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contract stability&lt;/td&gt;
&lt;td&gt;you own it forever&lt;/td&gt;
&lt;td&gt;you can change it on Tuesday&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two surfaces, two threat models, two lifetimes. They are not tiers of the same API. And the identifier is already different between them - which means the identifier is already the marker of which surface you're on. &lt;strong&gt;A raw &lt;code&gt;int64&lt;/code&gt; arriving on the public surface is a bug by definition. An opaque token arriving on the internal surface is a bug by definition.&lt;/strong&gt; You get that check for free, forever, without writing it.&lt;/p&gt;

&lt;p&gt;The rest of this article is what you can build once you accept that seam as a first-class thing rather than a translation chore.&lt;/p&gt;




&lt;h2&gt;
  
  
  Give the public token a type
&lt;/h2&gt;

&lt;p&gt;Here's the change, and it's small: the public id is not just a token, it's a &lt;strong&gt;prefix plus a token&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  o_kZ9mQ2xR            an order
  c_7bQ0pLMn            a comment
  iv_9Kd2mQ7rX4         an invoice
  l_3nR8tW1yA6          a one-time link

  &amp;lt;prefix&amp;gt;&amp;lt;sep&amp;gt;&amp;lt;token&amp;gt;
      │       │     └ the hashid from Part 2 — opaque, salted, tenant-scoped
      │       └ a character that cannot appear in the token alphabet
      └ the entity TYPE, in the clear
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The token half is exactly Part 2: a salted hashid of one id or of a designed tuple. The prefix half is new, it's in the clear, and it's readable &lt;strong&gt;before you decode anything&lt;/strong&gt;.&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%2F0qus6l0pinf9gte7qrbo.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%2F0qus6l0pinf9gte7qrbo.png" alt="Anatomy of a public id: a type prefix in the clear, a separator, and the opaque salted token from Part 2" width="800" height="560"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The separator is not a detail
&lt;/h3&gt;

&lt;p&gt;The first thing that bites: if your prefix is &lt;code&gt;o&lt;/code&gt; and your token alphabet contains &lt;code&gt;o&lt;/code&gt;, you cannot parse &lt;code&gt;okZ9mQ2xR&lt;/code&gt; back into a prefix and a token. There are exactly two honest fixes and you have to pick one on day one, because changing it later invalidates every link in the wild:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A separator character&lt;/strong&gt; - &lt;code&gt;o_kZ9mQ2xR&lt;/code&gt;. Pick a character excluded from the hashid alphabet (&lt;code&gt;_&lt;/code&gt; is the usual one, since the default alphabets are alphanumeric). Costs one character, reads well, survives copy-paste and double-click selection in most terminals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A fixed-width prefix plus a restricted alphabet&lt;/strong&gt; - prefixes are always exactly two characters, and those characters are removed from the token alphabet. No separator, slightly shorter, but you've now got a rule two layers deep that a future engineer has to not break.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I use the separator. It's more legible in a URL, it's obvious what it is when a customer pastes one into a support ticket, and the "why is the alphabet weird" question never comes up.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the prefix actually buys
&lt;/h3&gt;

&lt;p&gt;The prefix means the gateway knows &lt;strong&gt;what kind of thing the token points at before it does any work&lt;/strong&gt;. Three things follow from that, and they compound:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Which salt cell decodes it.&lt;/strong&gt; Part 2's salt matrix is &lt;code&gt;(tenant × entity_type)&lt;/code&gt;. Without the prefix, the entity type has to come from the route - which is fine when the route is &lt;code&gt;/api/v1/orders/{id}&lt;/code&gt;, and useless when the route is &lt;code&gt;/api/v1/l/{token}&lt;/code&gt; or a generic &lt;code&gt;/api/v1/resolve/{id}&lt;/code&gt;. The prefix carries the type with the value instead of with the path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which handler owns it.&lt;/strong&gt; Type-first dispatch instead of path-first dispatch. More on this below - it's the part that makes a gateway simple instead of a router with a switch statement in it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-type replay stops being possible.&lt;/strong&gt; A token minted for an order won't decode under the comment salt - that was already true in Part 2. What the prefix adds is that the mismatch is caught &lt;em&gt;before&lt;/em&gt; the decode, and caught &lt;em&gt;explicitly&lt;/em&gt;: the route says "this position is an order", the prefix says &lt;code&gt;c&lt;/code&gt;, and the request dies at the door with no decode attempted, no salt-history walk, no audit noise. You've turned a silent decode failure into a stated contract violation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That third point is worth dwelling on. Without a prefix, "wrong type" and "wrong tenant" and "garbage string" all produce the same thing: a decode that doesn't resolve. You get one undifferentiated &lt;code&gt;404&lt;/code&gt;. With a prefix, you can tell a &lt;strong&gt;malformed&lt;/strong&gt; id from a &lt;strong&gt;wrong-type&lt;/strong&gt; id from an &lt;strong&gt;unknown-prefix&lt;/strong&gt; id, and those are three different signals. The first is a broken client, the second is either a bug or someone probing, the third is either a stale link or someone guessing at your type namespace. Same &lt;code&gt;404&lt;/code&gt; to the caller, three different lines in your logs.&lt;/p&gt;




&lt;h2&gt;
  
  
  The prefix map is configuration, not code
&lt;/h2&gt;

&lt;p&gt;This is the part that surprised me, and it's a direct inheritance from Part 2's salt rotation.&lt;/p&gt;

&lt;p&gt;The prefix isn't a constant in the codebase. It's a &lt;strong&gt;row in a per-tenant map&lt;/strong&gt;, editable from the admin panel, exactly like the salt:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Public prefix&lt;/th&gt;
&lt;th&gt;Internal type&lt;/th&gt;
&lt;th&gt;Salt cell&lt;/th&gt;
&lt;th&gt;Owning upstream&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;o&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;order&lt;/td&gt;
&lt;td&gt;(tenant × order)&lt;/td&gt;
&lt;td&gt;order service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;c&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;comment&lt;/td&gt;
&lt;td&gt;(tenant × comment)&lt;/td&gt;
&lt;td&gt;comment service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iv&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;invoice&lt;/td&gt;
&lt;td&gt;(tenant × invoice)&lt;/td&gt;
&lt;td&gt;billing service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;l&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;link&lt;/td&gt;
&lt;td&gt;(tenant × link)&lt;/td&gt;
&lt;td&gt;the gateway itself&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Change &lt;code&gt;o&lt;/code&gt; to &lt;code&gt;ord&lt;/code&gt; and the outward encoding changes. &lt;strong&gt;Nothing else moves.&lt;/strong&gt; The &lt;code&gt;BIGINT&lt;/code&gt; in the database is untouched, the internal type name is untouched, the gRPC field is untouched, the index is untouched. The same property that made salt rotation a customer-facing button makes the prefix map a customer-facing setting: because the stored id is a stable &lt;code&gt;BIGINT&lt;/code&gt;, everything on the outside of the seam is presentation.&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%2Fw34me9yndmmnb2qyndtx.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%2Fw34me9yndmmnb2qyndtx.png" alt="The tenant-editable prefix map: a public alias resolving to an internal type, a salt cell and an owning upstream" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Why would a tenant care? Mostly they don't, and the defaults ship as defaults. But two real reasons come up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Their public API is their product.&lt;/strong&gt; If a tenant exposes our objects through their own API to their own customers, the token is in &lt;em&gt;their&lt;/em&gt; documentation. &lt;code&gt;ord_...&lt;/code&gt; versus &lt;code&gt;o_...&lt;/code&gt; is their naming decision, not mine, and it costs me a config row to let them have it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The prefix is a deliberate alias, so it can be deliberately meaningless.&lt;/strong&gt; Which brings us to the next section.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  An anonymous public surface
&lt;/h2&gt;

&lt;p&gt;The public API should say &lt;strong&gt;nothing&lt;/strong&gt; about how the system is built. Not the numeric id, not the table name, not the internal route, not the service that owns it, not the number of services. That's not paranoia; it's just the honest consequence of the two-surface table above. The internal shape changes when I split a service or merge two; the public surface must not change when that happens, and it can't change if it never described the internal shape in the first place.&lt;/p&gt;

&lt;p&gt;The prefix is a &lt;strong&gt;chosen public alias for an internal type.&lt;/strong&gt; It does not have to resemble the internal name at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  PUBLIC (chosen alias)          INTERNAL (real shape)

    o_kZ9mQ2xR          →        order            → order service      → orders table
    c_7bQ0pLMn          →        comment          → comment service    → comments table
    x7_9Kd2mQ7rX4       →        billing_document → billing service    → invoices table
                │
                └ says nothing about "invoice", "billing", or which service answers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three levels of naming, and only the first one is a promise to the outside world. The internal type can be renamed, the service can be split in two, the table can be partitioned into six - the public alias &lt;code&gt;x7&lt;/code&gt; is stable through all of it, because it was never derived from any of them.&lt;/p&gt;

&lt;p&gt;The dull version of this rule, which is where most leaks actually live:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No numeric id anywhere public.&lt;/strong&gt; Covered in Part 2, still the main one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No internal type name in the public alias&lt;/strong&gt; unless you've decided it's fine forever - because once it's in a customer's stored links, it is forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No internal route shape.&lt;/strong&gt; The public path is &lt;code&gt;/api/v1/orders/{id}&lt;/code&gt;, not &lt;code&gt;/order-service/v2/orders/{id}&lt;/code&gt;. If your public paths mirror your service boundaries, every service split becomes a public API break.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No shape in the errors.&lt;/strong&gt; A constraint name, a table name, or a driver message in a &lt;code&gt;400&lt;/code&gt; body undoes all of the above in one line. This one gets me more often than the ids do, because it arrives through a generic error handler nobody looked at.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Routing on the prefix
&lt;/h2&gt;

&lt;p&gt;Because the type is readable before the decode, the gateway can dispatch on it. That's the difference between a gateway that is a &lt;strong&gt;table&lt;/strong&gt; and a gateway that is a &lt;strong&gt;switch statement growing one arm per feature&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// A public id is &amp;lt;prefix&amp;gt;&amp;lt;sep&amp;gt;&amp;lt;token&amp;gt;. Parsing is dumb on purpose: it splits,&lt;/span&gt;
&lt;span class="c"&gt;// it does not interpret. Interpretation needs the tenant's map, which is below.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;PublicID&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Prefix&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Token&lt;/span&gt;  &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// The prefix map is per tenant and editable, exactly like the salt history.&lt;/span&gt;
&lt;span class="c"&gt;// Current() is what we mint with; Retired() is the remap grace window (see below).&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Prefixes&lt;/span&gt;  &lt;span class="k"&gt;interface&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;For&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt; &lt;span class="n"&gt;TenantID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;PrefixMap&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;PrefixMap&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Current&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EntityType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// inbound: prefix -&amp;gt; type&lt;/span&gt;
    &lt;span class="n"&gt;Retired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EntityType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// inbound: a prefix we recently moved off&lt;/span&gt;
    &lt;span class="n"&gt;Alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;typ&lt;/span&gt; &lt;span class="n"&gt;EntityType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c"&gt;// outbound: type -&amp;gt; prefix, for minting&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// Routes maps an internal TYPE to the upstream that owns it. Note what is NOT&lt;/span&gt;
&lt;span class="c"&gt;// here: no path parsing, no per-entity handler registration, no switch.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Routes&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Upstream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;typ&lt;/span&gt; &lt;span class="n"&gt;EntityType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Upstream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Resolution is then one straight line with no branching on entity type anywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Resolve turns a public id into an internal reference. It answers exactly two&lt;/span&gt;
&lt;span class="c"&gt;// questions - which type, which ids - and refuses everything it cannot answer.&lt;/span&gt;
&lt;span class="c"&gt;// It deliberately does NOT answer "is the caller allowed to", see the next section.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Gateway&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt; &lt;span class="n"&gt;TenantID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;want&lt;/span&gt; &lt;span class="n"&gt;EntityType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;ParsePublicID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// split on the separator, nothing more&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Ref&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;ErrUnknownID&lt;/span&gt; &lt;span class="c"&gt;// malformed: not a shape we ever mint&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prefixes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;For&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;typ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Current&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Prefix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;typ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Retired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Prefix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Ref&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;ErrUnknownID&lt;/span&gt; &lt;span class="c"&gt;// unknown prefix: fail closed, always&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;audit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RetiredPrefixHit&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Tenant&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Prefix&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Prefix&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// The route declares what it expects; the token declares what it is.&lt;/span&gt;
    &lt;span class="c"&gt;// A mismatch is a contract violation, not a lookup miss - and it costs no decode.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;want&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;TypeAny&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;typ&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;want&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Ref&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;ErrWrongType&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// From here it is Part 2 unchanged: the (tenant × type) salt cell decodes it,&lt;/span&gt;
    &lt;span class="c"&gt;// walking the salt history and firing the old-salt tripwire on a retired hit.&lt;/span&gt;
    &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;codecs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;For&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;typ&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decoder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Ref&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;ErrUnknownID&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Ref&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Tenant&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;typ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IDs&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SaltAge&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SaltAge&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// Dispatch is a lookup, not a decision. Adding an entity type adds a row to the&lt;/span&gt;
&lt;span class="c"&gt;// routing table and touches no code in the gateway.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Gateway&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt; &lt;span class="n"&gt;Ref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Upstream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;up&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;routes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Upstream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrNoRoute&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things I want to point at in that code, because they're the whole reason I like this shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Resolve&lt;/code&gt; has no knowledge of any specific entity.&lt;/strong&gt; No &lt;code&gt;case TypeOrder:&lt;/code&gt;. Adding invoices is a config row and an upstream registration. The gateway does not grow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;want&lt;/code&gt; comes from the route, not from the token.&lt;/strong&gt; The token never gets to tell the gateway what it is on a typed route. &lt;code&gt;/api/v1/orders/{id}&lt;/code&gt; passes &lt;code&gt;TypeOrder&lt;/code&gt; and a comment token dies there. Only a deliberately generic route - a share link resolver, say - passes &lt;code&gt;TypeAny&lt;/code&gt;, and that route is exactly the one where the prefix is doing real work, because nothing else knows the type.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Two doors
&lt;/h2&gt;

&lt;p&gt;Here's the deployment consequence, and it's optional in a way the rest of this isn't.&lt;/p&gt;

&lt;p&gt;If the public surface and the internal surface are genuinely different surfaces, they can be &lt;strong&gt;different front doors&lt;/strong&gt; - a public gateway and an internal gateway, sitting behind an edge router that decides which door a request is even allowed to knock on. Public tokens and raw &lt;code&gt;int64&lt;/code&gt; traffic then never share an entry point.&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%2Fql3fkpb6f3uahowzas0p.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%2Fql3fkpb6f3uahowzas0p.png" alt="An edge router with two listeners: a public door that accepts only prefixed tokens and an internal door restricted to the mesh" width="799" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Shaped as an edge-router config, it's less clever than it sounds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ---- public door: opaque tokens only, open to the internet -------------------&lt;/span&gt;
&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;api.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# A public id is &amp;lt;prefix&amp;gt;_&amp;lt;token&amp;gt;. If it doesn't have that shape, it isn't&lt;/span&gt;
    &lt;span class="c1"&gt;# a public id, and the request never reaches an application.&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt; &lt;span class="sr"&gt;"^/api/v1/[a-z-]+/(?&amp;lt;prefix&amp;gt;[a-z]&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="kn"&gt;1,3&lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)_(?&amp;lt;token&amp;gt;[0-9A-Za-z]&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="kn"&gt;6,40&lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;$&lt;span class="s"&gt;"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://public_gateway&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt; &lt;span class="sr"&gt;"^/api/v1/"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://public_gateway&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# collection routes: no id in the path&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# ---- internal door: raw int64 traffic, not reachable from outside ------------&lt;/span&gt;
&lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;8443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;gateway.internal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;allow&lt;/span&gt; &lt;span class="mf"&gt;10.0&lt;/span&gt;&lt;span class="s"&gt;.0.0/8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                       &lt;span class="c1"&gt;# the mesh, and nothing else&lt;/span&gt;
    &lt;span class="kn"&gt;deny&lt;/span&gt;  &lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://internal_gateway&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What that buys, concretely: a raw &lt;code&gt;int64&lt;/code&gt; in a path can never reach the public gateway, because the public listener has no location that matches a bare number. Not "the gateway rejects it" - &lt;strong&gt;the request never becomes a request.&lt;/strong&gt; That's a different quality of guarantee than an application-level check, and it survives an application-level bug.&lt;/p&gt;

&lt;h3&gt;
  
  
  The thing to be careful about here
&lt;/h3&gt;

&lt;p&gt;Notice what the config above does &lt;em&gt;not&lt;/em&gt; do: it does not know your prefix map. It matches the &lt;em&gt;shape&lt;/em&gt; &lt;code&gt;[a-z]{1,3}_&amp;lt;token&amp;gt;&lt;/code&gt;, not the &lt;em&gt;set&lt;/em&gt; &lt;code&gt;{o, c, iv, l}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's on purpose, and it's the single most important line in this article's honest column. &lt;strong&gt;The prefix map is tenant-editable. An edge-router config is deployed.&lt;/strong&gt; The moment you key a deployed routing rule on a value a tenant can change from an admin panel, you have built a system where a customer action can break routing, and the failure will happen at the layer with the worst observability and the slowest rollback.&lt;/p&gt;

&lt;p&gt;So: the edge router separates &lt;strong&gt;doors&lt;/strong&gt; by shape. The gateway dispatches by &lt;strong&gt;prefix&lt;/strong&gt;, because the gateway is the thing that can read the map. Coarse where it's static, fine where it's dynamic. Cross that line and the tenant-editable value becomes a deploy dependency.&lt;/p&gt;




&lt;h2&gt;
  
  
  The honest part
&lt;/h2&gt;

&lt;p&gt;Everything above is mechanism. Here's what it isn't, in the same blunt register as "a hashid is not encryption" from Part 2.&lt;/p&gt;

&lt;h3&gt;
  
  
  A prefix is not authentication, and routing is not authorization
&lt;/h3&gt;

&lt;p&gt;The prefix tells you what kind of thing a token claims to be. That is &lt;strong&gt;all&lt;/strong&gt; it tells you. It is in the clear, it's guessable in about four attempts, and anyone can put &lt;code&gt;o_&lt;/code&gt; in front of a string.&lt;/p&gt;

&lt;p&gt;So the rule from Part 2 does not move an inch: &lt;strong&gt;after the decode, every request runs the tenant-scoped ownership check.&lt;/strong&gt; Does this authenticated caller have the right to this object? Not "does the row exist" - the dangerous case is exactly the one where the row &lt;em&gt;does&lt;/em&gt; exist and belongs to somebody else. Routing a request to the correct upstream is not a statement about who's allowed to be there; it's a statement about who should be asked.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;What it does NOT do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Edge router&lt;/td&gt;
&lt;td&gt;keeps public and internal traffic on separate listeners&lt;/td&gt;
&lt;td&gt;authenticate anybody&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prefix&lt;/td&gt;
&lt;td&gt;says which type, which salt cell, which upstream&lt;/td&gt;
&lt;td&gt;prove the token is real&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hashid decode&lt;/td&gt;
&lt;td&gt;turns a valid token into &lt;code&gt;int64&lt;/code&gt;(s)&lt;/td&gt;
&lt;td&gt;prove the caller may see them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ownership check&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;decides&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only the last row is a security control. Everything above it reduces the number of ways to reach the check, and none of it replaces the check.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fail closed, always
&lt;/h3&gt;

&lt;p&gt;A prefix that isn't in the map - unknown, retired past its window, or made up - must be rejected. Not "guessed at", not "tried against every salt cell until one decodes". That fallback sounds helpful and is precisely a type-confusion oracle: try &lt;code&gt;x_&amp;lt;token&amp;gt;&lt;/code&gt; against every type and one of them eventually resolves, and now the attacker knows what the token is.&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%2Fled4571p0o3as0l9cghg.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%2Fled4571p0o3as0l9cghg.png" alt="The resolution path for an incoming public id, with every rejection failing closed rather than probing other types" width="800" height="814"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;incoming public id      "o_kZ9mQ2xR"
        │
        ▼  split on the separator
   malformed? ────────────────────────────► 404   (never a shape we mint)
        │ no
        ▼  look up the prefix in THIS tenant's map
   current prefix?  ──yes──► type
        │ no
        ▼
   retired prefix?  ──yes──► type  +  🚨 AUDIT: retired-prefix hit
        │ no
        ▼
   unknown          ────────► 404   (fail closed — never probe other types)
        │
        ▼  route says TypeOrder, token says comment?
   type mismatch    ────────► 404   (contract violation, no decode attempted)
        │
        ▼  decode under the (tenant × type) salt cell  → Part 2, unchanged
        ▼  tenant-scoped ownership check               → Part 1, unchanged
        ▼  serve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  A tenant-editable routing key needs a window
&lt;/h3&gt;

&lt;p&gt;If a tenant renames &lt;code&gt;o&lt;/code&gt; to &lt;code&gt;ord&lt;/code&gt; on Tuesday, links minted on Monday still say &lt;code&gt;o&lt;/code&gt;. Same problem as salt rotation, same solution, and it has to be built at the same time as the feature - not after the first support ticket:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the map keeps &lt;strong&gt;retired prefixes&lt;/strong&gt; with the type they used to mean, exactly like the salt history keeps retired salts;&lt;/li&gt;
&lt;li&gt;inbound resolution tries current, then retired;&lt;/li&gt;
&lt;li&gt;outbound minting &lt;strong&gt;always&lt;/strong&gt; uses the current alias;&lt;/li&gt;
&lt;li&gt;a hit on a retired prefix is &lt;strong&gt;audited&lt;/strong&gt;, which - same as the old-salt tripwire in Part 2 - tells you how much of the wild is still on the old naming, and therefore when it's safe to actually drop it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you skip the retired map, a prefix rename is a hard break of every link in every email your customer ever sent. That is not a rotation, it's an outage with a settings page in front of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two gateways cost two of everything
&lt;/h3&gt;

&lt;p&gt;This is the trade-off I want to state plainly, because "separate the surfaces" reads like an unambiguous win and it is not.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;One gateway, strict internal split&lt;/th&gt;
&lt;th&gt;Two gateways behind an edge router&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Deploy paths&lt;/td&gt;
&lt;td&gt;one&lt;/td&gt;
&lt;td&gt;two, and they can skew&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Configs&lt;/td&gt;
&lt;td&gt;one&lt;/td&gt;
&lt;td&gt;two, and they drift&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The public/internal boundary&lt;/td&gt;
&lt;td&gt;enforced by code and review&lt;/td&gt;
&lt;td&gt;enforced by network topology&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A bug that leaks internal shape&lt;/td&gt;
&lt;td&gt;can reach the public surface&lt;/td&gt;
&lt;td&gt;cannot cross the listener&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost of adding an endpoint&lt;/td&gt;
&lt;td&gt;one place&lt;/td&gt;
&lt;td&gt;decide which door, then one place&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failure mode&lt;/td&gt;
&lt;td&gt;a missing check&lt;/td&gt;
&lt;td&gt;a mismatched pair of configs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two doors buys you a &lt;strong&gt;structural&lt;/strong&gt; guarantee instead of a &lt;strong&gt;procedural&lt;/strong&gt; one, and structural guarantees survive bad days. It costs you a second deploy path, a second config, and one more place for drift - and drift between two gateways is a genuinely unpleasant class of bug, because each half looks correct on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One gateway with a strict public/internal split inside it is a legitimate choice, and often the better one.&lt;/strong&gt; If your team is small, if your public surface is narrow, if the internal surface is only ever reached from inside the mesh anyway - one gateway with the two surfaces as separate route trees, separate middleware chains and separate listeners in the same process gets you most of it for a fraction of the operational weight. The seam is in the id either way. Where you put the wall around the seam is a capacity question, not a correctness question.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this is: capabilities, not a prescription
&lt;/h2&gt;

&lt;p&gt;Three parts in, the pattern of this series is probably clear: I keep showing machinery and then telling you not to ship all of it. That's deliberate, and it applies here more than anywhere, because everything in this part is optional in a way Parts 1 and 2 weren't.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Typed prefix.&lt;/strong&gt; Buys type-first dispatch, cheap wrong-type rejection, and generic resolver routes. Costs a parsing rule you can never change and one more concept for the next engineer. Worth it the moment you have more than one entity type on a shared public surface, or a single generic link-resolver route.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefixes as tenant configuration.&lt;/strong&gt; Buys tenants control of their own public naming. Costs a retired-prefix map, a rotation UI, and a rule about never keying deployed infrastructure on it. Only worth it if a tenant's public API is genuinely their product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An anonymous public surface.&lt;/strong&gt; Buys freedom to reshape the internals without breaking anyone. Costs nothing but discipline, and I'd argue it's the one item on this list that's close to unconditional - it's cheap in advance and impossible to retrofit once customers have your internal names in their code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two doors.&lt;/strong&gt; Buys a structural boundary. Costs two deploy paths, two configs, and a drift surface. Worth it when the cost of a leaked internal surface is high enough to justify running two things.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are plenty of ways to keep a public and an internal surface apart - separate deployments, mutual TLS on the internal side, an API-management layer in front, gRPC internally and REST externally with no shared handler code at all. &lt;strong&gt;This is the one built on the identifier&lt;/strong&gt;, and its whole argument is that the identifier already crosses the boundary on every request, so the boundary might as well be enforced there.&lt;/p&gt;

&lt;p&gt;The wall that isn't a dial, and never becomes one: the tenant-scoped ownership check on every request. Everything else on this page is a trade-off you get to make on purpose.&lt;/p&gt;




&lt;h2&gt;
  
  
  The judgment is still the human part
&lt;/h2&gt;

&lt;p&gt;Same note I closed Part 2 on, because three parts of machinery haven't changed it. Ask an assistant to design a public API and you'll get sensible-looking routes with numeric ids in them, internal type names in the paths, one gateway serving both surfaces, and error bodies that name your database constraints. Fast, clean, demos beautifully. It won't tell you, unprompted, that the identifier is the seam, that a prefix makes the token self-describing before the decode, that keying an edge-router config on a tenant-editable value builds a customer-triggered outage, or that an unknown prefix must fail closed rather than politely try every salt cell.&lt;/p&gt;

&lt;p&gt;The mechanics are cheap. The judgment about which of these to actually run - and what each one costs you at 3am - is the part that's still yours.&lt;/p&gt;

&lt;p&gt;If you build serious backends, this is the kind of decision I keep writing up. Follow along on &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;. And if your public API and your internal API are the same API right now: &lt;em&gt;what would it cost you to change the shape of one service?&lt;/em&gt; I'd genuinely like to compare notes.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>security</category>
      <category>api</category>
      <category>go</category>
    </item>
    <item>
      <title>Snowflake IDs - Part 2: Across the Wire, the URL, and the Browser</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Wed, 19 Aug 2026 11:27:00 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/snowflake-ids-part-2-across-the-wire-the-url-and-the-browser-42kk</link>
      <guid>https://dev.to/anton_brilliantov/snowflake-ids-part-2-across-the-wire-the-url-and-the-browser-42kk</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 1 settled the primary key: a 64-bit Snowflake in a &lt;code&gt;BIGINT&lt;/code&gt;, generated at the edge. This part is about what happens when that id has to leave the trust boundary - into a public URL, a third-party API, a browser. Raw ints don't go out the door, a browser can't count that high without corrupting them, and a leaked link has to be revocable. Here's the whole boundary: hashids at the edge, composite tokens that carry a whole ownership chain, tenant-controlled salt rotation with a leak tripwire, and the one dull rule that saves your data - never send a naked int64 to a browser.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi again, I'm Anton - PHP/Symfony and Go, mostly writing about breaking a monolith into services without breaking the business. &lt;a href="https://github.com/brilliant-almazov" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt; argued for int8 + Snowflake as the primary key and promised the harder half: &lt;strong&gt;moving that id across boundaries.&lt;/strong&gt; That's this post. As in Part 1, I'm not writing any of these primitives by hand - hashids/Sqids libraries exist in every language; this is about &lt;em&gt;where&lt;/em&gt; the translation lives and &lt;em&gt;why&lt;/em&gt;. Running notes: &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;Inside the mesh, the id is just an &lt;code&gt;int64&lt;/code&gt; and life is simple. The trouble is entirely at the edges, and there are exactly three of them: the &lt;strong&gt;service boundary&lt;/strong&gt; (fine, send the int), the &lt;strong&gt;public boundary&lt;/strong&gt; (never send the int), and the &lt;strong&gt;browser&lt;/strong&gt; (can't even hold the int). Get any one wrong and you leak growth numbers, hand out IDOR, or silently corrupt the wrong record.&lt;/p&gt;




&lt;h2&gt;
  
  
  Inside the trust boundary: raw int8, no games
&lt;/h2&gt;

&lt;p&gt;Between services, the ID is just an &lt;code&gt;int64&lt;/code&gt;. gRPC carries it as &lt;code&gt;int64&lt;/code&gt;, the proto field is &lt;code&gt;int64&lt;/code&gt;, one service hands &lt;code&gt;order_id = 7239344029703798784&lt;/code&gt; to the next and nobody obfuscates anything. Inside the mesh there's no attacker to hide from and every layer of encoding is just latency and confusion. Raw ints flow freely behind the wall.&lt;/p&gt;

&lt;p&gt;The public surface is where it all changes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Outward: raw ints never leave the building
&lt;/h2&gt;

&lt;p&gt;On the public API, in URLs, in anything a browser or a third party sees, &lt;strong&gt;the raw int never appears.&lt;/strong&gt; The one exception is an admin panel that already sits behind authentication - staff can see the real id because they've earned the right to. Everyone else gets a &lt;strong&gt;hashid&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A hashid is a reversible, &lt;strong&gt;salted&lt;/strong&gt; encoding of an &lt;code&gt;int64&lt;/code&gt; into a short opaque string - &lt;code&gt;7239344029703798784&lt;/code&gt; becomes something like &lt;code&gt;kZ9mQ2xR&lt;/code&gt;. Encode on the way out, decode on the way in. Off-the-shelf, every language: the classic is &lt;strong&gt;hashids&lt;/strong&gt; (&lt;code&gt;speps/go-hashids&lt;/code&gt; in Go, &lt;code&gt;hashids/hashids&lt;/code&gt; or &lt;code&gt;vinkla/hashids&lt;/code&gt; in PHP); the maintained successor is &lt;strong&gt;Sqids&lt;/strong&gt; (&lt;code&gt;sqids/sqids-go&lt;/code&gt;, &lt;code&gt;sqids/sqids-php&lt;/code&gt;). Don't write your own - and notice the important property both share: &lt;strong&gt;they encode a &lt;em&gt;list&lt;/em&gt; of numbers, not just one.&lt;/strong&gt; Hold that thought, it's the composite trick below.&lt;/p&gt;

&lt;p&gt;The salt isn't one global value - it's scoped &lt;strong&gt;per tenant, and within a tenant, per entity type.&lt;/strong&gt; An &lt;code&gt;order&lt;/code&gt; has a different salt from a &lt;code&gt;project&lt;/code&gt;, which differs from a &lt;code&gt;user&lt;/code&gt;, and all of them differ across tenants. So the same internal id encodes to a different string for each customer &lt;em&gt;and&lt;/em&gt; each type - no cross-tenant correlation, no cross-type correlation, no "tenant A's id 5 looks like tenant B's id 5." Think of it as a small matrix of salts, &lt;code&gt;(tenant × entity_type)&lt;/code&gt;, with a tenant-level master salt above it - and every cell in that matrix is independently rotatable. More on why that granularity matters right below.&lt;/p&gt;

&lt;p&gt;These are the &lt;strong&gt;clean URLs&lt;/strong&gt; the outside world sees - opaque, tenant-scoped, no naked integers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                        PUBLIC (browser / API)              INTERNAL (mesh)
  single entity   GET /api/v1/orders/kZ9mQ2xR        -&amp;gt;     order_id = 7239344029703798784
  another tenant  GET /api/v1/orders/7bQ0pLMn        -&amp;gt;     order_id = 7239344029703798784   (same id, different salt)
  admin (authed)  shows  kZ9mQ2xR  =  7239344029703798784   (raw id on purpose, behind login)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same row, different faces. The public gets the hashid, the mesh gets the int, and the only place both are shown side by side is behind a login.&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%2F3odt2nokfplxv71phsef.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%2F3odt2nokfplxv71phsef.png" alt="Clean URLs with per-tenant hashids mapping to raw int64 order ids; an admin row shows both side by side" width="799" height="329"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A public id is not only a hashed number - it can name its own type
&lt;/h2&gt;

&lt;p&gt;Everything above shows a &lt;em&gt;bare&lt;/em&gt; token: an opaque string and nothing else, with the URL path (&lt;code&gt;/orders/…&lt;/code&gt;) doing the job of saying what kind of thing it points at. There's a better shape. The outward id can be &lt;strong&gt;&lt;code&gt;&amp;lt;prefix&amp;gt;&amp;lt;token&amp;gt;&lt;/code&gt;&lt;/strong&gt; - a short letter or two in front that names the &lt;strong&gt;entity type&lt;/strong&gt;, and then the salted hashid.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;o&lt;/code&gt; for an order, &lt;code&gt;c&lt;/code&gt; for a comment, &lt;code&gt;p&lt;/code&gt; for a project, &lt;code&gt;u&lt;/code&gt; for a user, &lt;code&gt;inv&lt;/code&gt; for an invoice. The orders example I've been using is just an example: &lt;strong&gt;nothing here is orders-specific.&lt;/strong&gt; Any entity type you expose on the public surface gets its own prefix, and the prefix is the only thing that differs 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;  bare token       kZ9mQ2xR         the TYPE comes from the route
  prefixed token   o·kZ9mQ2xR       the TYPE comes from the ID itself
                   │ └───────────── the salted hashid: one id, or a whole tuple
                   └─────────────── public alias for an internal entity type

  o kZ9mQ2xR   order     -&amp;gt;  decode with the (tenant × order)   salt cell
  c 7bQ0pLMn   comment   -&amp;gt;  decode with the (tenant × comment) salt cell
  p 4hT1nW8s   project   -&amp;gt;  decode with the (tenant × project) salt cell

  one generic route is now enough:  GET /api/v1/r/o kZ9mQ2xR
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The prefix makes the id self-describing at the edge
&lt;/h3&gt;

&lt;p&gt;This reverses the usual order of operations. Normally you decode first and &lt;em&gt;then&lt;/em&gt; discover what you're holding. With a prefix, the gateway knows three things from the first character, &lt;strong&gt;before it decodes anything&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;what kind of object&lt;/strong&gt; the token points at,&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;which salt cell&lt;/strong&gt; to decode it with - the &lt;code&gt;(tenant × entity_type)&lt;/code&gt; cell from the matrix above,&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;which handler&lt;/strong&gt; owns it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And it closes a whole class of confusion for free: &lt;strong&gt;a token minted for one type cannot be replayed as another.&lt;/strong&gt; Feed an &lt;code&gt;o…&lt;/code&gt; token to the comment path and two independent things disagree at once - the prefix says &lt;em&gt;order&lt;/em&gt;, and the comment salt cell doesn't decode it anyway. There's no "close enough" outcome; it fails closed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// The prefix is read BEFORE the decode: it selects the salt cell and the owner.&lt;/span&gt;
&lt;span class="c"&gt;// The mapping is CONFIG, resolved per tenant - never a switch statement in code.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Gateway&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;DecodePublic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt; &lt;span class="n"&gt;TenantID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;publicID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EntityType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;prefixes&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prefixes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;For&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;prefixes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;publicID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// "okZ9mQ2xR" -&amp;gt; "o", "kZ9mQ2xR"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrUnknownID&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;typ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;prefixes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// "o" -&amp;gt; order, "c" -&amp;gt; comment (this tenant's map)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrUnknownID&lt;/span&gt; &lt;span class="c"&gt;// unknown prefix: reject, never try the other cells&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// Same resolution chain as before - the prefix just told us which cell to use.&lt;/span&gt;
    &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;codecs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;For&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;typ&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decoder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrUnknownID&lt;/span&gt; &lt;span class="c"&gt;// prefix and salt cell disagree -&amp;gt; not ours&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SaltAge&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Accepts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;typ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SaltAge&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrLinkRevoked&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;typ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="c"&gt;// authorization still runs after this. Always.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Prefixes are configuration, not code
&lt;/h3&gt;

&lt;p&gt;The prefix map is &lt;strong&gt;data, not a deploy.&lt;/strong&gt; It lives per tenant and is editable from the admin panel - the same self-serve surface that already rotates salts in this article. One tenant's orders are &lt;code&gt;o&lt;/code&gt;, another's are &lt;code&gt;x7&lt;/code&gt;, and neither needed an engineer.&lt;/p&gt;

&lt;p&gt;That's safe for exactly the reason rotation is safe: &lt;strong&gt;changing a prefix changes only the outward encoding, never the stored &lt;code&gt;BIGINT&lt;/code&gt;.&lt;/strong&gt; No migration, no data touched, no deploy. It's the same property the whole design keeps cashing in - the id underneath is a stable int8, so everything about how it &lt;em&gt;looks&lt;/em&gt; outside is a configuration surface.&lt;/p&gt;

&lt;p&gt;With the same obligation attached: &lt;strong&gt;a prefix map needs the same history discipline as a salt.&lt;/strong&gt; Links already in the wild carry the &lt;em&gt;old&lt;/em&gt; prefix, so a renamed prefix has to stay resolvable, with the same three strategies as a retired salt - &lt;em&gt;accept + audit&lt;/em&gt;, &lt;em&gt;grace window&lt;/em&gt;, &lt;em&gt;hard cutoff&lt;/em&gt;. Rename a prefix without keeping the old mapping and you've broken every bookmark, exactly as a destructive salt rotation would.&lt;/p&gt;

&lt;h3&gt;
  
  
  This is how the public surface becomes anonymous
&lt;/h3&gt;

&lt;p&gt;Add it all up and the public API stops describing your system. It doesn't expose the numeric id, it doesn't expose the table, it doesn't expose the internal route or the internal type name. What goes out is &lt;strong&gt;a public alias for an internal type&lt;/strong&gt; - deliberately chosen, tenant-scoped, and swappable at will.&lt;/p&gt;

&lt;p&gt;Be honest about the kind of protection that is: it's anonymity of &lt;em&gt;shape&lt;/em&gt;, not secrecy. Whoever holds a link can see its prefix and, with a handful of links, work out that &lt;code&gt;o&lt;/code&gt; and &lt;code&gt;c&lt;/code&gt; are two different kinds of thing. What they can't see is &lt;em&gt;which&lt;/em&gt; things, how many there are, what the storage looks like, or whether two tenants' &lt;code&gt;o&lt;/code&gt; means the same type at all. It hides your internals from a stranger reading a URL; it is not a secret channel, and - like everything else on this page - it is not authorization.&lt;/p&gt;




&lt;h2&gt;
  
  
  Composite hashids: put the whole ownership chain in one token
&lt;/h2&gt;

&lt;p&gt;Here's the trick the "list of numbers" property unlocks, and it's my favourite. You don't have to encode just one id. Pack the &lt;strong&gt;master and the child together&lt;/strong&gt; - &lt;code&gt;[project_id, config_id]&lt;/code&gt;, &lt;code&gt;[order_id, line_id]&lt;/code&gt; - into a &lt;strong&gt;single&lt;/strong&gt; opaque token.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  naive (two separate hashids)   GET /api/v1/projects/kZ9mQ2xR/configs/7bQ0pLMn
                                 two tokens, two decodes, two lookups, easy to mismatch

  composite (one token)          GET /api/v1/c/9Kd2mQ7rX4
                                       │  decode -&amp;gt; [project_id, config_id]
                                       ▼
                                 the token carries the WHOLE chain
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On decode you get the whole chain back, so the gateway validates &lt;strong&gt;top-down before any handler runs&lt;/strong&gt;: does the &lt;em&gt;master&lt;/em&gt; exist and belong to this tenant? then is the &lt;em&gt;child&lt;/em&gt; actually under that master? A tampered or mismatched pair fails the &lt;code&gt;master -&amp;gt; child&lt;/code&gt; check at the edge - the handler never sees an inconsistent pair.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Encoder and decoder are BOTH interfaces, reached through a resolution CHAIN.&lt;/span&gt;
&lt;span class="c"&gt;// You never touch a salt directly - you resolve down to the thing that owns it:&lt;/span&gt;
&lt;span class="c"&gt;//   codecs.For(tenant)           -&amp;gt; the tenant's codec set&lt;/span&gt;
&lt;span class="c"&gt;//          .Type(entityType)     -&amp;gt; the (tenant × type) salt cell&lt;/span&gt;
&lt;span class="c"&gt;//          .Encoder() / .Decoder()&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Codecs&lt;/span&gt;       &lt;span class="k"&gt;interface&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;For&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt; &lt;span class="n"&gt;TenantID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;TenantCodecs&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;TenantCodecs&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;typ&lt;/span&gt; &lt;span class="n"&gt;EntityType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;TypeCodec&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;TypeCodec&lt;/span&gt;    &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Encoder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;Encoder&lt;/span&gt; &lt;span class="c"&gt;// encodes with the CURRENT salt&lt;/span&gt;
    &lt;span class="n"&gt;Decoder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;Decoder&lt;/span&gt; &lt;span class="c"&gt;// decodes across the salt HISTORY (+ audit)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Encoder&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Decoder&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DecodeMeta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c"&gt;// meta carries SaltAge&lt;/span&gt;

&lt;span class="c"&gt;// A composite [master, child] is keyed by its MASTER type; the route supplies&lt;/span&gt;
&lt;span class="c"&gt;// the types, they're never guessed. childType names what the 2nd id must be.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Gateway&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;DecodeScoped&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;tenant&lt;/span&gt; &lt;span class="n"&gt;TenantID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;masterType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;childType&lt;/span&gt; &lt;span class="n"&gt;EntityType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Scope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// resolve the chain: tenant -&amp;gt; master type -&amp;gt; decoder for that (tenant × type) cell.&lt;/span&gt;
    &lt;span class="n"&gt;dec&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;codecs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;For&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;masterType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decoder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;dec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// a token minted for another type/tenant won't decode&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Scope&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;ErrUnknownID&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SaltAge&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Accepts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;masterType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SaltAge&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Scope&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;ErrLinkRevoked&lt;/span&gt; &lt;span class="c"&gt;// retired-salt strategy; audit already fired in Decode&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;master&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="c"&gt;// Ownership is an authorization, not an existence check (see next section).&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;owns&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;masterType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;master&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;    &lt;span class="c"&gt;// master is THIS tenant's, THIS type?&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Scope&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;ErrForbidden&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;owns&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ChildOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;master&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;childType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c"&gt;// child is childType UNDER that master?&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Scope&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;ErrForbidden&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Scope&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;MasterType&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;masterType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Master&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;master&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ChildType&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;childType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Child&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// route wiring makes the types explicit, never guessed:&lt;/span&gt;
&lt;span class="c"&gt;//   GET /api/v1/projects/{token}/... -&amp;gt; DecodeScoped(tenant, TypeProject, TypeConfig, token)&lt;/span&gt;
&lt;span class="c"&gt;//   encoding is the same chain: g.codecs.For(tenant).Type(TypeOrder).Encoder().Encode(orderID)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One token, one decode, one ownership chain proven before the handler starts. That's a lot of IDOR surface closed at the door.&lt;/p&gt;

&lt;h3&gt;
  
  
  A token is a tuple you design - not just a pair
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;[master, child]&lt;/code&gt; is only the simplest case. The real leverage is that a hashid encodes a &lt;strong&gt;list&lt;/strong&gt; of numbers, so &lt;em&gt;you design the schema&lt;/em&gt; - pack whatever the edge needs to make one decision from one token. "Do everything through a single link": the URL carries the whole context and the gateway rebuilds it in one decode, with no extra lookups just to learn &lt;em&gt;what&lt;/em&gt; the token even points at.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;one token  =  a designed tuple of numbers, not a single id

  [ tenant_id | resource_type | resource_id | expiry_ts | nonce ]
       │             │              │             │          └ one-time guard
       │             │              │             └ self-expiry
       │             │              └ what it points at
       │             └ which KIND of thing (order? invoice? export?)
       └ who it belongs to  →  checked before anything else
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Scenario: a self-describing, one-time link
&lt;/h3&gt;

&lt;p&gt;The link that must (a) belong to a tenant, (b) point at exactly one resource, (c) expire, and (d) work exactly once - a share / download / magic link. Put all of it in the token:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;GET /api/v1/l/9Kd2mQ7rX4kP…&lt;/code&gt;  →  decode  →  &lt;code&gt;[tenant_id, resType, resID, expiry, nonce]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;and the gateway settles all four checks before any handler runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// One self-describing token carries the whole decision. Decode once, check four&lt;/span&gt;
&lt;span class="c"&gt;// things, serve. The token is a CARRIER; the security is in the server checks.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Gateway&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OpenLink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;caller&lt;/span&gt; &lt;span class="n"&gt;TenantID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Resource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;codecs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;For&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TypeLink&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decoder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Resource&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;ErrUnknownID&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resID&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;TenantID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="n"&gt;EntityType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;expiry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nonce&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tenant&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;caller&lt;/span&gt;                    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Resource&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;ErrForbidden&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c"&gt;// (a) ownership&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;After&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expiry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Resource&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;ErrExpired&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c"&gt;// (c) expiry&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nonces&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Burn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Resource&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;ErrUsed&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;      &lt;span class="c"&gt;// (d) one-time&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                 &lt;span class="c"&gt;// (b) the resource&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Be blunt about what the token is and isn't.&lt;/strong&gt; A hashid/Sqids token is &lt;strong&gt;obfuscation, not a signature.&lt;/strong&gt; It compresses a tuple into an opaque string; it does &lt;em&gt;not&lt;/em&gt; prove the tuple wasn't forged by someone who knows the (public) algorithm and guesses the salt. So the &lt;em&gt;security&lt;/em&gt; of a one-time link never rests on the encoding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;one-time-ness&lt;/strong&gt; comes from the server-side &lt;code&gt;nonce&lt;/code&gt; &lt;strong&gt;burn&lt;/strong&gt;, not from the token;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;expiry&lt;/strong&gt; is enforced by comparing to &lt;em&gt;your&lt;/em&gt; clock, never trusted from the number;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ownership&lt;/strong&gt; is the authz check from the next section;&lt;/li&gt;
&lt;li&gt;if the link must be &lt;strong&gt;tamper-proof&lt;/strong&gt; (a capability an attacker must not forge), carry a real &lt;strong&gt;HMAC&lt;/strong&gt; alongside - or use a signed token (PASETO / JWT) for that part. The hashid is the compact envelope; the MAC is the lock.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The composite token is a superb &lt;em&gt;carrier&lt;/em&gt; - one URL, all the context, one decode. It is not, by itself, a security mechanism.&lt;/p&gt;

&lt;h3&gt;
  
  
  Even strings fit: it's numbers all the way down
&lt;/h3&gt;

&lt;p&gt;Hashids encode numbers - but &lt;em&gt;any string is numbers&lt;/em&gt;, so a short string can ride in the same token. Two standard routes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hex / bytes.&lt;/strong&gt; hashids has &lt;code&gt;encodeHex&lt;/code&gt; / &lt;code&gt;decodeHex&lt;/code&gt;: feed it a hex string (a UUID's hex, a short byte blob) and it round-trips - the clean way to carry a UUID inside a composite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Char codes.&lt;/strong&gt; Map each character to its code point and encode the list. In PHP that's &lt;code&gt;ord()&lt;/code&gt; per char (&lt;code&gt;chr()&lt;/code&gt; to reverse); in Go a &lt;code&gt;[]byte&lt;/code&gt; / &lt;code&gt;[]rune&lt;/code&gt;. A short slug or status code becomes just more numbers in the tuple.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// PHP: a short string -&amp;gt; numbers -&amp;gt; hashid, and back&lt;/span&gt;
&lt;span class="nv"&gt;$nums&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ord'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str_split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"OPEN"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;      &lt;span class="c1"&gt;// [79, 80, 69, 78]&lt;/span&gt;
&lt;span class="nv"&gt;$token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$hashids&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="nv"&gt;$nums&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$back&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;implode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'chr'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$hashids&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;  &lt;span class="c1"&gt;// "OPEN"&lt;/span&gt;
&lt;span class="c1"&gt;// or, for a UUID / raw bytes as hex:&lt;/span&gt;
&lt;span class="nv"&gt;$token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$hashids&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;encodeHex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$uuidHex&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// decodeHex() to reverse&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep it &lt;strong&gt;tiny&lt;/strong&gt;, though: a hashid grows with the count and size of the numbers, so a composite token is for &lt;strong&gt;ids, type codes, timestamps, short flags&lt;/strong&gt; - not for stuffing blobs. The moment you're tempted to encode a paragraph, you want a real signed token or a lookup key, not a hashid. "Numbers all the way down" is a neat trick; the discipline is keeping the tuple small.&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%2Fcvoq4brt9uatutq6kmbi.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%2Fcvoq4brt9uatutq6kmbi.png" alt="One clean URL carrying a composite token that decodes to a master/child chain, validated top-down before any handler" width="800" height="424"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  In a multitenant system, every lookup is an authorization - not an existence check
&lt;/h2&gt;

&lt;p&gt;This is the rule the hashid and the composite token both lean on, and it has to be said explicitly because it's the one that actually keeps tenants apart. &lt;strong&gt;Decoding an id to an &lt;code&gt;int64&lt;/code&gt; tells you &lt;em&gt;which&lt;/em&gt; row; it never tells you the caller is &lt;em&gt;allowed&lt;/em&gt; to see it.&lt;/strong&gt; So every request, after decode, answers a second question before the handler runs: &lt;em&gt;does this authenticated caller (this tenant, this user) have the right to this object?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"Found vs not found" is not the check. In a multitenant system the dangerous case is exactly the one where the row &lt;strong&gt;is&lt;/strong&gt; found - it's a perfectly real &lt;code&gt;order&lt;/code&gt;, it just belongs to a &lt;em&gt;different tenant&lt;/em&gt;. If your lookup is &lt;code&gt;SELECT ... WHERE id = ?&lt;/code&gt; you've already leaked it; the query has to be &lt;code&gt;WHERE id = ? AND tenant_id = ?&lt;/code&gt; (and for a child, &lt;code&gt;AND &amp;lt;master&amp;gt;_id = ?&lt;/code&gt;), or the authz has to be a separate, explicit gate that runs regardless. Same for the composite: the &lt;code&gt;master → child&lt;/code&gt; validation earlier isn't "do these ids exist," it's "does &lt;em&gt;this tenant&lt;/em&gt; own the master, and is the child &lt;em&gt;under that master&lt;/em&gt;." A wrong-tenant id that happens to exist must come back &lt;strong&gt;403/404, never the object.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The unguessable Snowflake and the salted hashid lower the odds anyone &lt;em&gt;reaches&lt;/em&gt; a foreign id; the tenant-scoped authorization check is what makes reaching it harmless. First wall reduces attempts, second wall is the one that must never be missing - exactly the "an id is never authorization" point from Part 1, made concrete for multitenancy.&lt;/p&gt;




&lt;h2&gt;
  
  
  Salt rotation - self-serve, with a history so old links still work
&lt;/h2&gt;

&lt;p&gt;Salts get rotated: a leak, a policy, periodic hygiene. And here's a benefit that falls straight out of int8 + Snowflake: because the underlying id is a &lt;strong&gt;stable &lt;code&gt;BIGINT&lt;/code&gt;&lt;/strong&gt;, rotating the salt changes only the &lt;em&gt;outward encoding&lt;/em&gt;, never the data. So rotation can be a &lt;strong&gt;tenant self-service action&lt;/strong&gt; - a button in their admin panel. No engineering, no migration, no deploy: the tenant rotates their own salt and every new link they hand out uses it.&lt;/p&gt;

&lt;p&gt;That makes leaked links &lt;strong&gt;revocable by the customer who owns them.&lt;/strong&gt; But rotating naively would break every bookmark and emailed link at once - so I don't rotate destructively. I keep a &lt;strong&gt;salt history&lt;/strong&gt;: current salt first, then every retired salt in order. On the way &lt;em&gt;out&lt;/em&gt;, always encode with the current salt. On the way &lt;em&gt;in&lt;/em&gt;, try current first; if it doesn't decode, try each historical salt until one does. Old links keep working; new links use the new salt.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rotate anything, at any level - and always keep the history
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;(tenant × entity_type)&lt;/code&gt; matrix plus the tenant master salt is the whole point: &lt;strong&gt;everything is rotatable, at whatever granularity the situation needs.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The tenant master salt&lt;/strong&gt; - a nuclear rotate: re-scopes every type at once (a suspected full-account leak).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One entity type's salt&lt;/strong&gt; - just &lt;code&gt;order&lt;/code&gt; links leaked in an export? Rotate the &lt;code&gt;order&lt;/code&gt; cell for that tenant and nothing else moves; &lt;code&gt;project&lt;/code&gt; and &lt;code&gt;user&lt;/code&gt; links keep resolving.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A single object&lt;/strong&gt; - narrow blast radius when exactly one link is known-bad.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TENANT  acme-co
  master salt  ───────────────────────────────────────────────  rotate ⟳  (re-scopes everything)
     │
     ├── entity type: order    salt cell = [ current | v3 | v2 | v1 ]   rotate ⟳  (only order links move)
     ├── entity type: project  salt cell = [ current | v2 | v1 ]        rotate ⟳
     └── entity type: user     salt cell = [ current | v1 ]             rotate ⟳
                                             │        └─────────── retired: kept for back-compat + stats
                                             └── outward encoding only — the BIGINT id never moves

  salts are a (tenant × entity_type) matrix, each CELL keeps its own history.
  rotate any cell, or the master, or one object — always allowed, never touches data.
  per-cell strategy for retired salts:  accept+audit · grace window · hard cutoff
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the id underneath is a stable &lt;code&gt;BIGINT&lt;/code&gt;, none of these touch data - it's always only the outward encoding. Rotate the master, rotate one type, rotate one thing: always allowed, always cheap.&lt;/p&gt;

&lt;p&gt;And &lt;strong&gt;historicity is mandatory, not optional.&lt;/strong&gt; Every retired salt is kept - at every level - for two reasons: old links keep resolving (backward compatibility), and the audit trail below turns each retired salt into &lt;em&gt;statistics&lt;/em&gt; on who's still using stale links. Whether a given retired salt &lt;strong&gt;still decodes&lt;/strong&gt; is a per-level &lt;strong&gt;strategy&lt;/strong&gt;, and it's worth making explicit and configurable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;accept + audit&lt;/em&gt; - old salt still resolves, every hit logged (default; maximum backward-compat, full visibility);&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;grace window&lt;/em&gt; - old salt resolves for N days after rotation, then hard-stops;&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;hard cutoff&lt;/em&gt; - old salt rejected immediately (a real leak you want dead now), but still &lt;strong&gt;recorded&lt;/strong&gt; so you can see the leaked link being hammered.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Different levels can run different strategies (master salt on &lt;em&gt;hard cutoff&lt;/em&gt;, a noisy &lt;code&gt;order&lt;/code&gt; type on &lt;em&gt;grace window&lt;/em&gt;). The rule that never bends: a retired salt is &lt;strong&gt;never silently forgotten&lt;/strong&gt; - it's kept, and its use is always recorded, if only for the stats.&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%2Fd906veadjuu0za5s4h6k.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%2Fd906veadjuu0za5s4h6k.png" alt="A tenant by entity_type salt matrix: a tenant master salt over per-type cells, each cell holding its own history from current back to v1" width="800" height="333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The point: every retired salt becomes a tripwire
&lt;/h2&gt;

&lt;p&gt;Here's the part that turns a boring rotation mechanism into a security signal. When an incoming link decodes &lt;strong&gt;only under an old, retired salt - never the current one&lt;/strong&gt; - that's information. Someone is walking a link minted before the last rotation: a stale bookmark, an old email, or a link that leaked and is being replayed. Rotation &lt;em&gt;without&lt;/em&gt; audit is blind. Rotation &lt;em&gt;with&lt;/em&gt; audit turns each retired salt into a &lt;strong&gt;tripwire&lt;/strong&gt;: the moment a stale or leaked link is used, you get an audit event with the tenant, the token, and &lt;em&gt;how old&lt;/em&gt; the salt was. Self-serve rotation gives the tenant the revoke button; the audit tells them who's still knocking with the old key.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;incoming public id   "kZ9mQ2xR"
        │
        ▼
 ┌──────────────────────────┐
 │  gateway: decode hashid  │
 └──────────────────────────┘
        │  try salts in order
        ▼
   current salt   ──decodes?──►  int64   →  normal request        (no event)
        │ no
        ▼
   salt v(n-1)    ──decodes?──►  int64   →  serve  +  🚨 AUDIT: old-salt hit
        │ no
        ▼
   salt v(n-2)    ──decodes?──►  int64   →  serve  +  🚨 AUDIT: old-salt hit
        │ no
        ▼
   no salt matches  →  reject (404)  —  not a link we ever minted

  decoded ONLY under a retired salt  =  a stale / leaked / bookmarked
  link still in the wild.  Rotation without audit learns nothing.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// The concrete cell that .Encoder()/.Decoder() hand back. It IS both interfaces&lt;/span&gt;
&lt;span class="c"&gt;// for one (tenant × type) pair: the encoder uses the current salt, the decoder&lt;/span&gt;
&lt;span class="c"&gt;// walks the whole history. Nothing outside sees a salt - only Encode/Decode.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;typeCodec&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;tenant&lt;/span&gt; &lt;span class="n"&gt;TenantID&lt;/span&gt;
    &lt;span class="n"&gt;typ&lt;/span&gt;    &lt;span class="n"&gt;EntityType&lt;/span&gt;
    &lt;span class="n"&gt;salts&lt;/span&gt;  &lt;span class="n"&gt;SaltHistory&lt;/span&gt; &lt;span class="c"&gt;// newest-first: [current, v(n-1), v(n-2), ...]&lt;/span&gt;
    &lt;span class="n"&gt;audit&lt;/span&gt;  &lt;span class="n"&gt;AuditSink&lt;/span&gt;
    &lt;span class="n"&gt;lib&lt;/span&gt;    &lt;span class="n"&gt;HashidLib&lt;/span&gt;   &lt;span class="c"&gt;// speps/go-hashids or sqids - the actual codec&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;typeCodec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Encoder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;Encoder&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;typeCodec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Decoder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;Decoder&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// Encode always mints with the CURRENT salt.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;typeCodec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lib&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;With&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;salts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Current&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// Decode walks this cell's history; a hit under a retired salt is a tripwire.&lt;/span&gt;
&lt;span class="c"&gt;// The returned meta carries the SaltAge so the gateway applies its strategy.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;typeCodec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DecodeMeta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;salt&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;salts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;All&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c"&gt;// 0 = current, then retired in order&lt;/span&gt;
        &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lib&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;With&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt; &lt;span class="c"&gt;// wrong salt, try the next in this cell's history&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c"&gt;// decoded under a RETIRED salt: a stale/leaked link is live&lt;/span&gt;
            &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;audit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OldSaltHit&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Tenant&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;typ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Token&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SaltAge&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DecodeMeta&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;SaltAge&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DecodeMeta&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;ErrUnknownID&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&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%2Fh8twwv23v2i0yg47zqv2.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%2Fh8twwv23v2i0yg47zqv2.png" alt="Hashid decode chain: try current salt then each retired salt, emit an audit event on an old-salt hit" width="800" height="424"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  hashids is obfuscation, not encryption
&lt;/h2&gt;

&lt;p&gt;I have to say this as loudly as the "id is not auth" line from Part 1, because they're the same mistake in a different hat. A hashid is &lt;strong&gt;obfuscation, not encryption.&lt;/strong&gt; The salt is not a key; the encoding is reversible by anyone who knows the (public, off-the-shelf) algorithm and can guess the salt. Do &lt;strong&gt;not&lt;/strong&gt; treat the opaque string as a secret. It buys you: no naked sequential ints in URLs, no trivial enumeration, no cross-tenant correlation, and - with salt history - a leak tripwire. It does &lt;strong&gt;not&lt;/strong&gt; buy you authorization. Every request that arrives with a hashid still gets decoded to an &lt;code&gt;int64&lt;/code&gt; and then runs the exact same ownership check from Part 1. The opaque string and the authz check are partners, never substitutes.&lt;/p&gt;




&lt;h2&gt;
  
  
  The browser can't count that high (the castration bug)
&lt;/h2&gt;

&lt;p&gt;This one bites everybody exactly once, and it's silent, which is the worst kind.&lt;/p&gt;

&lt;p&gt;JavaScript's &lt;code&gt;Number&lt;/code&gt; is an IEEE-754 float64. It represents integers exactly only up to &lt;strong&gt;2⁵³ − 1 = 9,007,199,254,740,991&lt;/strong&gt;. A 64-bit Snowflake id is bigger than that. So the moment it goes through &lt;code&gt;JSON.parse&lt;/code&gt; (or any &lt;code&gt;Number()&lt;/code&gt; in JS), the browser &lt;strong&gt;silently rounds it&lt;/strong&gt; to the nearest representable float. The last digits get mangled. I call it getting &lt;em&gt;castrated&lt;/em&gt; - the id comes back looking almost right, wrong in the tail, and nothing throws.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;JavaScript Number  =  IEEE-754 float64
exact integers only up to   2^53 - 1  =  9,007,199,254,740,991

Snowflake id:   7,239,344,029,703,798,784      (19 digits, well past 2^53)
                       │
                       │   JSON.parse(...)  /  Number(...)
                       ▼
browser sees:   7,239,344,029,703,799,000      ← the tail is rounded off

  same shape, DIFFERENT record.  No error.  No warning.
  It reads, edits, or deletes the wrong row - and the logs look fine.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No exception, no &lt;code&gt;NaN&lt;/code&gt;, no console warning. The id just quietly points at a different (or non-existent) record. You find out when a customer reports that saving one thing changed another, and you spend a day disbelieving your own database before you realise the browser did it in transit.&lt;/p&gt;

&lt;p&gt;The fix is dull and absolute: &lt;strong&gt;serialize every int64 as a string in any web-facing JSON.&lt;/strong&gt; Not "the big ones" - &lt;em&gt;every&lt;/em&gt; int64, so there's no boundary to remember and no field that slips through. A string round-trips through JS untouched; you parse it back to an int64 on the server where 64 bits actually exist. Yes, even for the admin panel - the browser is a browser regardless of who's logged in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"order_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s2"&gt;"7239344029703798784"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tenant_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7188990030102237184"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"public_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"kZ9mQ2xR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"total_cents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4990&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two 64-bit ids are &lt;strong&gt;strings&lt;/strong&gt;, the small &lt;code&gt;total_cents&lt;/code&gt; stays a real number (nowhere near the ceiling), and &lt;code&gt;public_id&lt;/code&gt; is the hashid for URLs. This isn't a hack I invented - it's exactly why &lt;strong&gt;proto3's canonical JSON mapping encodes &lt;code&gt;int64&lt;/code&gt; as a string&lt;/strong&gt; by default. The people who designed the wire format hit this wall first and baked the fix into the standard. Follow them.&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%2Fm7aqpjtmesdgxl4h9yfn.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%2Fm7aqpjtmesdgxl4h9yfn.png" alt="A 64-bit Snowflake id silently rounded by a browser's float64 Number, and the string fix" width="800" height="365"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  It all happens at the gateway
&lt;/h2&gt;

&lt;p&gt;None of this belongs in your domain code. If your business logic knows what a hashid is, or remembers to stringify ids before responding, the concern has leaked into the wrong layer and you'll get it wrong somewhere. All of it lives in &lt;strong&gt;one place: the gateway / edge.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The gateway has exactly these jobs, and the services behind it have none of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;type prefix&lt;/strong&gt; - split the prefix off every inbound public id and resolve it, through the tenant's configured map, to an entity type: which salt cell decodes it, which handler owns it. On the way out, prepend the current prefix for that type.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;hashid codec&lt;/strong&gt; - decode every inbound public id (single or composite) to &lt;code&gt;int64&lt;/code&gt;(s), walking the salt history and emitting an audit event on an old-salt hit; encode every outbound &lt;code&gt;int64&lt;/code&gt; to the current salt's hashid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;int64 ⇆ string&lt;/strong&gt; - stringify every int64 on the way out to the browser, parse it back on the way in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Behind that seam, services speak &lt;strong&gt;pure int8, end to end.&lt;/strong&gt; The proto fields are &lt;code&gt;int64&lt;/code&gt;, the domain objects hold &lt;code&gt;int64&lt;/code&gt;, the SQL columns are &lt;code&gt;BIGINT&lt;/code&gt;. Domain code never hears the words "hashid" or "string id" - it can't get the encoding wrong because it never does the encoding.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LEVEL 1   browser / public API
          sees opaque clean hashid URL strings, and every int64 as a "string"
               │  hashids in URLs, JSON with string-encoded ids
               ▼
LEVEL 2   ┌────────────────────────────────────────────────────┐
          │  GATEWAY  —  the only place any of this lives        │
          │    1. hashid codec: decode in / encode out           │
          │       + composite [master,child]  + salt history     │
          │       + audit on old-salt hits                       │
          │    2. int64  &amp;lt;-&amp;gt;  string  in all web JSON            │
          └────────────────────────────────────────────────────┘
               │  raw int64  (gRPC int64)
               ▼
LEVEL 3   services  —  speak pure int8 end to end
          domain code never hears "hashid" or "string id"
               │  int64
               ▼
LEVEL 4   PostgreSQL  —  BIGINT primary keys, time-ordered inserts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One boundary, a handful of jobs, and everything inside it stays honest by never being asked to lie about a number.&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%2F9aawdq1eedvupddpyngb.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%2F9aawdq1eedvupddpyngb.png" alt="Four levels - browser, gateway, services, database - with the gateway owning hashid and int64-string translation" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The prefix is routable: one door in front, two gateways behind
&lt;/h2&gt;

&lt;p&gt;Here's the part that makes the prefix more than cosmetics. Because the type is readable &lt;strong&gt;before&lt;/strong&gt; the decode, the prefix is a &lt;strong&gt;dispatch key&lt;/strong&gt;. One gateway can stand in front of everything and send &lt;code&gt;o…&lt;/code&gt; to the order upstream and &lt;code&gt;c…&lt;/code&gt; to the comment upstream - no decode first, no lookup, no need for the URL path to carry the type at all.&lt;/p&gt;

&lt;p&gt;The more useful half is one level further out. The same property lets you &lt;strong&gt;split the public and the internal surface at the very edge&lt;/strong&gt;. A front proxy - nginx-level, or any edge router - looks at the first character of the id and sends prefixed public tokens to the &lt;strong&gt;public&lt;/strong&gt; gateway, while internal &lt;code&gt;int64&lt;/code&gt; traffic stays on the &lt;strong&gt;internal&lt;/strong&gt; one. The two surfaces never share a door, and the decision costs a string prefix match.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  PUBLIC traffic                                   INTERNAL traffic
  o kZ9mQ2xR   c 7bQ0pLMn                          order_id = 7239344029703798784
        │           │                                        │
        └─────┬─────┘                                        │
              ▼                                              │
  ┌──────────────────────────┐                               │
  │  front proxy (edge)      │   matches the PREFIX only     │
  │  o… c… p…  →  public     │   no decode, no lookup, no db │
  │  unknown   →  public     │   default route is the SAFE   │
  └────────────┬─────────────┘   one, never the internal     │
               ▼                                             ▼
  ┌──────────────────────────┐              ┌──────────────────────────┐
  │  PUBLIC gateway          │              │  INTERNAL gateway        │
  │  prefix → salt cell      │              │  raw int64, no codec     │
  │  decode · authz · audit  │              │  service + staff traffic │
  │  int64 ⇆ string          │              │  authz, same as ever     │
  └────────────┬─────────────┘              └────────────┬─────────────┘
               └────────►  services - pure int8  ◄───────┘

  the prefix is a DISPATCH key. It is never the authorization decision.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the honest caveats, because this is the kind of mechanism that quietly turns into a security assumption:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The prefix is not authentication.&lt;/strong&gt; Anyone can type an &lt;code&gt;o&lt;/code&gt;. It tells you what a token &lt;em&gt;claims&lt;/em&gt; to be, and nothing whatsoever about who is holding it. The tenant-scoped ownership check runs unchanged behind every door.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Routing must not become the authorization decision.&lt;/strong&gt; "It arrived at the public gateway, so it's a legitimate public request" is the same error as "it decoded, so it's allowed" - just moved into the proxy config where nobody reviews it. Routing picks a handler; the handler still asks whether this caller may touch this object.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A wrong-prefix token fails closed.&lt;/strong&gt; If the prefix and the salt cell disagree, the answer is a flat rejection - not a fallback that tries the other cells until something decodes. A router that shops a token around has rebuilt enumeration with extra steps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A routing rule built on a tenant-editable value needs a fallback.&lt;/strong&gt; Prefixes are self-serve, so a mapping can change while a request is in flight and while a proxy's config is stale. Two things keep that from becoming an outage or, worse, a leak: keep the retired mapping resolvable (&lt;em&gt;accept + audit&lt;/em&gt; / &lt;em&gt;grace window&lt;/em&gt; / &lt;em&gt;hard cutoff&lt;/em&gt;, exactly as with salts), and make the &lt;strong&gt;default route the safe one&lt;/strong&gt; - an unknown prefix goes to the public gateway and is rejected there. It must never fall through to the internal side.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the framing I'd want kept with it: there are &lt;strong&gt;many&lt;/strong&gt; patterns for keeping a public gateway and an internal gateway safely apart - separate hostnames, separate networks, mutual TLS on the internal side, a header the edge strips and the inside trusts. This one is simply the pattern built &lt;strong&gt;on the id itself&lt;/strong&gt;. If you already have prefixed public ids, it's nearly free. If you don't, "I want to route by prefix" is not on its own a good reason to introduce them. It's a capability to pick from, not a prescription.&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%2Fcrp3u59dkfdlk8po4y9l.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%2Fcrp3u59dkfdlk8po4y9l.png" alt="One opaque token decoding into a designed tuple of tenant, resource type, resource id, expiry and nonce, each field labelled with its job" width="800" height="294"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The real question: what's &lt;em&gt;possible&lt;/em&gt; vs what's right for &lt;em&gt;you&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;Here's the thing I want to be honest about after two parts of machinery: everything here is a &lt;strong&gt;capability, not a prescription.&lt;/strong&gt; Composite tokens, self-describing one-time links, a per-&lt;code&gt;(tenant × type)&lt;/code&gt; salt matrix with per-cell rotation strategies, typed prefixes and prefix-based routing at the edge, strings-as-numbers - it all works, and it's all &lt;em&gt;optional&lt;/em&gt;. The question that actually matters isn't "can I?" (you can) - it's "&lt;strong&gt;which of these fit me?&lt;/strong&gt;", and only you can answer it. In a lot of cases there's more than one right way; you're balancing a few forces at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Simplicity.&lt;/strong&gt; Fewer moving parts = fewer ways to be wrong at 3am. A single per-tenant salt with plain hashids may be everything you need; the full matrix with three retired-salt strategies is power you pay for in complexity forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security.&lt;/strong&gt; What does a leak, an enumeration, or a replay actually &lt;em&gt;cost you&lt;/em&gt;? A public blog's ids and a health record's ids are different threat models. Don't buy a vault for a doormat - and don't put a doormat on a vault.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A new approach.&lt;/strong&gt; A clever composite one-time token is elegant, but every non-standard mechanism is something the next engineer has to learn, trust, and not break. Novelty carries a tax; sometimes it's worth it, sometimes "boring and obvious" wins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Support / operations.&lt;/strong&gt; Salt histories, audit sinks, nonce stores, rotation UIs - that's all real code to run, monitor, migrate, and hand over. The most elegant design you can't operate is a liability, not an asset.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I'm not telling you to ship all of this. I'm showing that it's &lt;em&gt;possible&lt;/em&gt; and &lt;em&gt;how&lt;/em&gt;, so you can pick the subset that matches your threat model and your team's capacity. Sane default: start simple - &lt;code&gt;int8&lt;/code&gt; keys, plain per-tenant hashids, and the ownership check - and add each layer (composite tokens, rotation, audit, one-time links) only when a &lt;strong&gt;real requirement&lt;/strong&gt; pulls it in, never because it's clever. The one dial that isn't a dial - the wall you never remove - is the tenant-scoped &lt;strong&gt;authorization&lt;/strong&gt; check. Everything else on this page is a trade-off you get to make on purpose.&lt;/p&gt;




&lt;h2&gt;
  
  
  The judgment is the human part
&lt;/h2&gt;

&lt;p&gt;I lean hard on AI assistants for work like this, and my take doesn't move: &lt;strong&gt;AI amplifies a good engineer and exposes a weak one.&lt;/strong&gt; Ask a model for a data model and it'll cheerfully hand you UUIDv4 keys everywhere, raw sequential ids in your URLs, and a hand-rolled generator it didn't need to write - fast, clean-looking, passes the demo. It won't, unprompted, tell you to use int8 + Snowflake for locality, that the raw ints must stop at the trust boundary, that one composite token can carry the whole ownership chain, that a retired salt is a free tripwire, or that the browser is about to castrate every id over 2⁵³ and corrupt the wrong record in silence.&lt;/p&gt;

&lt;p&gt;The mechanics are cheap and mostly already written - Snowflake and hashids libraries exist; you configure them, you don't author them. The &lt;em&gt;judgment&lt;/em&gt; - int8 + Snowflake for the key, raw ints stop at the wall, composite tokens for ownership, self-serve rotation with an audit tripwire, every int64 a string at the browser - that's the part the human owns. Point the multiplier at a good design and it ships fast. Point it at "just give me some ids" and it'll help you build the leak faster than you can schedule the incident review.&lt;/p&gt;

&lt;p&gt;If you build serious backends, this is the kind of decision I keep writing up. Follow along on &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;. And if your URLs have raw sequential ids in them right now, or your API sends int64s as numbers: &lt;em&gt;which one is going to page you first?&lt;/em&gt; I'd genuinely like to compare notes.&lt;/p&gt;

</description>
      <category>database</category>
      <category>architecture</category>
      <category>security</category>
      <category>go</category>
    </item>
    <item>
      <title>Snowflake IDs - Part 1: Why int8 + Snowflake, and Never UUID</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Mon, 17 Aug 2026 12:19:33 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/snowflake-ids-part-1-why-int8-snowflake-and-never-uuid-1nb8</link>
      <guid>https://dev.to/anton_brilliantov/snowflake-ids-part-1-why-int8-snowflake-and-never-uuid-1nb8</guid>
      <description>&lt;p&gt;&lt;em&gt;An identifier is load-bearing infrastructure, not a boring column. Part 1 of a short series on how I pick identifiers and why: a 64-bit Snowflake in a &lt;code&gt;BIGINT&lt;/code&gt;, generated at the edge, time-ordered, and small - versus a random UUID that shreds your indexes or a &lt;code&gt;BIGSERIAL&lt;/code&gt; that prints your growth rate on every URL. No custom generator - the good implementations already exist; this is about the reasoning, not the wheel.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go. Most of what I write is about the slow, careful business of breaking a large PHP monolith into Go services while it keeps serving real customers, and identifiers are the one thing that has to stay coherent while everything else moves. This little two-part series pulls the ID decision out on its own. &lt;strong&gt;Part 1 (this one): why int8 + Snowflake is the right primary key.&lt;/strong&gt; Part 2: how you move that id across a service mesh, a public URL, and a browser without leaking or corrupting it. 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;. No hype, just the real work.&lt;/p&gt;

&lt;p&gt;An ID is the most-touched value in the whole system. It's the primary key, the foreign key, the URL segment, the cache key, the gRPC field, the log line you grep at 3am. Pick it badly and the cost doesn't show up in a demo - it shows up months later as index bloat, a competitor reading your order count, or a support ticket that says "it edited the wrong record and nobody knows why." So I treat the ID as a design decision, not a default, and I make it deliberately.&lt;/p&gt;




&lt;h2&gt;
  
  
  An ID has to be two things at once: small and unguessable-ish
&lt;/h2&gt;

&lt;p&gt;Small, because it's in every index and every join. Unguessable-ish, because it ends up in places I don't fully control. Those two pulls usually fight - the value that's cheapest to store (a sequential counter) is the one that leaks the most, and the value that leaks the least (a random 128-bit UUID) is the one that wrecks your indexes. A Snowflake in a &lt;code&gt;BIGINT&lt;/code&gt; is the point where they stop fighting.&lt;/p&gt;




&lt;h2&gt;
  
  
  64-bit BIGINT: eight bytes, and the B-tree loves it
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;BIGINT&lt;/code&gt; is 8 bytes. A UUID is 16. That 2× isn't just disk - it's every index page, every foreign key, every tuple in the cache. Double the key width and you halve how many entries fit in a B-tree page, which means more pages, deeper trees, more cache misses on every lookup.&lt;/p&gt;

&lt;p&gt;But the width is the smaller half of the story. The bigger half is &lt;em&gt;ordering&lt;/em&gt;. A Snowflake's high bits are a timestamp, so IDs come out &lt;strong&gt;time-ordered&lt;/strong&gt; - roughly sorted by creation. New rows land at the &lt;em&gt;right edge&lt;/em&gt; of the index, next to the last one, so inserts append to warm pages instead of scattering across cold ones. The index stays tight.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;64-bit Snowflake ID   (fits in a signed BIGINT / int64)

  1 bit    41 bits                 10 bits      12 bits
 ┌──────┬─────────────────────┬───────────┬────────────┐
 │ sign │  timestamp (ms)     │ worker id │  sequence  │
 │  0   │  ms since a custom  │  0..1023  │  0..4095   │
 │      │  epoch              │  which    │  per-ms    │
 │      │                     │  node     │  counter   │
 └──────┴─────────────────────┴───────────┴────────────┘
  unused  high bits = time -&amp;gt;    where it   uniqueness
          IDs sort by creation   was minted within a ms

  time in the HIGH bits  =&amp;gt;  k-sorted IDs  =&amp;gt;  append-y, tight B-tree
&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%2Fed8m0sba3ouc0fljckge.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%2Fed8m0sba3ouc0fljckge.png" alt=" " width="800" height="253"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why not UUID
&lt;/h2&gt;

&lt;p&gt;UUID is the reflexive "distributed id" answer, and it's the wrong shape for a primary key.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;UUIDv4&lt;/strong&gt; is 128 bits of randomness. Every insert lands in a &lt;em&gt;random&lt;/em&gt; spot in the index, forcing page splits, fragmenting the tree, and turning what should be an append into a random-write storm. On a hot table you feel it: write throughput drops and the index bloats far past the data it indexes. You pay that on every table, forever, for a property (global uniqueness) a 64-bit Snowflake already gives you at half the width.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;UUIDv7&lt;/strong&gt; fixes the &lt;em&gt;ordering&lt;/em&gt; problem - it puts a timestamp up front, so it's k-sorted like a Snowflake. It's a genuine improvement and a fine choice if you're wedded to the UUID type. But it's still &lt;strong&gt;128 bits&lt;/strong&gt; - twice the storage of what I need, in every index and every FK - and the timestamp-in-the-front makes it &lt;em&gt;more&lt;/em&gt; enumerable, not less. A time-ordered &lt;strong&gt;64-bit&lt;/strong&gt; id gives me the same locality win at half the width, in a native &lt;code&gt;BIGINT&lt;/code&gt; the database and every driver already understand, with a worker-id field that makes multi-node generation collision-proof by construction.&lt;/p&gt;

&lt;p&gt;So: UUIDv4 loses on locality, UUIDv7 loses on width. Snowflake int8 wins both.&lt;/p&gt;




&lt;h2&gt;
  
  
  Already on UUIDs? They're just integers too
&lt;/h2&gt;

&lt;p&gt;Here's the thing that makes this whole approach &lt;em&gt;not&lt;/em&gt; a religious int-vs-UUID war: &lt;strong&gt;a UUID is only 128 bits, and 128 bits is just numbers.&lt;/strong&gt; Any UUID can be represented as integers - two &lt;code&gt;int64&lt;/code&gt; (a hi/lo pair), a single 128-bit big integer, or, depending on the version, just the bytes that carry meaning (a v7's 48-bit timestamp prefix, a v4's random bits). Nothing about "it's a UUID" stops you from treating it as an int.&lt;/p&gt;

&lt;p&gt;Which means: &lt;strong&gt;even if your primary keys are already UUIDs, you don't have to migrate them to get most of this.&lt;/strong&gt; You keep the UUID column and, at the boundary, represent it as int(s) and do &lt;em&gt;exactly the same thing&lt;/em&gt; - the same hashids/Sqids treatment (those libraries encode a &lt;em&gt;list&lt;/em&gt; of numbers, so a 128-bit UUID is simply two numbers), the same per-&lt;code&gt;(tenant × type)&lt;/code&gt; salt, the same rotation-with-audit, the same int64-as-string for the browser (Part 2). The principle is identical; the only thing that changes between UUID versions is &lt;em&gt;which bytes you read&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Be honest about what that does and doesn't buy you, though. Representing a UUID as ints gives you the whole &lt;strong&gt;boundary&lt;/strong&gt; story for free - clean URLs, hashids, salt rotation, no naked ids leaking. It does &lt;strong&gt;not&lt;/strong&gt; fix the &lt;strong&gt;storage&lt;/strong&gt; story: a UUID is still 128 bits in every index, so the locality and width costs from above stay until you actually move the primary key to a Snowflake &lt;code&gt;int8&lt;/code&gt;. Treat-as-int is the zero-migration on-ramp; Snowflake int8 is the destination.&lt;/p&gt;




&lt;h2&gt;
  
  
  UUID versions: which bytes matter, and when a UUID actually earns it
&lt;/h2&gt;

&lt;p&gt;"Represent it as ints" means &lt;em&gt;read the bytes that matter for that version&lt;/em&gt; - and the versions are very different animals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;v1 (time + node)&lt;/strong&gt; - a 60-bit timestamp split awkwardly (low bits first) plus a clock sequence and the machine's MAC. Time is in there but leaks the node; rarely what you want.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;v4 (random)&lt;/strong&gt; - 122 bits of pure randomness, no structure to read. Carry it as the full 128-bit (hi/lo) pair; there's nothing to sort by. This is the one that shreds index locality.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;v5 (name-based, SHA-1)&lt;/strong&gt; - &lt;strong&gt;deterministic&lt;/strong&gt;: &lt;code&gt;uuidv5(namespace, name)&lt;/code&gt; hashes a namespace + a name and always returns the &lt;em&gt;same&lt;/em&gt; UUID for the same input. The namespace behaves like a &lt;strong&gt;salt&lt;/strong&gt;. No randomness, no coordination - the id is a pure function of stable inputs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;v7 (unix-ms + random)&lt;/strong&gt; - a 48-bit millisecond timestamp in the &lt;em&gt;high&lt;/em&gt; bytes, then random. Read the top 48 bits and you've got a time-ordered key, k-sorted like a Snowflake - which is exactly why it's the good modern default &lt;em&gt;if&lt;/em&gt; you're staying on UUIDs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why I'd actually reach for a UUID - v5 in particular.&lt;/strong&gt; Snowflake &lt;code&gt;int8&lt;/code&gt; is my default for a &lt;em&gt;minted&lt;/em&gt; primary key. But v5's determinism is a genuinely great property when you need to &lt;strong&gt;control&lt;/strong&gt; the id instead of generating a fresh one: derive a stable id straight from a business key so the same input always maps to the same row (idempotency and dedup for free), content-address something, or reproduce an id across systems with no shared sequence and no lookup table. "The id comes from the data" is a real superpower for those cases - and it's precisely what a random Snowflake &lt;em&gt;can't&lt;/em&gt; do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest pros and cons, so it's a choice and not a reflex:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;UUID good:&lt;/strong&gt; a 128-bit space you'll never exhaust; fully decentralized; &lt;strong&gt;v5 deterministic/derivable&lt;/strong&gt; (control the id); &lt;strong&gt;v7 time-ordered&lt;/strong&gt;; a universally understood type with native columns everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UUID bad:&lt;/strong&gt; 16 bytes in every index and FK (2× a BIGINT); &lt;strong&gt;v4 destroys locality&lt;/strong&gt;; heavier logs, URLs, cache keys; and it's still not a secret, so it needs the exact same boundary treatment (hashids, per-tenant salt) as an int.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the rule I actually use: default to Snowflake &lt;code&gt;int8&lt;/code&gt; for minted keys; reach for &lt;strong&gt;UUID v5&lt;/strong&gt; when I specifically need a &lt;em&gt;derived, reproducible&lt;/em&gt; id; and whichever I hold, carry it as int(s) at the boundary and apply Part 2 unchanged. I use it this way because the id strategy should follow what the id is &lt;em&gt;for&lt;/em&gt; - minted-and-fast versus derived-and-controllable - not which type is fashionable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Snowflake = generate the ID at the edge, before the INSERT
&lt;/h2&gt;

&lt;p&gt;The layout is the whole trick: &lt;code&gt;timestamp | worker id | per-ms sequence&lt;/code&gt;. The timestamp gives ordering. The worker id says &lt;em&gt;which node&lt;/em&gt; minted it, so two machines never collide. The sequence counts multiple IDs inside the same millisecond on one node. Put together, any node can mint a globally-unique, time-ordered ID &lt;strong&gt;locally&lt;/strong&gt;, with no round-trip to a central sequence and no coordination.&lt;/p&gt;

&lt;p&gt;That last part matters more than it sounds. It means the service about to create a row already knows the row's ID &lt;em&gt;before&lt;/em&gt; it talks to the database. I can build the whole object graph - parent, children, the foreign keys between them - in memory, then insert it in one shot. No "insert, read back the generated id, insert the children" dance.&lt;/p&gt;

&lt;p&gt;The generation is a solved problem - &lt;strong&gt;don't write your own.&lt;/strong&gt; The layout is a well-known bit formula and there are mature, battle-tested libraries in every language. Pick one and move on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Go:&lt;/strong&gt; &lt;code&gt;bwmarrin/snowflake&lt;/code&gt;, &lt;code&gt;sony/sonyflake&lt;/code&gt; (a slightly different bit split), &lt;code&gt;godruoyi/go-snowflake&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PHP:&lt;/strong&gt; &lt;code&gt;godruoyi/php-snowflake&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The original is Twitter's Snowflake; Instagram, Discord and Sony all shipped documented variants. Any of them is fine - what matters is the &lt;em&gt;shape&lt;/em&gt;, not the repo.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole algorithm, as pseudocode, is just a couple of shifts - so you can see there's nothing to hand-roll:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# what every Snowflake library does, once you strip the mutex and clock handling
&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ms_since_epoch&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# 41 bits of time, in the high bits
&lt;/span&gt;   &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;worker_id&lt;/span&gt;      &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# 10 bits: which node minted it
&lt;/span&gt;   &lt;span class="o"&gt;|&lt;/span&gt;  &lt;span class="n"&gt;sequence&lt;/span&gt;                &lt;span class="c1"&gt;# 12 bits: per-ms counter, wraps at 4096
&lt;/span&gt;
&lt;span class="c1"&gt;# time in the high bits =&amp;gt; ids sort by creation
# worker_id =&amp;gt; no cross-node collision
# sequence  =&amp;gt; up to 4096 ids per node per millisecond
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You configure the worker id per node and the custom epoch once, then call &lt;code&gt;Next()&lt;/code&gt; from the library. That's it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where Snowflake comes from - the original, and the descendants
&lt;/h2&gt;

&lt;p&gt;Snowflake isn't a pattern someone blogged into existence - it's a specific system Twitter built and open-sourced in 2010, and the choices above are load-bearing decisions from a real migration, not arbitrary bit-counting.&lt;/p&gt;

&lt;p&gt;Twitter announced it in &lt;a href="https://blog.x.com/engineering/en_us/a/2010/announcing-snowflake" rel="noopener noreferrer"&gt;&lt;strong&gt;Announcing Snowflake&lt;/strong&gt; (June 2010)&lt;/a&gt;. The trigger was concrete: they were moving off MySQL (whose auto-increment handed them ids) onto Cassandra, which has &lt;strong&gt;no&lt;/strong&gt; built-in id generation. They needed ids that were (1) &lt;strong&gt;roughly time-ordered&lt;/strong&gt; so they'd sort, and (2) &lt;strong&gt;generated with no coordination&lt;/strong&gt; - no central sequence, no lock, no single point of failure - at tens of thousands per second. That brief &lt;em&gt;is&lt;/em&gt; the two properties this article keeps circling back to.&lt;/p&gt;

&lt;p&gt;The original is a Scala network service (Thrift-based, predating Finagle), still readable in the archived repo &lt;a href="https://github.com/twitter-archive/snowflake" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;code&gt;twitter-archive/snowflake&lt;/code&gt;&lt;/strong&gt;&lt;/a&gt; at the &lt;code&gt;snowflake-2010&lt;/code&gt; tag. Its layout is the one everyone copied: &lt;strong&gt;1 sign bit · 41 bits ms timestamp&lt;/strong&gt; (custom epoch, ~69 years of range) &lt;strong&gt;· 10 bits machine id&lt;/strong&gt; (Twitter split it &lt;strong&gt;5 datacenter + 5 worker&lt;/strong&gt; = 1024 nodes) &lt;strong&gt;· 12 bits sequence&lt;/strong&gt; (4096 ids per node per millisecond). It's now enough of a standard to have its own &lt;a href="https://en.wikipedia.org/wiki/Snowflake_ID" rel="noopener noreferrer"&gt;Snowflake ID&lt;/a&gt; writeup.&lt;/p&gt;

&lt;p&gt;Those 10 machine bits are the whole multi-DC / multi-node story - why you can run this across datacenters with zero coordination:&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%2F68gc4qzj3u2mbxu7dq3n.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%2F68gc4qzj3u2mbxu7dq3n.png" alt=" " width="800" height="341"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  10 machine bits  =  5 datacenter  +  5 worker   =&amp;gt;  1024 independent minters

  DC A                      DC B                      DC C
  ┌────────────────┐        ┌────────────────┐        ┌────────────────┐
  │ worker 0 → id  │        │ worker 0 → id  │        │ worker 0 → id  │
  │ worker 1 → id  │        │ worker 1 → id  │        │ worker 1 → id  │
  │ worker 2 → id  │        │ worker 2 → id  │        │ worker 2 → id  │
  └────────────────┘        └────────────────┘        └────────────────┘
   no lock · no              no lock · no              no lock · no
   central sequence          central sequence          central sequence

  every node mints LOCALLY:  id = (time | dc | worker | sequence)
  the (dc,worker) field makes each node's id-stream DISJOINT  → collisions impossible
  no cross-node / cross-DC talk on the write path             → no bottleneck, no SPOF
  scale out = hand the new node a (dc,worker) id              → it just starts minting
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The idea then spread by being re-balanced for different scale points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://instagram-engineering.com/sharding-ids-at-instagram-1cf5a71e5a5c" rel="noopener noreferrer"&gt;&lt;strong&gt;Instagram - Sharding &amp;amp; IDs at Instagram&lt;/strong&gt;&lt;/a&gt; brought the same idea &lt;em&gt;inside&lt;/em&gt; Postgres with PL/pgSQL, minting time-ordered ids per shard (41 bits time, 13 bits shard, 10 bits per-shard sequence).&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/sony/sonyflake" rel="noopener noreferrer"&gt;&lt;strong&gt;Sony - Sonyflake&lt;/strong&gt;&lt;/a&gt; re-cut the bits for &lt;em&gt;more machines&lt;/em&gt;: 39 bits of 10-ms time, 8 sequence, &lt;strong&gt;16 machine&lt;/strong&gt; bits (65,536 nodes) - a different point on the same trade-off curve.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.discord.com/developers/reference" rel="noopener noreferrer"&gt;&lt;strong&gt;Discord&lt;/strong&gt;&lt;/a&gt; adopted the format wholesale and, tellingly, &lt;strong&gt;returns every id as a string in JSON&lt;/strong&gt; to dodge the exact 2⁵³ browser bug Part 2 is about.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And you never hand-roll the bit-twiddling - mature libraries carry it: &lt;strong&gt;Go&lt;/strong&gt; &lt;a href="https://github.com/bwmarrin/snowflake" rel="noopener noreferrer"&gt;&lt;code&gt;bwmarrin/snowflake&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://github.com/sony/sonyflake" rel="noopener noreferrer"&gt;&lt;code&gt;sony/sonyflake&lt;/code&gt;&lt;/a&gt;; every language has an equivalent. Pick an epoch and a worker id, call &lt;code&gt;Next()&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  vs BIGSERIAL / SERIAL: three problems Snowflake doesn't have
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;BIGSERIAL&lt;/code&gt; is the reflexive default in Postgres, and it's fine right up until it isn't. It backs the column with a central sequence, and that sequence is three problems wearing a trench coat:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It's a coordination point.&lt;/strong&gt; Every insert asks the sequence for the next value. On one box that's cheap; across shards or services it's a round-trip and a bottleneck you can't shard away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can't know the ID before the INSERT.&lt;/strong&gt; The value is assigned &lt;em&gt;by the database, on write&lt;/em&gt;. So you insert, read back the generated key, then insert anything that references it - extra round-trips, and awkward the moment you want to assemble a graph before persisting it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-shard collisions.&lt;/strong&gt; Two shards each running their own sequence both hand out &lt;code&gt;42&lt;/code&gt;. Merge or federate them and the keys clash. Snowflake's per-node worker id makes that structurally impossible.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Snowflake fixes all three: no central sequence, ID known at the edge, no collision across nodes.&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%2Fku6rd6lrne4ma8234tsk.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%2Fku6rd6lrne4ma8234tsk.png" alt=" " width="799" height="292"&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;Property&lt;/th&gt;
&lt;th&gt;UUIDv4&lt;/th&gt;
&lt;th&gt;UUIDv7&lt;/th&gt;
&lt;th&gt;BIGSERIAL&lt;/th&gt;
&lt;th&gt;Snowflake int8&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Width&lt;/td&gt;
&lt;td&gt;128-bit / 16 B&lt;/td&gt;
&lt;td&gt;128-bit / 16 B&lt;/td&gt;
&lt;td&gt;64-bit / 8 B&lt;/td&gt;
&lt;td&gt;64-bit / 8 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ordering&lt;/td&gt;
&lt;td&gt;random&lt;/td&gt;
&lt;td&gt;time-ordered&lt;/td&gt;
&lt;td&gt;sequential&lt;/td&gt;
&lt;td&gt;time-ordered (k-sorted)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Index locality&lt;/td&gt;
&lt;td&gt;poor (page splits)&lt;/td&gt;
&lt;td&gt;good&lt;/td&gt;
&lt;td&gt;excellent&lt;/td&gt;
&lt;td&gt;excellent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where it's generated&lt;/td&gt;
&lt;td&gt;client / anywhere&lt;/td&gt;
&lt;td&gt;client / anywhere&lt;/td&gt;
&lt;td&gt;central sequence&lt;/td&gt;
&lt;td&gt;at the edge, per node&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Know the ID before INSERT&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-shard collision&lt;/td&gt;
&lt;td&gt;~never&lt;/td&gt;
&lt;td&gt;~never&lt;/td&gt;
&lt;td&gt;possible&lt;/td&gt;
&lt;td&gt;~never&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Casual enumeration leak&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;some (time prefix)&lt;/td&gt;
&lt;td&gt;trivial&lt;/td&gt;
&lt;td&gt;hard&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Generation performance, and the control-vs-throughput trade-off
&lt;/h2&gt;

&lt;p&gt;The reason Snowflake feels free is that it &lt;em&gt;is&lt;/em&gt; almost free. Minting an id is a couple of bit-shifts under a mutex - no I/O, no network. The mature libraries do &lt;strong&gt;millions of ids per second per process&lt;/strong&gt;, and the only hard ceiling is the sequence field: &lt;strong&gt;4096 ids per node per millisecond&lt;/strong&gt; (~4M/s). Hit that ceiling inside one millisecond and the library simply waits for the next one - &lt;a href="https://github.com/bwmarrin/snowflake" rel="noopener noreferrer"&gt;&lt;code&gt;bwmarrin/snowflake&lt;/code&gt;&lt;/a&gt; blocks to the next ms, &lt;a href="https://github.com/sony/sonyflake" rel="noopener noreferrer"&gt;&lt;code&gt;sony/sonyflake&lt;/code&gt;&lt;/a&gt; trades time resolution (10-ms units) for far more machine bits. Crucially it &lt;strong&gt;scales linearly&lt;/strong&gt;: two nodes mint twice as fast because they share &lt;em&gt;nothing&lt;/em&gt;. There's no contention to fight.&lt;/p&gt;

&lt;p&gt;Now put &lt;code&gt;BIGSERIAL&lt;/code&gt; next to that. Every id is a &lt;strong&gt;round-trip to a shared sequence&lt;/strong&gt; - network latency plus lock contention on one hot object - and your id throughput is bounded by &lt;em&gt;the database&lt;/em&gt;, not the app, and can't scale past that one authority. That's the SERIAL story in one line: the sequence is a single, coordinated, and therefore rate-limited source of truth.&lt;/p&gt;

&lt;p&gt;Which surfaces the real axis you're choosing on - &lt;strong&gt;control vs generation&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Central sequence (SERIAL)&lt;/strong&gt; - maximum &lt;em&gt;control&lt;/em&gt;: dense, gapless, strictly monotonic, one authority that can hand you "invoice #1001 with no gaps." The price is coordination: a round-trip, a bottleneck, a single point, and no id before insert.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snowflake int8&lt;/strong&gt; - maximum &lt;em&gt;generation&lt;/em&gt;: local, uncoordinated, linear-scaling, id known before insert. The price is you give up density and a single authority - ids have gaps, ordering is only &lt;em&gt;k-sorted&lt;/em&gt;, and it leans on the node clock.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UUID v4&lt;/strong&gt; - maximum independence, &lt;em&gt;zero&lt;/em&gt; order or control (and the widest storage).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UUID v5&lt;/strong&gt; - control of a different kind: not dense/monotonic, but &lt;em&gt;deterministic&lt;/em&gt; - the id is derived from the data (see the UUID section).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Balance it by the job, and don't be afraid to mix: Snowflake &lt;code&gt;int8&lt;/code&gt; on the hot write paths where throughput and edge-generation win, and a real &lt;strong&gt;sequence&lt;/strong&gt; exactly where a &lt;em&gt;business&lt;/em&gt; rule needs gapless, auditable numbering (invoice or receipt numbers - which are a domain concern, not a primary key). Pick control where the requirement is control; pick generation where the requirement is throughput.&lt;/p&gt;




&lt;h2&gt;
  
  
  The security angle - a SECOND control, never the gate
&lt;/h2&gt;

&lt;p&gt;Here's where a sequential &lt;code&gt;BIGSERIAL&lt;/code&gt; quietly hurts you, and it has nothing to do with the database.&lt;/p&gt;

&lt;p&gt;Sequential IDs &lt;strong&gt;leak&lt;/strong&gt;. Two ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Enumeration / IDOR.&lt;/strong&gt; If order &lt;code&gt;41&lt;/code&gt; exists, so do &lt;code&gt;40&lt;/code&gt; and &lt;code&gt;42&lt;/code&gt;. An attacker who sees one URL can walk the whole table by adding and subtracting one. If your authorization is weak anywhere, sequential IDs hand out a map of everything to try.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The German-tank problem.&lt;/strong&gt; In WWII the Allies estimated German tank production from the &lt;em&gt;serial numbers&lt;/em&gt; on captured tanks - sequential numbers leak totals. Same math, your business: a competitor signs up, notes their &lt;code&gt;user_id&lt;/code&gt; is &lt;code&gt;50100&lt;/code&gt;, waits a week, signs up again as &lt;code&gt;51200&lt;/code&gt;, and now knows you added ~1,100 users that week. Your growth rate, order volume, invoice count - all readable from the increment. That's business intelligence you're printing on every URL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Snowflake helps because its IDs are &lt;strong&gt;not neatly sequential&lt;/strong&gt; - the low bits move, the timestamp is in milliseconds off a custom epoch, so you can't add one to get the next valid id and you can't subtract two to get a count. Casual enumeration and count-leaking get much, much harder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But - and I want to be blunt about this - an ID is NEVER a secret, and it is NEVER an authorization check.&lt;/strong&gt; "Hard to guess" is not "safe." The real control is an &lt;strong&gt;ownership/authz check on every single request&lt;/strong&gt;: &lt;em&gt;does this authenticated caller have the right to touch this object?&lt;/em&gt; - enforced server-side, every time, no exceptions. The unguessable-ish id is &lt;strong&gt;defense in depth&lt;/strong&gt; layered on top of that check, not a replacement for it. Anyone who treats a hard-to-guess id as the gate has built an IDOR with extra steps. The Snowflake shape buys you a &lt;em&gt;second&lt;/em&gt; wall; it does not excuse a missing first one. (There's a lot more to say on the security side - a later piece can pivot fully to it.)&lt;/p&gt;




&lt;h2&gt;
  
  
  Next: getting the id out of the building
&lt;/h2&gt;

&lt;p&gt;That's the primary key settled: 64-bit Snowflake, native &lt;code&gt;BIGINT&lt;/code&gt;, generated at the edge by a library you didn't write. Inside the trust boundary, that raw &lt;code&gt;int64&lt;/code&gt; is exactly what every service should speak.&lt;/p&gt;

&lt;p&gt;The moment it has to &lt;em&gt;leave&lt;/em&gt; - into a public URL, a third-party API, a browser - the rules change completely. Raw ints don't go out the door, browsers can't even count that high without corrupting them, and a leaked link needs to be revocable. That's &lt;strong&gt;Part 2: hashids at the edge, composite tokens, self-serve salt rotation with an audit tripwire, and why every int64 becomes a string before it touches a browser.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you build serious backends - Symfony, Go, or the messy space between a monolith and the services growing out of it - follow along on &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;. And if your primary keys are UUIDs right now: &lt;em&gt;what's the index bloat costing you that you haven't measured yet?&lt;/em&gt; I'd genuinely like to compare notes.&lt;/p&gt;

</description>
      <category>database</category>
      <category>architecture</category>
      <category>go</category>
      <category>php</category>
    </item>
  </channel>
</rss>
