<?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>Requirements Decide the Topology</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Thu, 24 Sep 2026 16:26:48 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/requirements-decide-the-topology-4fab</link>
      <guid>https://dev.to/anton_brilliantov/requirements-decide-the-topology-4fab</guid>
      <description>&lt;p&gt;&lt;em&gt;No line in the requirements, nothing to argue about - the design argument is not a matter of taste.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This series has been about the order the work happens in: a wish becomes requirements, requirements become a contract, the contract becomes tasks and specs. This is the last part of it, and it goes back to the first link - because the requirement is where the shape of the system got decided, whether or not anybody wrote that line down. Running notes live on my GitHub: &lt;strong&gt;&lt;a href="https://github.com/brilliant-almazov" rel="noopener noreferrer"&gt;github.com/brilliant-almazov&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

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




&lt;h2&gt;
  
  
  Primer, in one paragraph
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;non-functional requirement&lt;/strong&gt; says nothing about what the system does and everything about the conditions it does it under: load, latency, the ratio of reads to writes, how long data has to be kept, whether tenants share storage, what has to stay compatible with what. The systems here are Go services being carved out of a PHP monolith. Each one ships as a single image, declares the processes it runs in a manifest, and stores its data in PostgreSQL. Below, a &lt;strong&gt;layout&lt;/strong&gt; means the package layout of a service: which directories exist and what is allowed to live in them.&lt;/p&gt;

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

&lt;p&gt;The arguments that eat the most calendar time in a design discussion - &lt;em&gt;microservice or module&lt;/em&gt;, &lt;em&gt;CQRS or not&lt;/em&gt;, &lt;em&gt;a bus or a direct call&lt;/em&gt; - are not settled by taste, seniority or precedent. They are settled by a line in the non-functional requirements.&lt;/p&gt;

&lt;p&gt;The useful half of that claim is the contrapositive: &lt;strong&gt;if there is no such line, there is nothing to argue about, because the requirement has not been gathered yet.&lt;/strong&gt; A design discussion that cannot point at the line it is answering is not a design discussion. It is two people comparing preferences with a diagram between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The case: one line, all the way down to a directory name
&lt;/h2&gt;

&lt;p&gt;The line: &lt;em&gt;reads and writes behave differently here&lt;/em&gt; - different volumes, different shapes, different failure modes.&lt;/p&gt;

&lt;p&gt;That is a sentence about conditions, not about features. Here is what it turns into, with nothing in between.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exactly two layers touch the database.&lt;/strong&gt; &lt;code&gt;repository&lt;/code&gt; reads. &lt;code&gt;manager&lt;/code&gt; writes. There is no third one - no &lt;code&gt;store&lt;/code&gt;, no &lt;code&gt;writer&lt;/code&gt;, no &lt;code&gt;dao&lt;/code&gt;, no &lt;code&gt;service&lt;/code&gt;. A manager that needs to read something calls a repository instead of growing its own SQL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The layout runs from the component type, with the domain as a nested package&lt;/strong&gt; - not the other way round:&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/          reads only
      order/
    manager/
      entity/          writes only
      order/
    handler/
      grpc/
        entity/
&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%2Fep9mh8gjy4q5ih9wmgh3.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%2Fep9mh8gjy4q5ih9wmgh3.png" alt="A package tree with two branches - internal/repository/entity marked read only and internal/manager/entity marked write only - an arrow from the manager to the repository labelled reads through the repository, and four struck-out words beside them: store, writer, dao, service" width="800" height="513"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it looks like in numbers&lt;/strong&gt;, measured on 2026-08-16: &lt;strong&gt;2 733&lt;/strong&gt; &lt;code&gt;.go&lt;/code&gt; files excluding generated code, &lt;strong&gt;252 packages&lt;/strong&gt;, average file size &lt;strong&gt;39 lines&lt;/strong&gt;. That average is the tell. A layout that splits by component type first produces many small files by construction; you do not arrive at it by asking people to write shorter files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it cost.&lt;/strong&gt; Two packages where most codebases have one thing called a service, and a manager that wants to read has to go through a repository instead of writing three lines of SQL where it stands. That is a real tax, paid on every feature, and it buys exactly one thing: the read path and the write path can be changed, measured and reasoned about separately - which is what the requirement line asked for.&lt;/p&gt;

&lt;p&gt;Two neighbours of the same order, from the same set of requirements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Heavy background work sits next to synchronous handles.&lt;/em&gt; → &lt;strong&gt;two binaries out of one image&lt;/strong&gt;, &lt;code&gt;server&lt;/code&gt; and &lt;code&gt;worker&lt;/code&gt;, declared in the service manifest.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;This data has to be kept, and old states have to be reconstructable.&lt;/em&gt; → monthly audit partitions with retention done by dropping a partition, &lt;code&gt;HASH&lt;/code&gt; partitioning by owner, and two time axes instead of &lt;code&gt;UPDATE&lt;/code&gt;. &lt;strong&gt;66 migration files, 1 990 lines&lt;/strong&gt; of migrations as of the same date.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the boundary, said plainly: this is a breakdown of a structure and its price, not a report from a running installation. There is no production here with thousands of databases per tenant, and this service does not operate a shard map of its own.&lt;/p&gt;

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

&lt;p&gt;Three patterns, none of them stupid, all of them the same failure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Non-functional requirements as a trailing section.&lt;/strong&gt; The vocabulary is not the problem - &lt;a href="https://iso25000.com/index.php/en/iso-25000-standards/iso-25010" rel="noopener noreferrer"&gt;ISO/IEC 25010&lt;/a&gt; and &lt;a href="https://www.iso.org/standard/72089.html" rel="noopener noreferrer"&gt;ISO/IEC/IEEE 29148&lt;/a&gt; provide perfectly good catalogues of quality characteristics. The placement is: a section after the functional part, written once for the approval meeting, never opened again during design.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The style is chosen by popularity and by what the team has done before.&lt;/strong&gt; Which is a real input - &lt;a href="https://martinfowler.com/articles/microservice-trade-offs.html" rel="noopener noreferrer"&gt;Fowler's microservice trade-offs piece&lt;/a&gt; is largely about team-shaped costs - but it answers "what can we operate", not "what does this system need".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"We will figure it out when we hit it."&lt;/strong&gt; Sometimes correct. It becomes a problem only when the thing hit is a data-model decision, because those are the ones that do not move afterwards.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What the three have in common is that they leave the design question with no cited input. A question with no cited input gets answered by whoever is most confident in the room.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five lines
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requirement line&lt;/th&gt;
&lt;th&gt;What follows from it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;reads and writes carry different load and have different shapes&lt;/td&gt;
&lt;td&gt;reads and writes are separate layers, and it is visible in the package layout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;heavy background work sits next to synchronous handles&lt;/td&gt;
&lt;td&gt;separate daemons of one service, declared in the manifest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;any asynchronous processing&lt;/td&gt;
&lt;td&gt;a queue, not a goroutine scanning a table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tenant isolation and data volume&lt;/td&gt;
&lt;td&gt;a database per tenant, application-level sharding, or partitioning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;history and corrections have to be reconstructable&lt;/td&gt;
&lt;td&gt;a bitemporal model instead of &lt;code&gt;UPDATE&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%2Fxsyyo84fxc82pm7r0kbd.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%2Fxsyyo84fxc82pm7r0kbd.png" alt="Five rows, each a requirement line on the left and the structure it forces on the right, joined by an arrow: read layer and write layer, separate daemons, queue, database per tenant or sharding or partitioning, and bitemporal" width="800" height="565"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The rest of this is one section per row.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Reads and writes are two layers
&lt;/h3&gt;

&lt;p&gt;Covered above. The one thing worth repeating is the negative half: the rule is not "prefer small packages", it is that &lt;strong&gt;four common names do not exist&lt;/strong&gt;. &lt;code&gt;store&lt;/code&gt;, &lt;code&gt;writer&lt;/code&gt;, &lt;code&gt;dao&lt;/code&gt; and &lt;code&gt;service&lt;/code&gt; all let read and write logic share an object, which is precisely what the requirement line ruled out. Banning the names is the cheap way to enforce the split, because a name is checkable and an intention is not.&lt;/p&gt;

&lt;p&gt;This is not full CQRS. There is one model and one database; what is split is the code path, not the storage. &lt;a href="https://martinfowler.com/bliki/CQRS.html" rel="noopener noreferrer"&gt;The original framing&lt;/a&gt; is worth reading mostly for how narrow the useful version of it is.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Separate daemons, one image
&lt;/h3&gt;

&lt;p&gt;Two binaries out of one image: &lt;code&gt;server&lt;/code&gt; speaks gRPC; &lt;code&gt;worker&lt;/code&gt; relays outgoing events, runs background calculations and drops expired audit partitions. Both are declared in the service manifest - which means the set of daemons is a stated fact about the service, not a consequence of somebody having added a goroutine to &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The manifest matters more than the count. A daemon that is declared has a name, a place in the deployment and an owner; a background loop started inside a process has none of the three, and shows up in an incident as unexplained work.&lt;/p&gt;

&lt;p&gt;Who gets to add a third one is a separate question, and the answer is the service owner. That is deliberately not a technical decision.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. A queue, not a goroutine
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Any asynchronous processing goes through a queue.&lt;/strong&gt; Not "usually", not "unless it is small": a goroutine that scans a table on a timer and does the work itself is not a form of asynchronous processing. It is a background job pretending to be one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  requirement:  any asynchronous processing
  ───────────────────────────────────────────────────────────────
  accepted      producer ──▶ queue ──▶ consumer
                back pressure · retry policy · visible depth ·
                the work survives a restart

  not a form    goroutine ──▶ SELECT ... WHERE pending ──▶ do it
                none of the four, and no signal that none of the
                four are there
&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%2Fjso7i8sx8oyk4jy7nx5l.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%2Fjso7i8sx8oyk4jy7nx5l.png" alt="Two paths for the same asynchronous work: on the left a goroutine scanning a table inside the process, marked as not a form of asynchrony, with four consequences listed - no back pressure, no retry policy, no visible depth, work lost on restart; on the right a producer, a queue and a consumer, marked as the accepted form" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The difference is not style. A queue gives back pressure, a retry policy, a visible depth and a place for the work to survive a restart; an in-process scanner gives none of the four, and gives no signal that it gives none of the four. Stating it as a rule rather than a preference matters because the scanner is always the cheaper thing to write on the day you write it.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. One requirement, three different answers
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;Tenant isolation and data volume&lt;/em&gt; is the one line that does not resolve to a single structure. It has three answers, and they are not interchangeable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A database per tenant.&lt;/strong&gt; The strongest isolation, and the operational cost lands on migrations and connection pools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Application-level sharding.&lt;/strong&gt; Logical shards, a map from logical shard to physical address, and the shard bits carried inside the identifier itself, so the routing decision travels with the row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partitioning at the database level.&lt;/strong&gt; &lt;code&gt;HASH&lt;/code&gt; by owner for the large tables, monthly partitions for the audit trail, and retention performed by dropping a partition rather than deleting rows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Partitions are not shards.&lt;/strong&gt; Partitions live inside one database and are transparent to the planner - the query does not know they are there. Shards are different databases and are not transparent: something in the application has to decide which one to open. The two get discussed as though they were points on one scale, and they are not on the same scale at all. (&lt;a href="https://www.postgresql.org/docs/current/ddl-partitioning.html" rel="noopener noreferrer"&gt;The PostgreSQL manual&lt;/a&gt; is unusually clear about which problems partitioning does and does not solve.)&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%2F0uycmt4zmw5oydmmx1n0.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%2F0uycmt4zmw5oydmmx1n0.png" alt="Left, one database with horizontal slices inside it, labelled inside one database and transparent to the planner; right, three separate databases with an address map above them, labelled different databases and not transparent; under both, the line same requirement, three different answers" width="799" height="522"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The honest boundary again, because this is the row where it matters most: partitioning is what actually runs here. The database-per-tenant answer and the sharding answer are described as structures and prices, not as installations I operate.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Two time axes
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;We have to be able to show what we thought at the time&lt;/em&gt; is a requirement line, and it is the only thing that justifies the structure it forces.&lt;/p&gt;

&lt;p&gt;Two time axes: the &lt;strong&gt;valid&lt;/strong&gt; axis, when a fact was true in the world, and the &lt;strong&gt;system&lt;/strong&gt; axis, when the system came to believe it. The current state is a &lt;strong&gt;slice&lt;/strong&gt; through those axes, not a separate table. There is no destructive &lt;code&gt;UPDATE&lt;/code&gt; in the domain tables and no physical &lt;code&gt;DELETE&lt;/code&gt;; a correction is a new row that closes the previous one on the system axis.&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%2Fn69l80fmjpkeol8to3x7.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%2Fn69l80fmjpkeol8to3x7.png" alt="Two axes - valid time across and system time down - with a fact, a correction to it, and the current state drawn as a slice through both; beside them a struck-out UPDATE and a struck-out DELETE, and the note that a correction is a new row rather than an overwrite" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The price is real and it is paid on every read: nothing is a plain &lt;code&gt;SELECT&lt;/code&gt; any more, everything carries a slice condition. It is worth paying because of that one requirement line, and for no other reason. Without "show me what we thought at the time", the second axis is a tax with no payer - which is the standard reason to keep &lt;a href="https://www2.cs.arizona.edu/~rts/tdbbook.pdf" rel="noopener noreferrer"&gt;the bitemporal machinery&lt;/a&gt; out of a system that never asked for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it has to come from the requirement
&lt;/h2&gt;

&lt;p&gt;For each of the five, ask what changes if the requirement line does not exist.&lt;/p&gt;

&lt;p&gt;The structure does not change. The layers are still there, the queue is still there, the second time axis is still there. What disappears is the &lt;strong&gt;reason&lt;/strong&gt; - and with the reason gone, the first person to question the structure is arguing about preferences, and so is everyone answering them. Six months later the split gets undone by somebody with an equally good taste-level argument for undoing it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;If this line is missing&lt;/th&gt;
&lt;th&gt;What is still true&lt;/th&gt;
&lt;th&gt;What stops being true&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;reads and writes differ&lt;/td&gt;
&lt;td&gt;the two layers still exist&lt;/td&gt;
&lt;td&gt;why a manager may not hold SQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;heavy background work&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;worker&lt;/code&gt; still runs&lt;/td&gt;
&lt;td&gt;why it is not a goroutine in &lt;code&gt;server&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;any asynchronous processing&lt;/td&gt;
&lt;td&gt;the queue still delivers&lt;/td&gt;
&lt;td&gt;why the scanner was refused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tenant isolation and volume&lt;/td&gt;
&lt;td&gt;the partitions still drop&lt;/td&gt;
&lt;td&gt;why the owner is the partition key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;history and corrections&lt;/td&gt;
&lt;td&gt;the second axis still fills&lt;/td&gt;
&lt;td&gt;why every read carries a slice condition&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every row of that table is an argument somebody will have. With the line, the argument takes a minute and ends in the requirements document. Without it, the argument is about experience, and experience is not a thing two people can check against each other.&lt;/p&gt;

&lt;p&gt;That is the whole function of the requirement line. It is not documentation of a decision. It is the place where the decision was made, so that later arguments have somewhere to land other than seniority.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A requirement stated as a number is a commitment.&lt;/strong&gt; Somebody will measure against it, and the measurement can come back negative. A vague requirement never fails an audit, which is exactly why vague requirements are so popular.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A structure derived from a requirement is expensive to change when the requirement changes.&lt;/strong&gt; That is the same cost curve as Part 1 of this series: the further down the chain a change lands, the more artefacts have to agree with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Some of these answers are structure-and-price, not experience.&lt;/strong&gt; Database-per-tenant and application-level sharding are in that category here. Presenting them as operational track record would be a lie, and would make the parts I do run less believable.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The requirement is unknown and cannot be gathered.&lt;/strong&gt; Then the honest activity is investigation, not design. A topology derived from a guess is a structure with a fake justification, which is worse than one with none.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The load is knowably small.&lt;/strong&gt; Every one of the five costs something. At small scale four of them are pure overhead, and the fifth is usually a lookup table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A prototype that lives until the first measurement.&lt;/strong&gt; Its job is to produce the requirement line, not to obey one.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;An automated executor is very good at building whatever structure it is handed, and completely indifferent to whether that structure has a reason behind it. Hand it "split reads and writes" and it produces the layers; hand it "reads and writes carry different load, which is why they are split" and it produces the same layers - the code is identical either way. The difference shows up months later, in the first argument about whether to keep them, and by then the executor is not in the room. Speed of production multiplies the definitions it was given; the requirement line is where those definitions live.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;From requirement to contract — Part 7&lt;/strong&gt;, and the last part of this block. The next block starts from the other end: a catalogue of services, and the rule that a service exists when it is declared - not when its code happens to run.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you do this better&lt;/strong&gt;, tell me where your non-functional requirements live and who opens them during a design argument. If &lt;strong&gt;you have been through this&lt;/strong&gt;, which structure in your system turned out to have no requirement behind it? If &lt;strong&gt;you see it differently&lt;/strong&gt;, say where deriving the topology from stated requirements cost you more than deciding it by experience. How is it solved on your side, and what broke there?&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>database</category>
      <category>go</category>
      <category>backend</category>
    </item>
    <item>
      <title>TDD for Requirements</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Thu, 24 Sep 2026 01:59:47 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/tdd-for-requirements-2plg</link>
      <guid>https://dev.to/anton_brilliantov/tdd-for-requirements-2plg</guid>
      <description>&lt;p&gt;&lt;em&gt;Write the checkable condition first - the requirement, the contract and the spec all obey it.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This series has been about the order the work happens in: a wish becomes requirements, requirements become a contract, the contract becomes tasks and specs. This part is the shortest one in it, because it is one move applied four times. Running notes live on my GitHub: &lt;strong&gt;&lt;a href="https://github.com/brilliant-almazov" rel="noopener noreferrer"&gt;github.com/brilliant-almazov&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

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




&lt;h2&gt;
  
  
  Primer, in one paragraph
&lt;/h2&gt;

&lt;p&gt;Each step of that road ends in a &lt;strong&gt;gate&lt;/strong&gt;: one thing that is accepted, and one artefact that proves it - a number, a run, a file. A &lt;strong&gt;spec&lt;/strong&gt; is the self-contained brief one executor runs end to end; an executor is whoever performs it, usually an automated one here, and nothing below changes if it is a person. This part is about where the artefact in that third column comes from, because a gate is only as good as the condition it was given.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Write the checkable condition first. Then write the thing that satisfies it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is the same move as writing a test before the code, lifted one level up - out of the code and onto the requirement, the contract and the spec. Nothing about it is new at the code level. What changed for me is that I stopped filing it under testing and started treating it as the order in which things get written down at all.&lt;/p&gt;

&lt;p&gt;The reason is not purity. A condition written first is the only thing a gate can lean on. Written afterwards, it is a description of whatever was built, and it accepts whatever was built.&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%2Fhkv7kz7r6p3r4e2l7q5g.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%2Fhkv7kz7r6p3r4e2l7q5g.png" alt="Two lanes of the same work - above, the thing is built and then judged, and the verdict is an opinion; below, the condition is written first and the thing is built to satisfy it, and the verdict is a run" width="800" height="532"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The case: a condition that holds a direction and not a level
&lt;/h2&gt;

&lt;p&gt;The honest example first, because it shows the limit of the idea rather than an advertisement for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it was.&lt;/strong&gt; "Coverage must not fall" lived as an intention. Everyone agreed with it and nobody could check it, which is the defining property of an intention: it has no output to read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What was done.&lt;/strong&gt; The intention was turned into a ratchet. The threshold is recorded in the repository, it can only move up, and an attempt to write a lower number in fails the pull request. That is a condition: it produces a verdict without anyone forming a view.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  the rule        the recorded threshold may only go up
  the check       a pull request that writes a lower number fails
  the reading     green  →  nobody lowered the bar
                  red    →  somebody tried, on the record
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What is honest about it.&lt;/strong&gt; The threshold currently stands at &lt;strong&gt;&lt;code&gt;0&lt;/code&gt;&lt;/strong&gt;, while actual line coverage is &lt;strong&gt;86.7%&lt;/strong&gt;. The ratchet is wired and it works, but the bar has never been raised to where the tree already stands. So what the condition holds today is the &lt;strong&gt;direction&lt;/strong&gt; - nobody can quietly lower the recorded number - and not the &lt;strong&gt;level&lt;/strong&gt;: coverage could drift well below 86.7% and the check would stay green, because it is being compared to zero.&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%2Fn1re0bnjzdqtnorrwjf5.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%2Fn1re0bnjzdqtnorrwjf5.png" alt="A coverage track from 0 to 100 with the recorded threshold marked at 0 and actual line coverage marked at 86.7 percent, the span between them labelled as one the threshold claims none of, and a note that the direction is held and the level is not" width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is unfinished work, not an achievement, and I would rather write it down that way than round it up into a story about discipline. The move was right and the follow-through has not happened yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  The same move, in time
&lt;/h3&gt;

&lt;p&gt;The second instance went the other way round, which is what it is supposed to look like.&lt;/p&gt;

&lt;p&gt;Part 1 of this series described an audit that found 44 copies of four ways to read rows from a database. The part that belongs here is not the refactor - it is that the closing condition was written &lt;strong&gt;while copies were still being moved&lt;/strong&gt;, not after the last one:&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;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="err"&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="err"&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;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="err"&gt;·&lt;/span&gt;   &lt;span class="n"&gt;QueryRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="err"&gt;────────────────────────────────────────────────────────────────────&lt;/span&gt;
  &lt;span class="n"&gt;appear&lt;/span&gt; &lt;span class="n"&gt;in&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;tree&lt;/span&gt; &lt;span class="n"&gt;in&lt;/span&gt; &lt;span class="n"&gt;exactly&lt;/span&gt; &lt;span class="n"&gt;one&lt;/span&gt; &lt;span class="k"&gt;package&lt;/span&gt;
  &lt;span class="n"&gt;held&lt;/span&gt; &lt;span class="n"&gt;by&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;test&lt;/span&gt; &lt;span class="n"&gt;that&lt;/span&gt; &lt;span class="n"&gt;fails&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;moment&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;second&lt;/span&gt; &lt;span class="nb"&gt;copy&lt;/span&gt; &lt;span class="n"&gt;appears&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because that sentence existed early, every iteration in the set had something to be accepted against, and the last iteration was not a judgement call about whether the job was finished. The test answered.&lt;/p&gt;

&lt;h3&gt;
  
  
  On performance, the condition is a number taken beforehand
&lt;/h3&gt;

&lt;p&gt;The third instance is the one that is easiest to skip, because it costs work before the work.&lt;/p&gt;

&lt;p&gt;A performance condition is a &lt;strong&gt;baseline measurement, taken on the old behaviour and frozen as a constant in the test&lt;/strong&gt;. The new run is compared against that constant, and the comparison is printed. "It got better" without "what it was" is not proof - it is a recollection, and recollections about performance are usually about the last run rather than the first.&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%2Fu149862loz5s6zlmr717.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%2Fu149862loz5s6zlmr717.png" alt="Old behaviour on the left producing a baseline, the baseline frozen as a constant in the test, and the new behaviour compared against that same constant, with the closing line that better without before is not proof" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Taking that measurement means running the old code on purpose, after you already know how you want to change it. It is the least satisfying half-hour in the task, and it is the entire reason the result is arguable at all.&lt;/p&gt;

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

&lt;p&gt;None of this is new craft, and I am not claiming otherwise.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The test comes before the code&lt;/strong&gt; - &lt;a href="https://martinfowler.com/bliki/TestDrivenDevelopment.html" rel="noopener noreferrer"&gt;test-driven development&lt;/a&gt; has been the standard statement of the move for two decades.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Acceptance criteria are written on the story&lt;/strong&gt;, before implementation, often in a &lt;a href="https://martinfowler.com/bliki/GivenWhenThen.html" rel="noopener noreferrer"&gt;given / when / then&lt;/a&gt; shape so the condition reads the same to both sides.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-functional requirements get their own section&lt;/strong&gt; of the requirements document - that is what &lt;a href="https://www.iso.org/standard/72089.html" rel="noopener noreferrer"&gt;ISO/IEC/IEEE 29148&lt;/a&gt; formalises.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three work. What I keep from them is the ordering rule and nothing else: no template, no separate document, no role that owns it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four levels
&lt;/h2&gt;

&lt;p&gt;The move is the same each time; the &lt;strong&gt;form&lt;/strong&gt; of the condition is different, and that is the part worth being concrete about.&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%2Fztlmymdvjdg5kn7i1akx.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%2Fztlmymdvjdg5kn7i1akx.png" alt="Four rows - requirement, contract, spec, performance - each with the form its condition takes: one command or a number, cases before fields, a command without substitutions, a baseline on the old behaviour" width="800" height="513"&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;level&lt;/th&gt;
&lt;th&gt;the form the condition takes&lt;/th&gt;
&lt;th&gt;what it is without it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;requirement&lt;/td&gt;
&lt;td&gt;one command, or one number&lt;/td&gt;
&lt;td&gt;an adjective two people read differently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;contract&lt;/td&gt;
&lt;td&gt;the list of cases, before any field is named&lt;/td&gt;
&lt;td&gt;a message shape chosen by whoever typed first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;spec&lt;/td&gt;
&lt;td&gt;an acceptance command with nothing left to substitute&lt;/td&gt;
&lt;td&gt;a transition settled by discussion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;performance&lt;/td&gt;
&lt;td&gt;a baseline taken on the old behaviour&lt;/td&gt;
&lt;td&gt;a comparison against a memory&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  On a requirement
&lt;/h3&gt;

&lt;p&gt;The condition is something that can be &lt;strong&gt;run as one command or read as one number&lt;/strong&gt;. "Works fast" is not a criterion: nobody can say when it is met, so acceptance falls back to whoever is more insistent that day. "Holds N requests per second below M latency" is a criterion: one command runs it, or one number answers it, and both sides read the result the same way.&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%2F092qk9g9ziv1wmq8a2uf.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%2F092qk9g9ziv1wmq8a2uf.png" alt="Two rows - the phrase works fast struck through and marked not a criterion, and the line N requests per second under M latency in a box marked a criterion" width="800" height="508"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I am deliberately leaving &lt;code&gt;N&lt;/code&gt; and &lt;code&gt;M&lt;/code&gt; as letters. Those numbers belong to the system being specified, not to an article about the order the work is written in.&lt;/p&gt;

&lt;h3&gt;
  
  
  On a contract
&lt;/h3&gt;

&lt;p&gt;The condition is the &lt;strong&gt;set of cases the contract has to be able to express&lt;/strong&gt;, written down before a single field is named: success, refusal, conflict, an empty response, a page boundary. Five rows, minutes to write, and they settle most of the message shape - the empty case decides that an empty list is an answer rather than an error, the page-boundary case decides that the response carries a cursor, the refusal case decides that codes are an enum rather than prose in a message string.&lt;/p&gt;

&lt;h3&gt;
  
  
  On a spec
&lt;/h3&gt;

&lt;p&gt;The condition is the &lt;strong&gt;acceptance command, written into the text of the spec with no substitutions left in it&lt;/strong&gt; - no angle brackets, no "fill in the package here". And it runs against its own package only, not the whole tree, because an iteration accepted by a full run is being accepted by everybody else's work as well.&lt;/p&gt;

&lt;p&gt;There is a second reading of the same rule, and it is the more useful one: &lt;strong&gt;an iteration that gets rewritten after it was accepted means the criterion was not checkable.&lt;/strong&gt; The executor is not the finding there. The criterion is.&lt;/p&gt;

&lt;h3&gt;
  
  
  On performance
&lt;/h3&gt;

&lt;p&gt;The condition is the baseline described above: taken on the old behaviour, frozen as a constant, compared against and printed.&lt;/p&gt;

&lt;h2&gt;
  
  
  One requirement, all the way down
&lt;/h2&gt;

&lt;p&gt;Neutral domain, so the shape is the only thing visible.&lt;/p&gt;

&lt;p&gt;The wish: &lt;em&gt;the entity list should come back quickly.&lt;/em&gt; Not a requirement yet - there is nothing in it to check.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  requirement   the list returns within M &lt;span class="k"&gt;for &lt;/span&gt;a page of N entities
                on an account of the size we actually have

  contract      cases before fields:
                  success · empty response · page boundary

  spec          go &lt;span class="nb"&gt;test&lt;/span&gt; ./internal/repository/entity/... &lt;span class="nt"&gt;-race&lt;/span&gt; &lt;span class="nt"&gt;-count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1

  performance   baseline taken on the current query, frozen &lt;span class="k"&gt;in &lt;/span&gt;the &lt;span class="nb"&gt;test&lt;/span&gt;,
                the new query compared against it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four conditions, none of which needed the implementation to exist. And each one decided something about what came next: the case list decided that the response carries a cursor, the acceptance command decided where the boundary of the iteration is, the baseline decided which number the change would be about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the order matters
&lt;/h2&gt;

&lt;p&gt;Because a condition written first is not a check on the work - it is a &lt;strong&gt;description of the work's shape&lt;/strong&gt;, produced before the shape exists and therefore able to choose it.&lt;/p&gt;

&lt;p&gt;The case list decides which fields the contract needs. The acceptance command decides where one iteration ends and the next begins. The baseline decides which number the change is even about. Write any of them afterwards and they still read the same on the page, but each one now agrees with whatever was built, because it was written by somebody looking at it.&lt;/p&gt;

&lt;p&gt;And then there is the gate. A transition needs an artefact rather than an opinion, and a condition written first is how one gets manufactured: it exists before there is anything to be defensive about.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;We do not have a separate artefact for this.&lt;/strong&gt; There is no requirements test suite, no file where the conditions live as their own set. Checkability is expressed by the acceptance criterion in the spec and by forbidding tests in the tree - which is thinner than it sounds when I say "condition first", and I would rather name that than imply a system I do not have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writing the condition before the solution is harder than it looks.&lt;/strong&gt; It is genuinely easy to sit for twenty minutes on one sentence, and it is not always twenty minutes well spent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The baseline is work before the work.&lt;/strong&gt; Running the old behaviour on purpose feels like a detour every single time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A condition can be technically true and hold nothing.&lt;/strong&gt; The ratchet at &lt;code&gt;0&lt;/code&gt; is the example in this very article. A green check is not proof that the bar is where you think it is.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Research&lt;/strong&gt;, where the point is that nobody yet knows what should be built. There is nothing to write a condition about, and forcing one produces a confident sentence about a system nobody understands.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Make it nicer".&lt;/strong&gt; Where no criterion exists, inventing one produces a criterion for the wrong thing, and then the work optimises for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A one-off change where the condition is longer than the fix.&lt;/strong&gt; Two lines of code do not need a paragraph of acceptance ceremony, and I skip it.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;A criterion that runs as one command is the only form of acceptance that reads identically to a person and to an automated executor. Everything else - a definition of done written in adjectives, a reviewer's sense that this looks about right, a claim that the iteration is finished - needs somebody to look at it with their own eyes, and that somebody does not scale and does not stay consistent across a set of twenty iterations. Speed of execution makes the condition matter more, not less: whatever the condition failed to say gets built twenty times, quickly.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;From requirement to contract — Part 6.&lt;/strong&gt; Next: non-functional requirements decide the shape of the system - which layer takes the work, whether there is a queue in the middle, whether reads and writes are separated at all.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you do this better&lt;/strong&gt;, tell me what your conditions look like and where they live. If &lt;strong&gt;you have been through this&lt;/strong&gt;, what did your version of the threshold at zero turn out to be - the check that was green and held nothing? If &lt;strong&gt;you see it differently&lt;/strong&gt;, say where writing the condition first cost you more than it bought. How is it solved on your side, and what broke there?&lt;/p&gt;

</description>
      <category>testing</category>
      <category>architecture</category>
      <category>api</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Accountability Stays Human</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Wed, 23 Sep 2026 12:18:31 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/accountability-stays-human-2i9m</link>
      <guid>https://dev.to/anton_brilliantov/accountability-stays-human-2i9m</guid>
      <description>&lt;p&gt;&lt;em&gt;The executor writes most of the code; the check has to come from outside it.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton — a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This series is about the order the work happens in: a wish becomes requirements, requirements become a contract, the contract becomes tasks and specs, and the specs get run. This part is about the thing that does not travel down that road with the work — who is answerable for the result. Running notes live on my GitHub: &lt;strong&gt;&lt;a href="https://github.com/brilliant-almazov" rel="noopener noreferrer"&gt;github.com/brilliant-almazov&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

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




&lt;h2&gt;
  
  
  Primer, in one paragraph
&lt;/h2&gt;

&lt;p&gt;The cast is small. A &lt;strong&gt;spec&lt;/strong&gt; is a self-contained brief for one unit of work: it names the files, states the change and states what makes it accepted. An &lt;strong&gt;executor&lt;/strong&gt; performs one spec end to end; here that is usually an automated one, and nothing below changes if it is a person. A &lt;strong&gt;lead&lt;/strong&gt; prepares the facts, hands the specs out and accepts the results — that is me. The rules an executor is expected to follow live in an instruction file it reads before it starts. That file is the setting for everything that follows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule everybody had
&lt;/h2&gt;

&lt;p&gt;There was a rule, and it was not a vague one. In a test, the context comes from the test runtime; the empty placeholder context is forbidden. It was written down, in the instruction file, in the place rules are kept, and every executor read it before writing a line.&lt;/p&gt;

&lt;p&gt;Tests kept arriving with the empty one.&lt;/p&gt;

&lt;p&gt;Not once, and not from one executor. Across weeks, in whatever direction the work went, in files written by different runs on different days. The rule existed. It had been read. Nothing enforced it, so it did not hold.&lt;/p&gt;

&lt;p&gt;It started holding on the day writing such a file began to fail.&lt;/p&gt;

&lt;p&gt;That is the whole article in three lines. The rest of it is the price:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  reminder   a sentence in the conversation      lives for one session
  rule       a line in the instruction file      works while it is read
  check      a run that fails                    works whether or not anyone remembers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What the rule was worth unenforced: 421 calls in 184 files
&lt;/h2&gt;

&lt;p&gt;Eventually I counted instead of assuming. In one service tree, with the rule in force and visible: &lt;strong&gt;421 calls to the empty context, across 184 test files.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The number is not the interesting part. What those tests were worth is.&lt;/p&gt;

&lt;p&gt;A test built on the empty context has two properties, and both of them are quiet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It does not cancel along with itself.&lt;/strong&gt; The test's own lifetime is not attached to anything the test runtime controls, so when the thing under test hangs, the test hangs with it until the package timeout — instead of failing at the moment the deadline passes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It checks nothing at all about context propagation.&lt;/strong&gt; If the production code drops the context it was handed and quietly substitutes its own, the suite stays green. The exact defect the rule existed to prevent is the one defect that class of test cannot see.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the suite was green, and green meant considerably less than everyone reading it assumed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the rule became.&lt;/strong&gt; The form was made explicit rather than implied: the context in a test comes from the test runtime; a port stub does not discard the context it is handed but keeps it in a field and hands it back to the test; and every transport, repository, manager and consumer carries a cancellation test — cancel the context, assert the failure, assert nothing was written. Then the part that actually changed the outcome: writing a test file that uses the empty context &lt;strong&gt;fails&lt;/strong&gt;. Not a warning in a linter report somebody reads on Friday. The write does not complete.&lt;/p&gt;

&lt;p&gt;The repair took a set of small mechanical iterations, one file at a time, and none of it was interesting. That is the shape of this kind of debt: the discovery is one command, the fix is a queue of boring edits, and the only thing that stops the queue from re-forming next month is the thing that fires on the write.&lt;/p&gt;

&lt;p&gt;Two more findings from the same period, same class, one line each:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A cache-invalidation consumer was written and registered in &lt;strong&gt;no daemon at all&lt;/strong&gt;. The package compiles. Its unit tests are green. In production, invalidation does not run — not slowly, not intermittently. It does not run.&lt;/li&gt;
&lt;li&gt;A decode failure is returned as an ordinary error. The driver reads a returned error as "hand it back", and the subscription declares no redelivery limit, no backoff and no dead-letter queue. One undecodable message retries forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three findings, one shape. Every one of them is caught by a check that runs outside the executor's own account of what it did. Not one of them is caught by a green build — because in all three cases the build &lt;strong&gt;was&lt;/strong&gt; green.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this is normally handled
&lt;/h2&gt;

&lt;p&gt;Nothing below is a criticism. It is the baseline, and each line of it is reasonable where it came from.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Practice&lt;/th&gt;
&lt;th&gt;The common shape&lt;/th&gt;
&lt;th&gt;Where it stops&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Review&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://google.github.io/eng-practices/review/" rel="noopener noreferrer"&gt;Reading the change&lt;/a&gt; is the main way a machine-written error is caught&lt;/td&gt;
&lt;td&gt;A reviewer's attention is finite and uneven, and it degrades exactly when volume rises&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instruction file&lt;/td&gt;
&lt;td&gt;Conventions written into a file the executor reads — the &lt;a href="https://agents.md" rel="noopener noreferrer"&gt;&lt;code&gt;AGENTS.md&lt;/code&gt;&lt;/a&gt; convention and its equivalents&lt;/td&gt;
&lt;td&gt;It works while it is read, and reading it is not verifiable from outside&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Green tests&lt;/td&gt;
&lt;td&gt;The suite passed, so the change is fine&lt;/td&gt;
&lt;td&gt;A test can be green because it asserts nothing — see &lt;a href="https://martinfowler.com/bliki/TestCoverage.html" rel="noopener noreferrer"&gt;coverage as a signal&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three are true, and none of them is enforcement. Each leaves a gap that is filled by somebody remembering — a reviewer remembering to look for the thing, an executor remembering the line it read, a reader remembering that green is a weaker signal than it looks. Remembering is exactly the resource that does not survive volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  The share of the code is high; the accountability does not divide
&lt;/h2&gt;

&lt;p&gt;Most of the code in this work is produced by an executor. I am not going to attach a percentage to that — I do not measure it, and putting an invented number next to a real one is how the real one stops being believed.&lt;/p&gt;

&lt;p&gt;What I will state flatly is the part that does not scale with the share:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Setup, checking and acceptance stay with a person. Whatever fraction of the code the executor writes, none of the answerability moves with it.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is not an ethical position and I would rather not dress it up as one. It is about who answers to whoever is paying. Nobody on the other side of that conversation has any interest in which part of the output was typed by whom. There is one name attached to the result, and it is a human one — so the checking has to sit in the same place the answering does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reason is technical, not ideological
&lt;/h2&gt;

&lt;p&gt;The practical reason is narrower and more useful than the moral one: &lt;strong&gt;an executor fails in a predictable, small set of ways.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not randomly, and not creatively. It reproduces a form it saw nearby. It satisfies the letter of an acceptance criterion that was written loosely. It writes the code and does not wire it up. It answers "done" because the last command it ran exited zero.&lt;/p&gt;

&lt;p&gt;A predictable failure has exactly one reliable answer, and trust is not it. When a mistake recurs, the response is not a firmer reminder — it is a check that makes the mistake impossible to commit. Everything else in this article follows from that one sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ladder: reminder → rule → check
&lt;/h2&gt;

&lt;p&gt;I named this ladder in passing in the previous part. Here is the version with each rung honestly priced.&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%2Frzsouau25u56qzgdp27a.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%2Frzsouau25u56qzgdp27a.png" alt="Three rungs rising left to right — reminder, lives for one session; rule, works while it is read; check, works always and marked in green — with a reliability scale on the right carrying no numbers" width="800" height="621"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reminder.&lt;/strong&gt; A sentence in a conversation: "don't do that". It works for the duration of that conversation and is gone at the end of it. The next executor never saw it. That is not a weakness of the executor — it is what a reminder is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule.&lt;/strong&gt; A line in the instruction file. Better: it survives the session, it is quotable, it is the same for everyone. And it is conditional on being read, and on being read the same way twice. The 421 calls are what that condition costs once the file is long and the rule is one line in the middle of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check.&lt;/strong&gt; A linter rule, a forbidding test, a structure test, a blocking hook on the tool that writes the file. It does not care whether anybody remembers it exists. It fires at the moment of the mistake, on the mistake, with the fix in the message.&lt;/p&gt;

&lt;p&gt;The rungs are not alternatives. A rule that keeps being broken is a check that has not been written yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five rules that became checks
&lt;/h2&gt;

&lt;p&gt;Concrete, from the current setup. Each row was a rule first, was broken, and became a check afterwards.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The rule it used to be&lt;/th&gt;
&lt;th&gt;The check it is now&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;read rows through the one shared core, not by hand&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 environment catalog and the metrics snapshot true&lt;/td&gt;
&lt;td&gt;a drift check in the build: regenerate, compare, fail on a difference&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;do not let test coverage slide&lt;/td&gt;
&lt;td&gt;a ratchet on the threshold — it can rise, it cannot be lowered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;do not write a spec containing a phrase that makes it unrunnable&lt;/td&gt;
&lt;td&gt;the file write is blocked when that phrasing appears in the text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;take the context in a test from the test runtime&lt;/td&gt;
&lt;td&gt;the file write is blocked when the empty context appears in a test&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%2Fwee5hwga7x75wmf9xvzq.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%2Fwee5hwga7x75wmf9xvzq.png" alt="Five rows pairing a rule with the check that replaced it — forbidding test, drift check in the build, coverage ratchet, blocked file write on a forbidden phrasing, blocked file write on an empty context in a test" width="799" height="522"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read the right-hand column as a group. None of those five is clever. Every one of them is mechanical, cheap to run, and — this is the point — &lt;strong&gt;indifferent to who is at the keyboard and how the day is going&lt;/strong&gt;. That indifference is the entire property being bought.&lt;/p&gt;

&lt;h2&gt;
  
  
  A gate leans on an artefact, not on a claim
&lt;/h2&gt;

&lt;p&gt;From which one requirement on the process follows, short enough to fit on a line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Every gate leans on an artefact — a number, a run, a file — and never on the executor's statement that the thing is done.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&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%2F4zizn2gtmejz5oxzkpsa.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%2F4zizn2gtmejz5oxzkpsa.png" alt="A gate with two arrows approaching it: the upper one, labelled " width="800" height="551"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Drawn as the two things that arrive at a transition, it is not a subtle design:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   "the iteration is finished"  ──▶ ┃  closed          a claim is not an input
                                    ┃
   number · test run · file    ──────────────▶        the artefact is the input
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"It's done" is a claim. It carries no information about whether it is true, and it costs nothing to produce — including when I am the one producing it. A number that was measured, a command that was run with its output read, a file that exists at a named path: those can be wrong too, but they can be wrong &lt;em&gt;checkably&lt;/em&gt;, which is a different category of thing.&lt;/p&gt;

&lt;p&gt;This is not scepticism about the executor. It is the same rule turned on me: when I say a spec is ready, that is a claim; when the spec contains no open question, that is a fact somebody else can verify without asking me.&lt;/p&gt;

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

&lt;p&gt;The bill is real, and it arrives in four parts.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every check is code.&lt;/strong&gt; It has to be written, and then maintained indefinitely, against a tree that keeps moving. The five above were not free and they are not finished.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A check catches form, never intent.&lt;/strong&gt; &lt;code&gt;for rows.Next()&lt;/code&gt; in exactly one package is a fact about shape. It says nothing about whether the query behind it is the right query. No amount of this saves a wrong architecture; it only stops a known-wrong form from spreading.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A false positive is expensive twice.&lt;/strong&gt; It costs the executor's run, and it costs the credibility of the check — one that cries wolf gets routed around, and then it is worse than nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One of these is honestly unfinished.&lt;/strong&gt; The coverage ratchet exists and works in the direction it was built for: the threshold can only ever rise. But the threshold is currently set to &lt;code&gt;0&lt;/code&gt;, while actual coverage sits at &lt;strong&gt;86.7 %&lt;/strong&gt;. The mechanism is in place; the bar has not been raised to meet it. That is a job not yet done, and I would rather list it here than let the word "ratchet" imply a discipline that is not being applied yet.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A one-off script nobody will maintain.&lt;/strong&gt; The check outlives the thing it guards, and then it is pure cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A rule that has never actually been broken.&lt;/strong&gt; Start with the reminder. A check written for a hypothetical failure buys nothing and adds a thing to maintain. Let the rule fail first, then automate it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An area with no checkable signal.&lt;/strong&gt; If the property you care about has no mechanical fingerprint, an invented one produces false positives — and false positives are how a check loses its authority.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;All of this is kept on &lt;strong&gt;my own tasks&lt;/strong&gt;. There is no multi-person sample behind any of it. The 421 and the 184 are counts from one tree on one day, not a study; the five checks are the ones I happened to need, in the order I happened to need them.&lt;/p&gt;

&lt;p&gt;I would be misrepresenting the material if I presented it as an industry finding rather than as one practitioner's log, and I have no way of knowing which parts of it survive contact with a team of eight.&lt;/p&gt;

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

&lt;p&gt;The uncomfortable version of the throughline: speed of production amplifies whoever set the definitions, and it amplifies a bad definition exactly as efficiently as a good one. An executor that writes a correct file in four minutes writes an incorrectly specified one in four minutes too — and then it writes a hundred of them.&lt;/p&gt;

&lt;p&gt;Which is why the ladder ends where it does. The faster the production side gets, the less the middle rung is worth on its own, and the more of the outcome rests on checks that fire without anyone being present. That leaves the definitions — what counts as done, what counts as an artefact, which mistake is worth a check — sitting exactly where they were before any of this started: with a person, who cannot delegate them, and who is the one asked about the result.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;From requirement to contract — Part 5.&lt;/strong&gt; Next: the checkable condition comes before the thing that satisfies it — writing the test, the criterion or the guard first, and what changes when the order runs the other way.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you do this better&lt;/strong&gt;, tell me which of your rules you managed to turn into a check and which ones resisted. If &lt;strong&gt;you have been through this&lt;/strong&gt;, what was your version of the 421 — the rule everyone had read and nobody followed? If &lt;strong&gt;you see it differently&lt;/strong&gt;, say where a blocking check cost you more than the mistake it prevented. How is it solved on your side, and what broke there?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>architecture</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Gates, Not Micromanagement</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Tue, 22 Sep 2026 17:10:07 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/gates-not-micromanagement-5h1i</link>
      <guid>https://dev.to/anton_brilliantov/gates-not-micromanagement-5h1i</guid>
      <description>&lt;p&gt;&lt;em&gt;Control lives on the transitions; between them nobody watches.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton — a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This series is about the order the work happens in: a wish becomes requirements, requirements become a contract, the contract becomes tasks and specs. This part is about where the checks sit on that road, and about the kind of supervision I stopped doing. Running notes live on my GitHub: &lt;strong&gt;&lt;a href="https://github.com/brilliant-almazov" rel="noopener noreferrer"&gt;github.com/brilliant-almazov&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

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




&lt;h2&gt;
  
  
  Primer, in one paragraph
&lt;/h2&gt;

&lt;p&gt;The shape of the work in this series: a wish becomes requirements, requirements become a contract declared in a schema, the contract becomes tasks, and a task becomes a &lt;strong&gt;spec&lt;/strong&gt; — a self-contained brief that one executor runs end to end without reading anything outside the files it names. An executor is whoever performs one such spec; here that is usually an automated one, and nothing below changes if it is a person. A &lt;strong&gt;lead&lt;/strong&gt; prepares the facts, hands the specs out and accepts the results. That is the whole cast: a road with steps on it, specs travelling down it, and one person deciding whether a step has been passed.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Control belongs on the transitions, not on the process.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Watching how the work is going produces no fact that the acceptance of the next transition would not produce anyway. It produces a feeling of proximity, at the price of an interruption on both sides — and it produces it repeatedly, because a process has no natural end and a transition does.&lt;/p&gt;

&lt;p&gt;So the road from a wish to a release is cut into five transitions, and each of them carries exactly two things: &lt;strong&gt;what is accepted&lt;/strong&gt;, and &lt;strong&gt;what proves it&lt;/strong&gt;. Everything between two gates is nobody's business until the next gate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  wish ──▶ requirements ──▶ contract ──▶ spec ──▶ execution ──▶ acceptance ──▶ release
                         │            │        │             │              │
                        [1]          [2]      [3]           [4]            [5]
                      numbers    compatibility  no open    one command   build, tests,
                     not "fast"     is green    question    that runs   structure, drift
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Nothing below is a criticism. It is the baseline I moved away from, and every line of it is reasonable in the setting it came from.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Practice&lt;/th&gt;
&lt;th&gt;The common shape&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Daily status&lt;/td&gt;
&lt;td&gt;A &lt;a href="https://scrumguides.org/scrum-guide.html" rel="noopener noreferrer"&gt;daily meeting&lt;/a&gt; where everyone says what they did and what they will do&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Board with columns&lt;/td&gt;
&lt;td&gt;A &lt;a href="https://www.atlassian.com/agile/kanban/wip-limits" rel="noopener noreferrer"&gt;work-in-progress limit&lt;/a&gt; on a board, so load is visible at a glance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Review along the way&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://google.github.io/eng-practices/review/" rel="noopener noreferrer"&gt;Reading changes as they appear&lt;/a&gt;, not only at the end&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Progress report&lt;/td&gt;
&lt;td&gt;A written summary of how far along the work is, produced on a cadence&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All four answer the question &lt;em&gt;how is it going&lt;/em&gt;. None of them answers &lt;em&gt;may this step be passed&lt;/em&gt;. That is the question I care about, because it is the only one that changes what happens next.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five gates
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Transition&lt;/th&gt;
&lt;th&gt;What is accepted&lt;/th&gt;
&lt;th&gt;What proves it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;requirements → contract&lt;/td&gt;
&lt;td&gt;requirements are complete and consistent&lt;/td&gt;
&lt;td&gt;non-functional ones stated as numbers, not as the word "fast"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;contract → spec&lt;/td&gt;
&lt;td&gt;the contract is accepted&lt;/td&gt;
&lt;td&gt;the compatibility check is green&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;spec → execution&lt;/td&gt;
&lt;td&gt;the spec is runnable without reconnaissance&lt;/td&gt;
&lt;td&gt;there is no open question in its text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;execution → acceptance&lt;/td&gt;
&lt;td&gt;the code does what it claims&lt;/td&gt;
&lt;td&gt;one acceptance command&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;acceptance → release&lt;/td&gt;
&lt;td&gt;the change does not break the neighbours&lt;/td&gt;
&lt;td&gt;build, tests, structure and drift checks&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%2Fcuadfg4ladugnq11v3k9.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%2Fcuadfg4ladugnq11v3k9.png" alt="Five gate cards along one line of work — requirements to contract, contract to spec, spec to execution, execution to acceptance, acceptance to release — each carrying what is accepted and, in monospace, the artefact that proves it" width="800" height="494"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The third column is the whole design. It is not a wish, not a definition of done written in adjectives, not "the reviewer is happy". It is something that can be run or read: a number, a green check, the absence of a question mark, a command, a build.&lt;/p&gt;

&lt;p&gt;A gate whose third column cannot be filled in is not a gate. It is a meeting.&lt;/p&gt;

&lt;p&gt;Firing one looks like this, and it looks the same every time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   execution ──▶ acceptance
   ────────────────────────────────────────────────
   the claim     "the iteration is finished"
   the artefact  one command, run, output read
                 ─────────────────────────────────
                 ok      → transition passed
                 not ok  → not accepted, no discussion of effort
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The claim and the artefact are on separate lines on purpose. The first one is never the input to the decision; the second one always is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Between the gates, nobody watches
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   gate 3 ───────────────── the interval ───────────────── gate 4
   spec handed over       nobody watches in here          acceptance
                          ✗ no status check-in
                          ✗ no progress report
                          ✗ no review mid-run
&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%2Fjiviysib2a27vg2cppiz.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%2Fjiviysib2a27vg2cppiz.png" alt="Two green gate posts with a wide neutral interval between them, the interval labelled nobody watches in here and carrying three struck-through lines — no status check-in, no progress report, no review mid-run" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two consequences, and the second one is the uncomfortable one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nobody interrupts the work in progress.&lt;/strong&gt; Not to ask how it is going, not to suggest a better approach halfway, not to look at a half-written file. The interval belongs to whoever is inside it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Responsibility is delegated together with the decisions.&lt;/strong&gt; Whoever holds a gate has the right to say &lt;em&gt;not accepted&lt;/em&gt;, and is not obliged to justify it line by line. That right is what makes the gate a gate; without it the gate degenerates into an opinion that can be argued past, and the process reverts to supervision by whoever argues longest.&lt;/p&gt;

&lt;h2&gt;
  
  
  A gate stands on an artefact, not on a claim
&lt;/h2&gt;

&lt;p&gt;This is the line I would keep if I had to delete everything else in this article.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A statement that the work is done is not evidence that the work is done.&lt;/strong&gt; Not from an executor, not from me. Every gate leans on an artefact — a number, a run, a file — and the artefact is looked at, not reported on.&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%2Fiz9mlzsr0i6fvk2v6jpg.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%2Fiz9mlzsr0i6fvk2v6jpg.png" alt="Two cards side by side — on the left a claim, the words it's done struck through with a hairline and marked not evidence; on the right an artefact, three monospace lines reading a number, a run, a file" width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The reason is not ideological, it is a property of the executor. Executors fail in a predictable, small set of ways, and the reliable answer to a predictable failure is an external check rather than trust. The ladder I use everywhere is &lt;strong&gt;reminder → rule → check&lt;/strong&gt;: a reminder lives in one conversation, a rule works for as long as somebody reads it, and only the check works whether or not anyone remembers it exists.&lt;/p&gt;

&lt;p&gt;That ladder applies to me as much as to anyone else. When I say the spec is ready, that is a claim. When the spec contains no open question, that is a fact somebody can verify without me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The case: five signs, all of them read at a gate
&lt;/h2&gt;

&lt;p&gt;Here is what this actually looks like on real work, rather than as a principle.&lt;/p&gt;

&lt;p&gt;A set of specs is written, handed out, executed, accepted. Along the way, five things can happen. Each of them is visible &lt;strong&gt;at a gate&lt;/strong&gt; — during acceptance, or as a blocked write — and none of them requires anybody to have watched the work happen.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sign&lt;/th&gt;
&lt;th&gt;What it actually means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;a clarifying question was asked&lt;/td&gt;
&lt;td&gt;a fact was not written into the spec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;a rule blocked the write&lt;/td&gt;
&lt;td&gt;a primitive that should have been reused was not named&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;two iterations edit the same file&lt;/td&gt;
&lt;td&gt;the split into iterations is wrong&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;an iteration is rewritten after acceptance&lt;/td&gt;
&lt;td&gt;the acceptance criterion was not checkable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;an iteration ate more context than its named files need&lt;/td&gt;
&lt;td&gt;a defect of the spec&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%2Fimg2iwfrynazt1oh6yum.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%2Fimg2iwfrynazt1oh6yum.png" alt="Five rows, each pairing a sign with what it means — a clarifying question means a missing fact, a blocked write means an unnamed primitive, two iterations on one file means a wrong split, a rewrite after acceptance means an uncheckable criterion, excess context means a spec defect — closing with the extreme case of about 350 000 tokens per iteration" width="800" height="612"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read the right-hand column again. Not one entry says anything about the executor. Every one of them is a &lt;strong&gt;defect of the specification&lt;/strong&gt;, discovered at a transition and paid for at that transition.&lt;/p&gt;

&lt;p&gt;The extreme case of the last row is the number I keep coming back to in this series: iterations of one early set were eating &lt;strong&gt;about 350 000 tokens each&lt;/strong&gt;, because the executor was assembling its own context — hunting for types, paths and signatures instead of receiving them. The tasks were completing. Nothing looked wrong from the outside. The cost was visible only because acceptance recorded it, and it turned into two concrete repairs — a closed list of files per iteration, and a section of stated facts in the controlling spec.&lt;/p&gt;

&lt;p&gt;I am not going to attach a count of how often each sign fired, or how long a transition takes. Those numbers do not exist in my records, and inventing them next to a real one is how a real one stops being believed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like from the lead's side
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fojm5tzfqto1a443e55tv.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%2Fojm5tzfqto1a443e55tv.png" alt="Four steps in a row — prepare the facts, hand out, accept the iteration by its own package, accept the stage with one full run at the end — with a note that the lead does not write code and that there is no full run inside an iteration" width="800" height="414"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The chain is short, and the shape of it is the point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prepare the facts.&lt;/strong&gt; The only step where reading around the codebase is allowed at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hand out.&lt;/strong&gt; One executor, one task, one context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accept the iteration&lt;/strong&gt; — by its own package only. There is no full run inside an iteration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accept the stage&lt;/strong&gt; — one full run, once, at the end.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lead does not write code. That is not a rank, it is a conflict of interest: the person holding a gate should not also be the person whose work the gate is judging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why gates and not observation
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Observation requires presence. A gate does not: it fires when the transition is attempted, whether or not anyone is at their desk.&lt;/li&gt;
&lt;li&gt;Observation catches only what has already happened, and usually catches it in the version the observer was told about. A gate catches a divergence at the moment of the transition, and it costs one run.&lt;/li&gt;
&lt;li&gt;Observation scales badly. Two parallel executors are already more than one person can watch honestly; the gates do not care how many there are, because they sit on the road rather than on the worker.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every gate needs an artefact, and building the artefact is work.&lt;/strong&gt; A gate without one drifts into taste, and taste is exactly the thing being replaced. That is a real up-front bill: the compatibility check, the acceptance command, the structure and drift checks all had to be built before any of this could be run this way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Not accepted" without a line-by-line justification reads as rude.&lt;/strong&gt; It works only where there is enough trust in whoever holds the gate, and it takes a while to establish that trust. In a setting without it, this style is corrosive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Between gates, nothing is known about the work.&lt;/strong&gt; If a transition is long, bad news arrives late. The cure is a finer split — more, smaller transitions — not the reintroduction of supervision. Every time I have been tempted to add a check-in, the real problem was that an iteration was too big.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Research.&lt;/strong&gt; When the transition is not visible yet, there is nothing to put a gate on. First the exploration, then the spec, then the gates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A task without a readiness criterion.&lt;/strong&gt; "Make it nicer", "improve it" — there is nothing to accept, so acceptance cannot happen and the gate is theatre.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Teaching someone new.&lt;/strong&gt; Here the process itself is the point: watching how a person arrives at an answer is the entire content of the exercise. Gating only the outcome teaches nothing.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;An executor cannot be given a conversation about how the work is going. There is nowhere to put one. It can be given exactly two things: an input and a criterion — and it will return exactly what those two describe, including everything they failed to describe.&lt;/p&gt;

&lt;p&gt;Which means the discipline that reads as harsh when applied to people turns out to be the only shape that works at all here. Not because the tooling demands rigour, but because it removes the fallback: with a person, a vague spec is quietly repaired by a conversation nobody records. With an executor there is no conversation, so the vagueness is delivered intact, in the output, at the gate. That is a good thing. It just leaves the definitions with nowhere to hide, and the definitions are still mine to get right.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;From requirement to contract — Part 4.&lt;/strong&gt; Next: responsibility does not move to the executor, even when the executor writes most of the code — what stays on a person, and what it takes to keep it there.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you do this better&lt;/strong&gt;, tell me what your third column looks like — what artefact your transitions actually lean on. If &lt;strong&gt;you have been through this&lt;/strong&gt;, what did removing the status check-in break first? If &lt;strong&gt;you see it differently&lt;/strong&gt;, say where watching the process caught something a gate would have missed. How is it solved on your side, and what broke there?&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>productivity</category>
      <category>management</category>
      <category>ai</category>
    </item>
    <item>
      <title>The Tracker Is the Spine</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Sun, 20 Sep 2026 22:02:27 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/the-tracker-is-the-spine-b3h</link>
      <guid>https://dev.to/anton_brilliantov/the-tracker-is-the-spine-b3h</guid>
      <description>&lt;p&gt;&lt;em&gt;Estimate, status, incidents, budget — one thread, or the cost of a requirement is a feeling.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton — a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This series is about the order the work happens in: a wish becomes requirements, requirements become a contract, the contract becomes tasks. This part is about the last link — the tracker task — and about what it has to carry before anyone can answer "what did that requirement cost".&lt;/p&gt;

&lt;p&gt;Running notes live on my GitHub: &lt;strong&gt;&lt;a href="https://github.com/brilliant-almazov" rel="noopener noreferrer"&gt;github.com/brilliant-almazov&lt;/a&gt;&lt;/strong&gt;. This is how I do it right now, with the price attached — maybe you already do it better, maybe you see it differently.&lt;/p&gt;




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

&lt;p&gt;The tracker here is not reporting and it is not ceremony. It is the only place where a requirement, a contract, a unit of executable work, a pull request and an acceptance decision sit on &lt;strong&gt;one thread&lt;/strong&gt;, in an order you can walk in either direction.&lt;/p&gt;

&lt;p&gt;That thread exists for exactly one question: &lt;em&gt;what did this cost?&lt;/em&gt; Not "was everyone busy" — what one requirement cost, from the sentence someone said out loud to the change being accepted.&lt;/p&gt;

&lt;p&gt;Without an estimate written before the work and a fact recorded after it, that question has no answer. It has opinions. "That one was expensive" and "that one was fine" become statements about how the week felt, and every argument about scope after that is an argument between two feelings.&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%2Fiei6yqrjdw7pzqj67jz9.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%2Fiei6yqrjdw7pzqj67jz9.png" alt="One horizontal thread with five nodes — requirement, contract, task, PR, accepted — and under it a table of what each node records; the task node is highlighted and carries estimate, incidents and budget" width="800" height="527"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The case: iterations that cost 350 000 tokens each
&lt;/h2&gt;

&lt;p&gt;The concrete thing that made me build this rather than talk about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happened.&lt;/strong&gt; In one of the early prompt sets, each iteration was eating &lt;strong&gt;about 350 000 tokens&lt;/strong&gt;. The tasks were being completed. Nothing was failing. The number simply sat there in the task, next to the estimate, being much larger than the estimate implied.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it was going.&lt;/strong&gt; Not into writing code. The largest single line item was &lt;strong&gt;reconnaissance&lt;/strong&gt;: the executor was assembling its own context — hunting for types, for file paths, for signatures, reading neighbouring packages to work out how things were done around here. After reconnaissance came repeat runs, and after that, rework.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What that actually meant.&lt;/strong&gt; The same fact — one type name, one signature, one convention — was being rediscovered independently by every executor that touched that area. The cost of a fact was being multiplied by the number of people who needed it, and nobody was writing the fact down anywhere it would be found next time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What changed.&lt;/strong&gt; The spec grew two things: a &lt;strong&gt;closed list of files&lt;/strong&gt; for each iteration, and a section called &lt;em&gt;facts of the set&lt;/em&gt;. A fact is written once, in the spec. Reading is limited to the named files — at most three of them. Searching the tree is not permitted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is visible today.&lt;/strong&gt; As of 2026-08-16: &lt;strong&gt;298 spec files&lt;/strong&gt;, &lt;strong&gt;more than 20 closed sets&lt;/strong&gt;, 3 sets in flight, 5 written but not yet taken, and the largest single set at &lt;strong&gt;41 files&lt;/strong&gt;. Real set sizes, for scale: &lt;strong&gt;13, 12 and 40 iterations&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I am not going to attach hours or money to that case. Those values do not exist in my records, and putting invented ones next to a real argument is how a real argument stops being trusted.&lt;/p&gt;

&lt;p&gt;The part worth keeping is this: &lt;strong&gt;the 350 000 was only ever visible because the task carried an estimate and a recorded fact.&lt;/strong&gt; Without both, it is an executor that finished its work. With both, it is a spec defect with a price on it.&lt;/p&gt;

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

&lt;p&gt;Nothing below is a criticism; it is the baseline I diverged from, and it is a reasonable baseline.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Practice&lt;/th&gt;
&lt;th&gt;The common shape&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Estimate&lt;/td&gt;
&lt;td&gt;Abstract units of complexity — &lt;a href="https://www.mountaingoatsoftware.com/blog/what-are-story-points" rel="noopener noreferrer"&gt;story points&lt;/a&gt;, deliberately not hours, deliberately relative&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Status&lt;/td&gt;
&lt;td&gt;Movement through a board, largely as reporting upward to a manager&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reflection&lt;/td&gt;
&lt;td&gt;A &lt;a href="https://scrumguides.org/scrum-guide.html" rel="noopener noreferrer"&gt;retrospective&lt;/a&gt; at the end of an iteration, discussing the period as a whole&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Incidents&lt;/td&gt;
&lt;td&gt;Written up afterwards, in a &lt;a href="https://sre.google/sre-book/postmortem-culture/" rel="noopener noreferrer"&gt;postmortem&lt;/a&gt;, for the events big enough to warrant one&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The reason points avoid hours is sound: hours invite the estimate to be read as a commitment, and relative sizing between people is more stable than absolute sizing. The reason retrospectives are periodic is also sound — a period gives you a sample.&lt;/p&gt;

&lt;p&gt;My problem with the baseline is narrower than "it's wrong". It is that none of those four practices attaches a number to &lt;strong&gt;one requirement&lt;/strong&gt;. They attach numbers to a period, a board, or an outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do instead — four practices, on every task
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. An estimate is mandatory
&lt;/h3&gt;

&lt;p&gt;Every task carries an estimate before work starts. The scale is &lt;strong&gt;hours of executor time: 1 point ≈ 1 hour of an executor's run.&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;Property&lt;/th&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;When&lt;/td&gt;
&lt;td&gt;Set before the work starts, never after&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scale&lt;/td&gt;
&lt;td&gt;Hours of executor time, not abstract complexity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;On scope change&lt;/td&gt;
&lt;td&gt;Revised explicitly, as a visible edit, not quietly absorbed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What it excludes&lt;/td&gt;
&lt;td&gt;Human hours. This estimates the run, not the labour&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is the one people push back on, and the pushback is fair — it is the exact thing story points were invented to avoid. My defence is that this scale is not measuring a person, so it cannot be used as a promise extracted from one. It measures a run whose actual cost is recorded afterwards in the same units. An estimate you can compare against a fact in the same unit is worth more to me than an estimate that is safe from misuse.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Status moves on every transition
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  taken into work  ────►  PR opened  ────►  accepted
                             │
                             └──► blocked on something external
                                  (a status, not silence)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three transitions, each one moved the moment it happens. The fourth state matters most: when a task is waiting on something outside it, that is a &lt;strong&gt;named status&lt;/strong&gt;, not a task that has gone quiet. Silence and blocked look identical from outside and cost very different things.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Incidents are recorded at the moment they happen
&lt;/h3&gt;

&lt;p&gt;Five categories. One line each, written when the thing occurs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;What it catches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Executor error&lt;/td&gt;
&lt;td&gt;The work was done wrong against a spec that was clear&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scope change&lt;/td&gt;
&lt;td&gt;The requirement moved after the task was estimated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spec ambiguity&lt;/td&gt;
&lt;td&gt;The spec permitted two readings and the wrong one was taken&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Technical surprise&lt;/td&gt;
&lt;td&gt;Reality differed from what the spec assumed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External block&lt;/td&gt;
&lt;td&gt;Progress stopped on something outside this task&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%2Fnqapcs144scfcn6xq5ee.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%2Fnqapcs144scfcn6xq5ee.png" alt="Five rows, each an incident category on the left and what it catches on the right, closing with the line: recorded at the moment, not afterwards" width="800" height="532"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And one rule that does more work than the five categories combined: &lt;strong&gt;reconstructing incidents after the fact is itself an incident.&lt;/strong&gt; Not a scolding — a data-quality statement. Numbers assembled from memory at the end of a task are numbers you cannot act on, so the act of assembling them late gets recorded as the defect it is.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. A final report closes the task
&lt;/h3&gt;

&lt;p&gt;Every task ends with a report in a fixed shape, so that tasks are comparable to each other:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmddjwb09ttc4rgdjvt6p.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%2Fmddjwb09ttc4rgdjvt6p.png" alt="A vertical list of final report fields — time split into executor, human and pauses, then tokens, money, external calls, incidents, estimate versus actual, and result — with the value slots deliberately left blank" width="800" height="546"&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;  time            executor  ·  human  ·  pauses
  tokens          ─
  money           ─
  external calls  ─
  incidents       ─
  estimate        ─
  actual          ─
  result          ─
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The slots above are empty on purpose. I am publishing the &lt;strong&gt;shape&lt;/strong&gt; of the report, not a filled example — I do not have a sanitised filled one to show, and inventing plausible hours and dollars to make a figure look complete would undo the entire point of the section.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the thread makes visible
&lt;/h2&gt;

&lt;p&gt;Every number below exists only because each task carries an estimate and a recorded fact. Remove either one and these are anecdotes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tokens per iteration, one early set&lt;/td&gt;
&lt;td&gt;≈ 350 000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Largest expense inside that&lt;/td&gt;
&lt;td&gt;Reconnaissance — types, paths, signatures, neighbouring packages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real set sizes&lt;/td&gt;
&lt;td&gt;13 · 12 · 40 iterations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spec files, as of 2026-08-16&lt;/td&gt;
&lt;td&gt;298&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Closed sets&lt;/td&gt;
&lt;td&gt;20+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sets in flight&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Written, not yet taken&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Largest single set&lt;/td&gt;
&lt;td&gt;41 files&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%2F8le7nk26wwncvdawgbl4.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%2F8le7nk26wwncvdawgbl4.png" alt="Four cards with the counted numbers — 298 spec files, more than 20 closed sets, 3 in flight, 5 written but not taken — over a strip carrying the largest set at 41 files and the real set sizes of 13, 12 and 40 iterations" width="799" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The distribution is the interesting part. Sets of 13 and 12 iterations sit next to a set of 40, and knowing which shape you are about to start is the difference between an estimate and a guess. That distribution is only knowable because the closed sets kept their numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signals that a task was expensive
&lt;/h2&gt;

&lt;p&gt;Five of them. Each is one line in the task, and any one of them means the work cost more than the estimate implied:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A clarifying question had to be asked.&lt;/li&gt;
&lt;li&gt;A rule fired and blocked something.&lt;/li&gt;
&lt;li&gt;Two iterations edited the same file.&lt;/li&gt;
&lt;li&gt;An iteration was rewritten after acceptance.&lt;/li&gt;
&lt;li&gt;An iteration consumed more context than its named files could account for.&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%2F7faed7h0su4gwuqd0zxz.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%2F7faed7h0su4gwuqd0zxz.png" alt="Five signals that a task cost more than its estimate implied, each with what it calibrates, closing with the line that this calibrates the spec and not the executor" width="800" height="532"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;All five are read the same way, and this is the part that decides whether the practice survives contact with people: &lt;strong&gt;these calibrate the spec, not the executor.&lt;/strong&gt; A clarifying question means a sentence was ambiguous. A rewrite after acceptance means the readiness criterion was wrong. The moment any of these is read as a performance signal about whoever did the work, recording them honestly stops, and the whole thread turns into fiction that costs time to produce.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Because the estimate and the fact are in the same unit.&lt;/strong&gt; Hours of executor time, estimated up front, hours of executor time recorded afterwards. That gives one number you can put next to the value of the requirement and ask whether the trade was worth making. Two numbers in different units — points against dollars, complexity against duration — cannot be compared without a conversion nobody agrees on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Because incident categories answer "what do I fix", not "who was wrong".&lt;/strong&gt; Each category has a different repair, and the repair is what makes the category worth writing down:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;What it repairs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Spec ambiguity&lt;/td&gt;
&lt;td&gt;Fixed in text — the sentence gets rewritten&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Executor error&lt;/td&gt;
&lt;td&gt;Fixed by an external check, not by asking for more care&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scope change&lt;/td&gt;
&lt;td&gt;Fixed in the estimate, explicitly and visibly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Technical surprise&lt;/td&gt;
&lt;td&gt;Fixed in the facts section, so the next set inherits it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External block&lt;/td&gt;
&lt;td&gt;Fixed by making the wait visible, not by waiting quietly&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of those repairs are the subject of the next parts of this series. Ambiguity is repaired by making the spec exhaustive. Executor error is repaired by a check that runs whether or not anyone remembers to run it.&lt;/p&gt;

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

&lt;p&gt;An automated executor makes the accounting cheap — the numbers can be pulled and folded into a report without anyone typing them. What it does not do is decide what counts as an incident, or what the estimate should have been, or which of the five categories a given failure belongs in. Those are judgements, and the whole value of the thread is in them. Cheap measurement amplifies whoever set the definitions; it does not supply the definitions.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It taxes every task.&lt;/strong&gt; Estimating, moving status, writing incidents as they happen and closing with a report all take time on every single task, including the tiny ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is the first thing to be dropped under pressure.&lt;/strong&gt; When a deadline is close, the accounting is what gets skipped, and it gets skipped exactly when the data would have been most useful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Late reconstruction produces numbers you cannot trust.&lt;/strong&gt; Which is why it is classified as an incident rather than as a catch-up — the alternative is a tracker full of confident, wrong figures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The scale does not transfer.&lt;/strong&gt; "One point ≈ one hour of an executor run" is not a scale for estimating a team of people, and using it that way would reproduce every problem story points were designed to solve.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A one-off change.&lt;/strong&gt; Seven fields of accounting around a single-line fix is ceremony, and I skip it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A task with no readiness criterion.&lt;/strong&gt; If there is nothing to accept, there is no end to the thread, and a report on an unaccepted task is a report on nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Somebody else's process.&lt;/strong&gt; Where status already carries a different meaning — a contractual milestone, a billing state — overloading it with a second meaning breaks the first one.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The estimates, the incidents and the budgets described here are kept on &lt;strong&gt;my own tasks&lt;/strong&gt;. There is no multi-person sample behind any of it, and I would be misrepresenting it if I presented these numbers as an industry finding rather than as one practitioner's log.&lt;/p&gt;

&lt;p&gt;There is also no separate analysis function in this setup: requirements are formulated by the person who owns the task. That works at this size and it is a known limit, not a design principle.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;From requirement to contract — Part 3.&lt;/strong&gt; Next: watching a process is not the same as gating it — where the checks sit on the transitions themselves, so that a step cannot be passed rather than merely being observed.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you do this better&lt;/strong&gt;, tell me what unit your estimate and your actual are both recorded in. If &lt;strong&gt;you have been through this&lt;/strong&gt;, what did your version of the 350 000 turn out to be hiding? If &lt;strong&gt;you see it differently&lt;/strong&gt;, say where per-task accounting cost you more than it returned. How is it solved on your side, and what broke there?&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>architecture</category>
      <category>management</category>
      <category>ai</category>
    </item>
    <item>
      <title>The Contract Is a .proto File</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Sat, 19 Sep 2026 16:36:38 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/the-contract-is-a-proto-file-48g9</link>
      <guid>https://dev.to/anton_brilliantov/the-contract-is-a-proto-file-48g9</guid>
      <description>&lt;p&gt;&lt;em&gt;One handle, one source of truth - the HTTP spec is derived, not written.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This series is about the road from a requirement to a contract: what has to be true before anybody writes code. This part is the short one, with a single claim in 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;.&lt;/p&gt;

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




&lt;h2&gt;
  
  
  Primer, in one paragraph
&lt;/h2&gt;

&lt;p&gt;Every service-to-service call in this system is declared in a schema file - a &lt;code&gt;.proto&lt;/code&gt; - kept in one shared contract repository. The build turns that file into typed clients and servers for both sides of the call. The same file also carries the HTTP role of a handle: the door service exposes it over HTTP, and the HTTP specification is produced from the schema rather than maintained beside it. One file per interface; everything else in the picture is output.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;The contract is declared in the schema, and nowhere else.&lt;/strong&gt; Messages, calls, the meaning of each field, which fields are required, the bounds a value may take, the codes a refusal can carry - all of it lives in the &lt;code&gt;.proto&lt;/code&gt;. Nothing about the interface is described in a second place.&lt;/p&gt;

&lt;p&gt;That reads like a style preference until you name the alternative out loud: two documents that mean the same thing, kept in step by a person remembering to do it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The case: a declaration that is checked by the build
&lt;/h2&gt;

&lt;p&gt;I did not learn this on handles. I learned it on the two most boring artifacts a service has - its environment variables and its metrics - where the same principle is already running.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it was.&lt;/strong&gt; Both were described by hand, in files next to the code, and both drifted from the code quietly. Nothing failed. The description was simply wrong, and it stayed wrong for as long as nobody happened to read it against the source.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What was done.&lt;/strong&gt; Both descriptions are now taken off the code by machine and kept as repository artifacts. For a variable declared by the service configuration the catalog records &lt;code&gt;defined_in&lt;/code&gt; - the file and the line of the declaration; for one declared by the platform, the owning catalog. The metrics snapshot has one row per metric plus a &lt;code&gt;&amp;lt;dynamic&amp;gt;&lt;/code&gt; row for the factory that registers metrics at runtime.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Artifact&lt;/th&gt;
&lt;th&gt;Entries&lt;/th&gt;
&lt;th&gt;Where they come from&lt;/th&gt;
&lt;th&gt;An entry records&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Environment catalog&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;59 variables&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;45 platform · 14 service config&lt;/td&gt;
&lt;td&gt;where it is declared&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Metrics snapshot&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;67 entries&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;59 platform · 6 service · &lt;code&gt;&amp;lt;dynamic&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;how it is registered&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%2Fpkjlw4jyxmd1pifbkv4u.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%2Fpkjlw4jyxmd1pifbkv4u.png" alt="Two machine-made snapshots side by side - an environment catalog of 59 variables split into 45 from the platform and 14 from the service configuration, and a metrics snapshot of 67 entries from the platform, the service and a dynamic factory - with the drift check below them and the one uncovered zone marked separately" width="800" height="567"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What holds it.&lt;/strong&gt; Drift is a build failure. The check runs the same generator in &lt;code&gt;--check&lt;/code&gt; mode whenever any &lt;code&gt;.go&lt;/code&gt; file, the manifest, the modules file or the snapshot itself changes, and the generator is installed at the same platform version the service has in its modules. Not a review convention, not a linter warning - a red build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it stops.&lt;/strong&gt; Resource variables are not in the catalog: the platform builds their names by concatenation at runtime, so there is nothing static to read. That is a stated hole rather than a silent one, and it is the honest part of the claim.&lt;/p&gt;

&lt;p&gt;The conclusion is one line, and it is the whole article:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A description written by hand drifts. A description that is derived and checked does not.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The schema does exactly that, for a call and for an HTTP handle.&lt;/p&gt;

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

&lt;p&gt;The common arrangement is three files describing one interface. The schema declares the call. A separate document describes the HTTP surface - an &lt;a href="https://spec.openapis.org/oas/latest.html" rel="noopener noreferrer"&gt;OpenAPI&lt;/a&gt; file, hand-written or half-generated. Human-facing documentation is the third. All three are true on the day they are written, and after that, keeping them equal is manual work backed by an agreement.&lt;/p&gt;

&lt;p&gt;The agreement is the failure point. Nothing in the pipeline notices when the schema gains a field the HTTP document has never heard of.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we do instead: one handle, one source
&lt;/h2&gt;

&lt;p&gt;The HTTP handle is not a second interface. It is the same contract with its role marked: the method carries an &lt;a href="https://github.com/googleapis/googleapis/blob/master/google/api/http.proto" rel="noopener noreferrer"&gt;HTTP annotation&lt;/a&gt; in its declaration, and the HTTP specification is produced from the schema.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    .proto  (one file)
        messages · calls · required fields · bounds · refusal codes
                              │
                  ┌───────────┴───────────┐
                  ▼                       ▼
            gRPC call                HTTP handle
            typed client             role marked by an annotation
            typed server             spec derived from this same file

            ✗  hand-written HTTP spec, kept in step by a person
&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%2F9w4s0ogkngc4lhuk8zf1.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%2F9w4s0ogkngc4lhuk8zf1.png" alt="One .proto box in the centre listing messages, calls, required fields, bounds and refusal codes, with two arrows down to a gRPC call box and an HTTP handle box marked role annotation and derived spec, and a crossed-out box for a hand-written HTTP specification" width="800" height="539"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Stated as a prohibition, which is how I actually apply it: &lt;strong&gt;there are no two sources of truth for one handle.&lt;/strong&gt; If a fact about the interface is written in a second place, that place is either generated or wrong.&lt;/p&gt;

&lt;p&gt;Compatibility is not my judgement either. The contract build tool checks every change against the published schema, so a breaking change is visible before a line of implementation exists. That is what makes the gate at the end of the previous part enforceable: a requirement moves on to specification only with an accepted contract, and &lt;em&gt;accepted&lt;/em&gt; means the compatibility check is green.&lt;/p&gt;

&lt;h2&gt;
  
  
  Contract analytics is its own step
&lt;/h2&gt;

&lt;p&gt;Because the schema is the only place, arguing about it early is cheap and arguing about it late is not. So the field-by-field discussion - names, enums, what a zero value means, what a refusal returns - happens on the schema, before generation, where an edit costs a line.&lt;/p&gt;

&lt;p&gt;The part I insist on is the order. The set of cases the contract has to be able to express is written &lt;strong&gt;before a single field is named&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;  success           the ordinary answer, and what it contains
  refusal           which codes exist, and what the caller does with each
  conflict          two inputs that each exist but disagree
  empty response    a valid empty list, not an error
  page boundary     how the caller asks for more, and how it learns there is no more
&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%2Fk6aymk7v8751va11j42h.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%2Fk6aymk7v8751va11j42h.png" alt="Five case rows - success, refusal, conflict, empty response and page boundary - each in monospace with a one-line meaning, above a hairline and the note that the list is written before a single field is named" width="800" height="513"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Five rows. They take minutes to write and they settle most of the message shape: the empty case decides that an empty list is an answer and not an error, the page-boundary case decides that the response carries a cursor, and the refusal case decides that codes are an enum rather than free text in a message string.&lt;/p&gt;

&lt;h2&gt;
  
  
  The example
&lt;/h2&gt;

&lt;p&gt;Neutral package, neutral domain - this is the shape, not our schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight protobuf"&gt;&lt;code&gt;&lt;span class="na"&gt;syntax&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"proto3"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;package&lt;/span&gt; &lt;span class="nn"&gt;example&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;service&lt;/span&gt; &lt;span class="n"&gt;EntityService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// The HTTP role is declared on the method; the HTTP spec is derived from it.&lt;/span&gt;
  &lt;span class="k"&gt;rpc&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;ListEntitiesRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ListEntitiesResponse&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;message&lt;/span&gt; &lt;span class="nc"&gt;ListEntitiesRequest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="na"&gt;parent_id&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// required; a refusal, not an empty list, when it does not exist&lt;/span&gt;
  &lt;span class="kt"&gt;int32&lt;/span&gt;  &lt;span class="na"&gt;page_size&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// 1..200, clamped server-side; 0 means the default&lt;/span&gt;
  &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="na"&gt;page_token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// cursor from a previous response; empty asks for the first page&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;message&lt;/span&gt; &lt;span class="nc"&gt;ListEntitiesResponse&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;repeated&lt;/span&gt; &lt;span class="n"&gt;Entity&lt;/span&gt; &lt;span class="na"&gt;entities&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// an empty list is a valid answer&lt;/span&gt;
  &lt;span class="kt"&gt;string&lt;/span&gt;          &lt;span class="na"&gt;next_page_token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// empty only when this is the last page&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;RefusalCode&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;REFUSAL_CODE_UNSPECIFIED&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="na"&gt;REFUSAL_CODE_PARENT_NOT_FOUND&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="na"&gt;REFUSAL_CODE_PAGE_TOKEN_INVALID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="na"&gt;REFUSAL_CODE_SELECTOR_CONFLICT&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&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;Four of the five cases are visible there without reading any implementation, and the fifth - conflict - has a code reserved for it. Lists are cursor-paginated with a clamped page size; there are no unbounded lists here.&lt;/p&gt;

&lt;h2&gt;
  
  
  What stays data, and not contract
&lt;/h2&gt;

&lt;p&gt;Not everything that looks like an enum belongs in the schema. A category - a type, a status, the level at which something is attached - is a dictionary row and a string code, not a &lt;code&gt;.proto&lt;/code&gt; enum. A new category is then added as data: no contract change, no regeneration, no rollout. An enum is reserved for the things that change together with the code, where a new member means nothing until some branch handles it.&lt;/p&gt;

&lt;p&gt;The test I use is one question: &lt;em&gt;if a new member arrives, does any code have to change?&lt;/em&gt; No - dictionary. Yes - enum.&lt;/p&gt;

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

&lt;p&gt;Three costs, all real:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The schema becomes a bottleneck.&lt;/strong&gt; Until the contract is accepted there is no code and no work to hand out. That is deliberate, and it is still a bottleneck.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Field discussion looks like bureaucracy&lt;/strong&gt; - right up to the first breaking change that gets caught before implementation instead of after release.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The contract build tool is one more dependency&lt;/strong&gt; in the build, with its own version to keep in step with everything else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cost that is easiest to underrate is the one this ladder prices - the same edit, before and after generation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  edit in the schema, before generation     1 line
  ───────────────────────────────────────────────────────────────
  edit after generation                     contract
                                            generated code
                                            service code
                                            tests
                                            documentation
&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%2F1fxey87ydc8iwx76galp.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%2F1fxey87ydc8iwx76galp.png" alt="A two-step ladder - the upper step, an edit in the schema, priced at one line; the lower step, an edit after generation, priced across five artifacts: contract, generated code, service code, tests and documentation" width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;Three cases where I would not pay for any of it: an interface with exactly one consumer and that consumer inside the same binary; a one-off internal handle with a life expectancy of a week; and exploratory work where the shape of the answer is genuinely not known yet and the first few versions are meant to be thrown away.&lt;/p&gt;

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

&lt;p&gt;A schema that both the call and the HTTP specification are derived from is the same move as putting a fact into the text of a specification, one level up: the executor does not choose the shape of the answer, it receives it. What is derived cannot drift, and what cannot drift needs nobody to remember it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;From requirement to contract — Part 2.&lt;/strong&gt; Next: the tracker - the only place where the price of a requirement is actually visible, and what that price looks like once it is written down honestly.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you do this better&lt;/strong&gt;, tell me where you keep the HTTP description when the schema is the source. If &lt;strong&gt;you have been through this&lt;/strong&gt;, what did your second source of truth turn out to cost? If &lt;strong&gt;you see it differently&lt;/strong&gt;, say where a hand-written spec beats a derived one. How is it solved on your side, and what broke there?&lt;/p&gt;

</description>
      <category>api</category>
      <category>grpc</category>
      <category>architecture</category>
      <category>go</category>
    </item>
    <item>
      <title>Requirements Come Before the Contract</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Fri, 18 Sep 2026 08:28:35 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/requirements-come-before-the-contract-48do</link>
      <guid>https://dev.to/anton_brilliantov/requirements-come-before-the-contract-48do</guid>
      <description>&lt;p&gt;&lt;em&gt;Until the schema is declared, changing your mind costs one paragraph.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 Hi, I'm Anton — a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This series is about the order the work happens in: what has to exist before anyone writes a line of code, and what that order is actually worth. This part is about the first links of that chain — a wish, and the requirements it becomes. Running notes live on my GitHub: &lt;strong&gt;&lt;a href="https://github.com/brilliant-almazov" rel="noopener noreferrer"&gt;github.com/brilliant-almazov&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

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




&lt;h2&gt;
  
  
  The moment a requirement changes
&lt;/h2&gt;

&lt;p&gt;Someone comes back and says the thing should behave slightly differently. Same sentence, one clause changed. What that costs depends entirely on one question: has the contract been declared yet?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before it has&lt;/strong&gt;, the requirement is the only artefact that exists. Editing it is editing one paragraph. Nothing downstream has been generated from it, so nothing downstream has to agree with the edit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After it has&lt;/strong&gt;, the same clause moves five things that must stay consistent with each other:&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
  ──────                          ─────
  the requirement                 the contract
                                  generated code
                                  handwritten code
                                  tests
                                  documentation

  one artefact                    five artefacts, in step
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I am not going to attach hours or money to that. I do not have those numbers, and putting invented ones next to a real argument is how a real argument stops being trusted. The shape is the point: one edit against a coordinated edit.&lt;/p&gt;

&lt;p&gt;What this article is about is where that line sits, and why it is worth knowing before you cross it rather than after.&lt;/p&gt;

&lt;h2&gt;
  
  
  The case: a form nobody ever decided
&lt;/h2&gt;

&lt;p&gt;The clearest thing I have on this is not a dramatic outage. It is a boring one, which is why it generalises.&lt;/p&gt;

&lt;p&gt;Reading rows out of a database is a form. Open a result set, loop it, check the error, close it. Or read exactly one row. Or count. Or ask whether a row exists at all. Four shapes, all of them decided a hundred times a year by whoever is typing at the moment.&lt;/p&gt;

&lt;p&gt;That form was never stated as a requirement, so every author decided it locally. An audit of one service tree on &lt;strong&gt;2026-08-13&lt;/strong&gt; found &lt;strong&gt;44 copies of four 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;what it is for&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;/td&gt;
&lt;td&gt;read a set of rows&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;read exactly one row&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;counter&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;COUNT(*)&lt;/code&gt; into a number&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;existence probe&lt;/td&gt;
&lt;td&gt;does a row exist at all&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;/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;&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%2Fcxr83qh9a7197mot6k2x.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%2Fcxr83qh9a7197mot6k2x.png" alt="Four bars — many-row loop 18 copies, single row 6, counter 8, existence probe 12, 44 in total — with the closing criterion and the package-count figures below them" width="800" height="654"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two details make this a requirements story rather than a tidiness story.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First&lt;/strong&gt;, &lt;strong&gt;12 of the 44&lt;/strong&gt; differed from each other only in the text of the error wrapper. Not in behaviour, not in the type they returned in any meaningful way — in one sentence of prose attached to a failure. Twelve authors wrote the same loop and phrased the same failure twelve ways, because there was nothing to conform to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second&lt;/strong&gt;, the right form already existed in that tree. It had been written, it worked — and it lived inside a domain package, where nothing outside that domain could reach it. So the tree did not lack a solution. It lacked a statement that this was &lt;em&gt;the&lt;/em&gt; solution, made before the copies existed.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it took to close
&lt;/h3&gt;

&lt;p&gt;Moving the 44 onto one core took a set of &lt;strong&gt;40 iterations, one file per iteration&lt;/strong&gt;. That is the honest cost line: not a heroic weekend, a queue of small mechanical changes, each one boring on its own.&lt;/p&gt;

&lt;p&gt;The part worth stealing is not the refactor. It is the closing criterion that came out of it, which is checkable by a machine:&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;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="err"&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="err"&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;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="err"&gt;·&lt;/span&gt;   &lt;span class="n"&gt;QueryRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="err"&gt;─────────────────────────────────────────────────────────────────&lt;/span&gt;
  &lt;span class="n"&gt;appear&lt;/span&gt; &lt;span class="n"&gt;in&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;tree&lt;/span&gt; &lt;span class="n"&gt;in&lt;/span&gt; &lt;span class="n"&gt;exactly&lt;/span&gt; &lt;span class="n"&gt;one&lt;/span&gt; &lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="n"&gt;held&lt;/span&gt; &lt;span class="n"&gt;by&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;test&lt;/span&gt; &lt;span class="n"&gt;that&lt;/span&gt;
  &lt;span class="n"&gt;fails&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;moment&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;second&lt;/span&gt; &lt;span class="nb"&gt;copy&lt;/span&gt; &lt;span class="n"&gt;shows&lt;/span&gt; &lt;span class="n"&gt;up&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That sentence is what a requirement is supposed to look like. It is not "be consistent about database reads". It is a condition somebody can run, that answers yes or no, and that keeps answering after everyone involved has forgotten the conversation.&lt;/p&gt;

&lt;p&gt;The same service carries a second decision of exactly this kind. It grows by &lt;strong&gt;number of packages, not by size of file&lt;/strong&gt;: it went from &lt;strong&gt;13 packages at an average file of 50 lines to 252 packages at an average of 39&lt;/strong&gt;. Whether you like that shape is beside the point — it was decided once, in front, so no author had to re-decide it and no two authors could decide it differently.&lt;/p&gt;

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

&lt;p&gt;There is a well-developed craft here, and I am not pretending otherwise.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;requirements document from a template&lt;/strong&gt; — the &lt;a href="https://www.volere.org/templates/volere-requirements-specification-template/" rel="noopener noreferrer"&gt;Volere specification template&lt;/a&gt; is the classic one, and &lt;a href="https://www.iso.org/standard/72089.html" rel="noopener noreferrer"&gt;ISO/IEC/IEEE 29148&lt;/a&gt; is the standard that formalises the whole activity.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;dedicated analyst role&lt;/strong&gt;, with its own body of knowledge — that is what &lt;a href="https://www.iiba.org/career-resources/a-business-analysis-professionals-foundation-for-success/babok/" rel="noopener noreferrer"&gt;BABOK&lt;/a&gt; is for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Requirements as a wiki section&lt;/strong&gt;, living next to the design pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A call, then minutes&lt;/strong&gt; — decide by voice, write down what was decided.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of these work. They work best where there are more roles than there are here: an analyst who owns the document, reviewers who read it, a cadence that keeps it current. Where the same person holds the wish, the requirement and the acceptance, a heavy template mostly produces a document nobody re-reads. What I kept from that craft is the part that survives being small: &lt;strong&gt;every step leaves a file, and no step is a conversation.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How I do it — the chain
&lt;/h2&gt;

&lt;p&gt;Seven links. Each one is an artefact somebody can open, not a thing somebody remembers.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Wish&lt;/strong&gt; — one sentence, in the customer's own language. Not a design, not a ticket: the sentence they actually said.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Requirements&lt;/strong&gt; — functional and non-functional, written down as a list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contract&lt;/strong&gt; — a &lt;code&gt;.proto&lt;/code&gt; file. This is the first artefact that code is generated from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tracker tasks&lt;/strong&gt; — one per executable unit, each with an estimate and an acceptance criterion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spec&lt;/strong&gt; — a written specification for whoever does the work, precise enough to be executed without asking questions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Code and tests.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Acceptance&lt;/strong&gt; — a gate that passes or fails.&lt;/li&gt;
&lt;/ol&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%2Fcf5g1kpzik6gjcxsnzvb.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%2Fcf5g1kpzik6gjcxsnzvb.png" alt="Seven boxes in a row — wish, requirements, contract, tasks, spec, code plus tests, acceptance — each labelled with the artefact it produces, with a vermilion rule after the contract marked no code before this point" width="800" height="419"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The line that does the work: &lt;strong&gt;before link 3, no code exists.&lt;/strong&gt; Not "we try not to start early" — there is nothing to start from, because the thing code is generated from has not been written yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "non-functional" means here
&lt;/h2&gt;

&lt;p&gt;This is the half that gets skipped, so it is worth being concrete. In practice the non-functional list is short and always the same headings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;heading&lt;/th&gt;
&lt;th&gt;the question it answers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;load&lt;/td&gt;
&lt;td&gt;how much of this, and how often&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;latency&lt;/td&gt;
&lt;td&gt;how long the caller is willing to wait&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;read-to-write ratio&lt;/td&gt;
&lt;td&gt;which side of the system this feature actually stresses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;retention&lt;/td&gt;
&lt;td&gt;how long the data has to stay readable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tenant isolation&lt;/td&gt;
&lt;td&gt;what one account is allowed to see of another&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;compatibility&lt;/td&gt;
&lt;td&gt;what already depends on this and must keep working&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these is documentation for its own sake. Each one is a line that later decides something structural — which layer takes the work, whether there is a queue in the middle, whether reads and writes are separate at all. That consequence is a whole topic of its own and it is the last part of this series; here I only want to establish that the line has to be written down before the contract, because the contract is where those consequences start to be expensive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decomposition goes by architecture, not by taste
&lt;/h2&gt;

&lt;p&gt;Once a requirement is written, it has to land somewhere. Which layer it lands on is a property of the system, not a preference of whoever picked it up.&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%2Fzwstrc6brjisqveyei2r.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%2Fzwstrc6brjisqveyei2r.png" alt="Three requirements on the left — listing entities stays fast, an order is written in one transaction, recalculation never blocks the caller — each with a straight arrow to the layer that owns it: read layer, write layer, background daemon" width="800" height="508"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;listing entities stays fast as an account grows&lt;/em&gt; — a read requirement. It lands on the read layer: queries, indexes, pagination.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;an order is written in one transaction&lt;/em&gt; — a write requirement. It lands on the write layer: transaction boundary, conflict rules.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;recalculation never blocks the caller&lt;/em&gt; — a requirement about pace, not about either. It lands on a background worker: queue, retry policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The test of whether decomposition is really architectural is simple: hand the same sentence to two people and see whether it lands in the same place. If it can land in three places depending on who reads it, the sentence is not a requirement yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the order is worth keeping
&lt;/h2&gt;

&lt;p&gt;Because reversing is allowed, and it stays cheap up to the fifth link.&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%2Foef22c9qefo8jzpufaw5.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%2Foef22c9qefo8jzpufaw5.png" alt="Two panels — before the contract, one box reading one paragraph; after the contract, five boxes reading the contract, generated code, handwritten code, tests, documentation, each marked as one of five that must follow" width="800" height="541"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That gives one rule I can apply without thinking about it: &lt;strong&gt;a requirement that is still being argued about is reworked on the schema, not after generation.&lt;/strong&gt; If two of us disagree about what a field means, the argument is cheap right now and expensive in an hour.&lt;/p&gt;

&lt;p&gt;The second rule is about what a requirement is allowed to say. Every requirement needs a criterion that is either &lt;strong&gt;one command&lt;/strong&gt; or &lt;strong&gt;one number&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%2Fx8ucqafmw0xggp4e5rco.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%2Fx8ucqafmw0xggp4e5rco.png" alt="Two panels — works fast, marked as something that cannot be closed, against holds N requests per second below M latency, marked as something that closes the task" width="800" height="461"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;"Works fast" is not a criterion: nobody can say when it is met, so acceptance falls back to whoever is more insistent that day. "Holds N requests per second below M latency" is a criterion: one command runs it, or one number answers it, and both sides read the result the same way. I am deliberately leaving N and M as letters — those numbers belong to the system being built, not to an article about the order of the work.&lt;/p&gt;

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

&lt;p&gt;This is not free, and pretending it is would be the fastest way to make it look like advice.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Somebody has to hold the whole chain.&lt;/strong&gt; Seven links only work as one thread if one person can see all seven. That person is a bottleneck by construction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There is no analyst function here, and no analyst.&lt;/strong&gt; The owner of the task writes the requirements and runs the breakdown. That is fewer handoffs and also fewer second opinions — a wrong requirement stays wrong until it is caught by acceptance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There is no formal requirements document.&lt;/strong&gt; No template, no numbered specification, no sign-off page. Requirements live in the tracker task and in the spec. It works because the artefacts are small and current; it would not survive a reader who joins in a year and expects a document.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On a small change the chain does not pay for itself.&lt;/strong&gt; Seven links around a one-line fix is ceremony, and I skip it.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A research task&lt;/strong&gt;, where the point is that nobody knows yet what should be built. There are no requirements to gather because the input to them does not exist. That work is investigation first, requirements after, and forcing the order the other way produces confident sentences about a system nobody understands.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A one-off change&lt;/strong&gt; that will never be extended and has no consumers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A task with no readiness criterion.&lt;/strong&gt; If nothing can be checked at the end, acceptance is impossible and the last link of the chain is missing, which makes the other six decorative.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;An automated executor does not care what order it got the facts in. Hand it a task with the form undecided and it will decide the form, confidently, and keep going — and it will do that separately in each of the 44 places, because each place is a fresh conversation to it. It does not get slower when the input is incomplete; it gets more expensive, quietly, and the bill arrives later as inconsistency.&lt;/p&gt;

&lt;p&gt;Which is the actual argument for the order. The chain is not there to help whoever is executing. It is there for whoever pays for the rework.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;From requirement to contract — Part 1.&lt;/strong&gt; Next: there is exactly one contract, and it is a &lt;code&gt;.proto&lt;/code&gt; file — including for endpoints that are only ever spoken to over HTTP.&lt;/p&gt;

&lt;p&gt;If &lt;strong&gt;you do this better&lt;/strong&gt;, tell me where your requirements live and who keeps them current. If &lt;strong&gt;you have been through this&lt;/strong&gt;, what did your version of the 44 copies turn out to be? If &lt;strong&gt;you see it differently&lt;/strong&gt;, say where writing requirements first cost you more than it saved. How is it solved on your side, and what broke there?&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>api</category>
      <category>backend</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Dependency Injection in Go, After Years of Symfony</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Thu, 17 Sep 2026 12:58:07 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/dependency-injection-in-go-after-years-of-symfony-5fgf</link>
      <guid>https://dev.to/anton_brilliantov/dependency-injection-in-go-after-years-of-symfony-5fgf</guid>
      <description>&lt;p&gt;&lt;em&gt;The container I had, the container I don't have, and what I do instead.&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 one is about the single piece of Symfony I missed most on the way over, and about what I found when I stopped missing it and looked at what I'd built in its place. 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 do on one codebase, with the costs I actually pay. Not advice for yours.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the container actually did for me
&lt;/h2&gt;

&lt;p&gt;In Symfony, dependency injection is not a topic. It's infrastructure that has been finished for years, and the measure of how finished it is: I went whole quarters without opening a wiring file.&lt;/p&gt;

&lt;p&gt;Six things it does, and it's worth separating what each one buys from what each one hides, because they are not the same list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Autowiring.&lt;/strong&gt; A constructor asks for an interface, the container resolves it by type. Adding a dependency to a class is adding a parameter - no configuration line, no registration, nothing else edited.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderCreator&lt;/span&gt; 
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;OrderRepository&lt;/span&gt; &lt;span class="nv"&gt;$orders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;EventBus&lt;/span&gt; &lt;span class="nv"&gt;$events&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What it buys: a new collaborator costs one line, in the file where it's used. What it hides: the code that constructs this object doesn't exist as text anyone wrote. There is no call site to read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Services declared by configuration.&lt;/strong&gt; &lt;code&gt;config/services.yaml&lt;/code&gt; with &lt;code&gt;autowire: true&lt;/code&gt;, &lt;code&gt;autoconfigure: true&lt;/code&gt;, and a resource glob that registers an entire directory. Buys: a directory of new classes is available without touching a wiring file at all. Hides: the population of the container is data, and the answer to "what objects exist" is a query, not a file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decoration.&lt;/strong&gt; &lt;code&gt;#[AsDecorator]&lt;/code&gt;, or &lt;code&gt;decorates:&lt;/code&gt; plus &lt;code&gt;.inner&lt;/code&gt; in configuration, wraps an existing service so that every existing caller gets the wrapper instead - callers untouched, order controlled by priority. Buys: cross-cutting behaviour added without editing a single consumer. Hides: the class you're reading may not be the object that runs. &lt;code&gt;OrderCreator&lt;/code&gt; in the file, three decorators in the container, and nothing at the call site says so.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lazy services.&lt;/strong&gt; &lt;code&gt;lazy: true&lt;/code&gt; gives you a proxy; the real object is constructed on first method call. Buys: an expensive dependency that most requests never touch stops costing those requests anything. Hides: construction time moves to a place you're not looking at when you profile.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-environment graphs and compiler passes.&lt;/strong&gt; A different set of definitions per environment, plus compiler passes that rewrite the graph as it's built. Buys: the test graph and the production graph are the same design with substituted parts. Hides: two graphs that must stay true to each other, and one of them is assembled by code that runs at build time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A compiled container.&lt;/strong&gt; The whole graph is resolved ahead of the request and written out as generated PHP. This is the part people forget when they call container DI "dynamic": a missing dependency, an ambiguous type hint, a service that can't be constructed - all of that fails when the container compiles, not on the request that needed it. And &lt;code&gt;debug:container&lt;/code&gt; / &lt;code&gt;debug:autowiring&lt;/code&gt; answer questions about the graph from the command line.&lt;/p&gt;

&lt;p&gt;Put the buys together and you get the single sentence that makes the container worth it: &lt;strong&gt;adding a dependency costs one constructor parameter, and the graph is verified before anything serves traffic.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Put the hides together and you get the price: &lt;strong&gt;the graph is not in the code you read.&lt;/strong&gt; To find out what an object actually receives at runtime, you run a tool. That's not a complaint - in a monolith with hundreds of services in one process, hand-writing that assembly would be worse in every direction. The container earns its keep exactly there, and the size of the graph is why.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  in Symfony           what it buys              what it hides
  ---------------------------------------------------------------------
  autowire: true       one constructor param     nobody writes the
                                                 constructing call
  services.yaml        a directory by one glob   what exists is data
  #[AsDecorator]       no consumer edited        what runs is not
                                                 what you read
  lazy: true           built on first call       construction moves
  services_test.yaml   parts swapped per env     two graphs to keep true
  debug:container      the graph in one command  it is not in a file
&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%2F1i6qagq0y16fm69agku8.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%2F1i6qagq0y16fm69agku8.png" alt="Six rows, each a Symfony container feature named in code - autowire, services.yaml, AsDecorator, lazy, per-environment definitions, debug:container - with two columns beside each row headed what it buys and what it hides" width="800" height="560"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Go doesn't have this out of the box
&lt;/h2&gt;

&lt;p&gt;Go's answer is that a dependency is a parameter, and parameters are passed at call sites, and call sites are text in a file that a person wrote.&lt;/p&gt;

&lt;p&gt;There are no annotations to read. There is no convention that the runtime inspects your types and builds things for you. The compiler assembles nothing on your behalf; &lt;code&gt;go build&lt;/code&gt; does not have a hook where a framework gets to construct your object graph. This is the same design decision that gives Go no inheritance, no implicit constructors, no exceptions - the reading of a line should not depend on machinery declared elsewhere.&lt;/p&gt;

&lt;p&gt;I've stopped treating that as an omission. But it has a price, and the price is not "you type more".&lt;/p&gt;

&lt;p&gt;The price is that &lt;strong&gt;everything the container did for you is now something you either write or do without&lt;/strong&gt;. Lazy construction, decoration, per-environment substitution, lifecycle ordering - the language has no opinion about any of it, so each one arrives as a small hand-rolled mechanism, written under deadline, by whoever hit the need first.&lt;/p&gt;

&lt;p&gt;I have the receipt for this. When I audited my own Go service for code that already existed in the shared platform library, one of the findings was a scheduler initialised lazily on a &lt;code&gt;sync.Once&lt;/code&gt; with captured variables, instead of the platform's initialisation-state store. That's &lt;code&gt;lazy: true&lt;/code&gt;, rebuilt by hand, in one service, by me. It works. It's also invisible to anything that could tell me it exists - no declaration, no registry, no way to ask the process what is lazy and what isn't.&lt;/p&gt;

&lt;p&gt;The same audit found a service running two different loggers in two different packages. Nothing enforces that a dependency is &lt;em&gt;the&lt;/em&gt; dependency when the dependency is just an argument.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four options, and what each one charges
&lt;/h2&gt;

&lt;p&gt;Everybody in Go knows all four. They're not ranked; they're priced.&lt;/p&gt;

&lt;h3&gt;
  
  
  Manual assembly at the composition root
&lt;/h3&gt;

&lt;p&gt;The direct route: a function that constructs everything in order and hands the result to the daemon.&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;func&lt;/span&gt; &lt;span class="n"&gt;buildServer&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;deps&lt;/span&gt; &lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Deps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Server&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;orders&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;NewOrders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deps&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;entities&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;NewEntities&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deps&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;orderWrites&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;manager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewOrders&lt;/span&gt;&lt;span class="p"&gt;(&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;deps&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Clock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deps&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="c"&gt;// ...and so on, in dependency order, for every domain&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;Charges:&lt;/strong&gt; one long function that every feature touches, so every feature branch conflicts in it; the order is maintained by hand; and - this is the one that actually bites - nothing tells you when a node is missing. More on that below, because it's the whole reason this article isn't just "write it by hand, it's fine".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it fits:&lt;/strong&gt; a graph you can hold in your head, or a graph that repeats the same shape enough that the repetition can be factored out.&lt;/p&gt;

&lt;h3&gt;
  
  
  Code generation of the graph
&lt;/h3&gt;

&lt;p&gt;The wire-shaped approach: declare provider functions, declare what you want, and a generator writes the assembly function you'd have written yourself, at build time, in real Go you can read and step through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Charges:&lt;/strong&gt; a generator in the build path, and everyone who touches the project needs it; errors phrased in the generator's vocabulary rather than the language's; and when something is wrong you debug generated code, which is a file nobody wrote and everybody is slightly afraid to edit. It also puts a &lt;code&gt;DO NOT EDIT&lt;/code&gt; header in the repository - and in my experience a &lt;code&gt;DO NOT EDIT&lt;/code&gt; header is a rule, not a mechanism, unless a drift check enforces it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it fits:&lt;/strong&gt; a large, fairly stable graph where the assembly function has become genuinely unpleasant, and the team already runs generators for other reasons.&lt;/p&gt;

&lt;h3&gt;
  
  
  A runtime container with reflection
&lt;/h3&gt;

&lt;p&gt;The dig/fx-shaped approach, and the closest thing to what I had in Symfony: register constructors, ask for a type, get the graph built by reflection. &lt;code&gt;fx&lt;/code&gt; adds lifecycle on top - ordered start and stop hooks, which is a real problem you'd otherwise solve by hand in every service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Charges:&lt;/strong&gt; resolution moves to runtime. A missing provider is a start-up error phrased in terms of &lt;code&gt;reflect.Type&lt;/code&gt;, not a compile error on a line. The graph isn't in the code - you're back to "ask the tool", except the tool is now inside your process. Debugging goes through the container, which means the layer you least want to learn is the layer you must learn first when something is wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it fits:&lt;/strong&gt; many components with the same shape and a real lifecycle problem - things that must start in order and stop in reverse. If you're writing that ordering by hand in every binary, a container is not an indulgence.&lt;/p&gt;

&lt;h3&gt;
  
  
  Functional options and plain constructors
&lt;/h3&gt;

&lt;p&gt;Not dependency injection at all, but it covers the part of the problem people usually mean: an object with several optional collaborators.&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;func&lt;/span&gt; &lt;span class="n"&gt;NewClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&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;opts&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="n"&gt;Option&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Charges:&lt;/strong&gt; constructors grow, and the option list quietly becomes the object's configuration surface - which is fine until two options are mutually exclusive and nothing says so. Nothing resolves anything: you still write every call site.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it fits:&lt;/strong&gt; a library boundary, or any constructor where most callers want defaults and a few want one thing changed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    option               gives                      charges
    -------------------------------------------------------------------
 &amp;gt;  manual assembly      the graph is a file,       a missing node is
                         in order                   a green build
    generated graph      real Go the compiler       a generator in the
                         checks                     build; code nobody wrote
    runtime container    resolution and             a start-up message
                         lifecycle ordering         about a reflect.Type
    functional options   defaults with one          constructors grow into
                         thing changed              a config surface
&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%2Fsiw3q4aqxmywz93ujaap.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%2Fsiw3q4aqxmywz93ujaap.png" alt="Four columns headed manual assembly, code generation, runtime container, functional options; each column lists what it gives and what it charges, with manual assembly marked as the one picked" width="800" height="367"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two of these - manual assembly and options - are the language's own grain. The other two rebuild a container out of tooling: one at build time, one at run time. That's the actual choice, and it's not between "explicit" and "convenient". It's about &lt;strong&gt;when you want to find out that the graph is wrong&lt;/strong&gt;: at compile, at start-up, or never.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I assemble a service
&lt;/h2&gt;

&lt;p&gt;My answer is a split, and I only noticed it was a split when I sat down to write this. &lt;strong&gt;Infrastructure is declared. Domain objects are written.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The declared half
&lt;/h3&gt;

&lt;p&gt;Every service carries one manifest. It declares the daemons and the resources, and that declaration is the only place they're declared:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;service&amp;gt;&lt;/span&gt;
  &lt;span class="na"&gt;daemons&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;server&lt;/span&gt;
      &lt;span class="na"&gt;handlers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;grpc&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;worker&lt;/span&gt;
      &lt;span class="na"&gt;handlers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;scheduler&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;infra&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
  &lt;span class="na"&gt;tx&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
      &lt;span class="na"&gt;pool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
  &lt;span class="na"&gt;grpc&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;
      &lt;span class="na"&gt;daemons&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;messaging&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From that declaration the platform builds the pool, the transaction registry, the gRPC server, the scheduler and the broker connection - and the name of every environment variable follows from it by one convention, &lt;code&gt;&amp;lt;TYPE&amp;gt;_&amp;lt;NAME&amp;gt;_&amp;lt;FIELD&amp;gt;&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;Declaration&lt;/th&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;postgres: main&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POSTGRES_MAIN_DSN&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;grpc: api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GRPC_API_PORT&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;messaging: main&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MESSAGING_MAIN_RABBITMQ_URL&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is a container. It's a small one, it only wires infrastructure, and I did not think of it as dependency injection until I compared it side by side with &lt;code&gt;services.yaml&lt;/code&gt;. Same trade exactly: a declaration instead of a call site, convenience bought with implicitness.&lt;/p&gt;

&lt;p&gt;What makes me comfortable with it is the one thing Symfony's container also does and most hand-rolled configuration doesn't - &lt;strong&gt;the declaration is verified&lt;/strong&gt;. The service's variable catalogue is a generated artefact: 59 variables, 45 of them declared by the platform and 14 by the service's own configuration, each service-side one recorded with the file and line it's defined in. Drift between the declaration and the code fails CI. If the graph isn't in the code I read, then something had better be checking it, and here something is.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  declared - the manifest             written - the assembly
  ---------------------------------   -------------------------------
  postgres: main                      executor    a pool or a tx
    -&amp;gt; POSTGRES_MAIN_DSN              repository  reads only
  grpc: api                           manager     writes, find-or-create
    -&amp;gt; GRPC_API_PORT                  method      one of 20 generic RPCs
  messaging: main                     Calls       the domain's handlers
    -&amp;gt; MESSAGING_MAIN_RABBITMQ_URL    daemon      server or worker
  ---------------------------------
  59 variables catalogued, 45 of
  them from the platform; drift
  against the code fails CI
&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%2Fr5ylw7p9cduxeyd1ejl9.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%2Fr5ylw7p9cduxeyd1ejl9.png" alt="Two panels: on the left the declared half, showing manifest resources resolving into environment variable names and noting the catalogue check; on the right the written half, showing the assembly order from executor through repository, manager, methods and calls to the daemon" width="799" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The written half
&lt;/h3&gt;

&lt;p&gt;Domain objects are assembled by hand, in layers, in dependency order, in the daemon's assembly package for the domain:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the executor - a pool or a transaction, and nothing downstream knows which;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;repositories&lt;/strong&gt; - read only;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;managers&lt;/strong&gt; - write only, including find-or-create;&lt;/li&gt;
&lt;li&gt;the RPC method bodies, which live in generic methods rather than in the domain;&lt;/li&gt;
&lt;li&gt;the domain's &lt;code&gt;Calls&lt;/code&gt;, where the handlers are named;&lt;/li&gt;
&lt;li&gt;the daemon, which mounts them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two rules do most of the work here. &lt;strong&gt;Dependencies are interfaces&lt;/strong&gt;, one interface per file in a cross-cutting port package - so an assembly line reads as "this concrete thing satisfies that named role", and a test substitutes at the same seam. And &lt;strong&gt;the transaction is not an argument&lt;/strong&gt;: &lt;code&gt;tx pgx.Tx&lt;/code&gt; in a signature is forbidden. Transactionality is a decorator applied from the outside, on mutations only, with the executor taken from the context. The domain code does not know it is running inside a transaction.&lt;/p&gt;

&lt;p&gt;That decorator is worth noticing, because it's Symfony's decoration idea kept and everything around it dropped. It's the same benefit - a cross-cutting concern that consumers don't implement - and the difference is that it's applied at one visible place per domain rather than through a configuration key. I took the pattern and refused the indirection.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this stays readable longer than you'd expect
&lt;/h3&gt;

&lt;p&gt;Manual wiring supposedly collapses under its own weight. Mine hasn't yet, and the reason is not discipline. It's that &lt;strong&gt;the repeated subtree is a generic, not a copy&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Packages are laid out by component type first with the domain nested inside - &lt;code&gt;internal/repository/&amp;lt;domain&amp;gt;&lt;/code&gt;, &lt;code&gt;internal/manager/&amp;lt;domain&amp;gt;&lt;/code&gt;, &lt;code&gt;internal/grpc/handler/&amp;lt;domain&amp;gt;&lt;/code&gt;, &lt;code&gt;internal/daemon/server/&amp;lt;domain&amp;gt;&lt;/code&gt;. Between two domains, the assembly differs in the type and in the spec, and in nothing else. So the assembly itself became a core with type parameters - &lt;code&gt;Builder[Repo, Mgr]&lt;/code&gt;, &lt;code&gt;Domain[Repo, Mgr]&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; - and a domain's wiring is four small files with the same skeleton.&lt;/p&gt;

&lt;p&gt;The numbers say the same thing from outside: the service is 2733 non-generated Go files across 252 packages, averaging 39 lines; 44 files exceed 100 lines and only two of those aren't tests. A composition root would be enormous in that tree if each domain's subtree were bespoke. It isn't, so it isn't.&lt;/p&gt;

&lt;p&gt;This is the honest form of "manual wiring is fine": &lt;strong&gt;manual wiring is fine when the wiring is regular.&lt;/strong&gt; A container's biggest win is over irregularity - N different shapes, each needing its own construction. Remove the irregularity and you remove most of the win.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  executor      port.Executor       a pool or a transaction
     |
  repository    Reader[In, Out]     read side only
     |
  manager       Manager             write side only, find-or-create
     |
  method        20 generic RPCs     the RPC body, not in the domain
     |
  calls         Domain[Repo, Mgr]   the domain's handlers, named
     |
  daemon        server / worker     one image, two assembly points

  transactionality: a decorator, applied on mutations only.
  tx pgx.Tx never appears in a signature - the executor comes
  from the context, and the domain does not know it is in one.
&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%2Fk0wgbumxcotgx3kk4tvd.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%2Fk0wgbumxcotgx3kk4tvd.png" alt="Six stacked bands showing the assembly order from executor at the top through repository, manager, generic methods, calls and daemon, with the transaction decorator marked as applying to mutations only" width="800" height="654"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where manual assembly breaks
&lt;/h2&gt;

&lt;p&gt;Now the part I'd want to read if someone else wrote this.&lt;/p&gt;

&lt;h3&gt;
  
  
  It fails silently, and that's a category difference
&lt;/h3&gt;

&lt;p&gt;The same audit that found the hand-rolled lazy scheduler found this: &lt;strong&gt;a cache-invalidation consumer, fully written, registered in no daemon.&lt;/strong&gt; Its subscription was never wired anywhere. In production, cache invalidation did not run at all.&lt;/p&gt;

&lt;p&gt;Look at what didn't catch it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;the compiler&lt;/strong&gt; - the package compiles perfectly; being uncalled is not an error in any language;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the unit tests&lt;/strong&gt; - green, because they test the consumer's logic, and the logic was correct;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;review&lt;/strong&gt; - there is nothing to see. The absence is in a different file from the thing that's absent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the exact failure mode a compiled container removes. In Symfony, a service that nothing uses is at worst a warning you can query for, and a service whose dependency is missing fails the container build. In hand-wired Go, "I forgot to add the line" produces a green build, green tests, and a feature that silently doesn't exist.&lt;/p&gt;

&lt;p&gt;That asymmetry is the strongest argument against my own approach, and I'd rather state it than argue with 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/consumer/&amp;lt;domain&amp;gt;   written, correct, registered in no daemon
        |
        +-- the compiler     [passed]  the package compiles
        +-- its unit tests   [passed]  green - the logic is right
        +-- review           [passed]  the absence is in another file
        |
        v
  in production, cache invalidation did not run at all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi5yhm8df06q06jy3lg1s.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%2Fi5yhm8df06q06jy3lg1s.png" alt="One case laid out: a consumer that compiles, passes its unit tests and survives review, registered in no daemon, with the production outcome marked broken and the note that not-called is not an error in any language" width="800" height="367"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Two composition roots drift
&lt;/h3&gt;

&lt;p&gt;One image builds two binaries, &lt;code&gt;server&lt;/code&gt; and &lt;code&gt;worker&lt;/code&gt;, selected by a build argument. That's two assembly points. Anything both need is written twice, and the two copies diverge in the direction nobody is looking - the same class of drift as the two loggers the audit found in one service.&lt;/p&gt;

&lt;p&gt;A container has one graph and a per-binary selection of it. Hand wiring has two graphs that happen to agree today.&lt;/p&gt;

&lt;h3&gt;
  
  
  The tests are a third graph
&lt;/h3&gt;

&lt;p&gt;Every test that needs a real graph builds one. My answer is rigs and stands in a shared test-support package with no assertions in them - which works, and which is a third assembly point that drifts from the other two exactly the same way. Nothing about hand wiring makes this go away; it makes it your problem to name.&lt;/p&gt;

&lt;h3&gt;
  
  
  The threshold, said honestly
&lt;/h3&gt;

&lt;p&gt;I don't have a node count where I'd switch. What I have is three signals, and any one of them is the moment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;a subtree that isn't regular&lt;/strong&gt; - a domain whose construction genuinely differs, appearing more than twice;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;lifecycle ordering by hand&lt;/strong&gt; - things that must start in order and stop in reverse, written per binary, which is a bug factory and precisely what &lt;code&gt;fx&lt;/code&gt; exists for;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;a wiring omission reaching production twice&lt;/strong&gt; - because the first time is a mistake and the second time is a property of the method.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I'd do before switching is climb the ladder I use for everything else: &lt;strong&gt;reminder → rule → check&lt;/strong&gt;. A reminder lives for one conversation. A rule lives while people read it. Only a check works - a structure test that walks the tree and fails the build when a consumer type exists that no daemon's assembly references. That test would have caught the unregistered consumer, it is exactly the compiled container's guarantee reproduced at a fraction of the machinery, and I want to be precise: &lt;strong&gt;I haven't written it.&lt;/strong&gt; It's a plan. The rule that says "always register your consumer" is currently living in my head, which is the level of the ladder that demonstrably didn't work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  break point              what absorbs it today            status
  -------------------------------------------------------------------
  a wiring omission        a structure test over the tree   NOT WRITTEN
  two composition roots    nothing - server and worker      DRIFTS
                           are assembled twice
  a third graph in tests   rigs and stands, no assertions   held
  an irregular subtree     one generic assembly per domain  held
&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%2Fzevajcr98joixn69496a.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%2Fzevajcr98joixn69496a.png" alt="Four break points of hand wiring as rows - silent omission, two composition roots, a third graph in tests, an irregular subtree - each with what absorbs it today and the one marked as still unwritten" width="799" height="489"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;So: does Go need a proper container, or is the convenience necessarily bought with implicitness?&lt;/p&gt;

&lt;p&gt;I notice I haven't answered it consistently in my own code. I accepted declaration-driven wiring for infrastructure - the manifest is a container by any reasonable definition - and refused it for domain objects. That line isn't "explicit always". It's closer to: &lt;strong&gt;declare the plumbing that is identical in every service; write the graph that is the design.&lt;/strong&gt; The manifest describes something that should be the same everywhere and boring; the domain assembly describes decisions specific to this service, and I want those in a file, in order, where a reader trips over them.&lt;/p&gt;

&lt;p&gt;The other half of my comfort is the check. The declared half of my wiring is safe not because it's small but because drift between the declaration and the reality fails a build. That suggests the thing I actually want from a Go container isn't reflection and it isn't code generation - it's &lt;strong&gt;verification&lt;/strong&gt;: something that knows what the graph is supposed to be and fails when the code disagrees. Symfony gets that by compiling the container. Wire gets it by generating real code the compiler then checks. A runtime container mostly doesn't get it at all, which is why it's the option I'm least drawn to despite it being the one closest to what I had.&lt;/p&gt;

&lt;p&gt;Where that leaves me, for now: hand wiring plus a regular layout plus generics for the repeating subtree, and one unwritten structure test that would close the gap the container closed for free. I'm staying here because the graph being readable has been worth more to me than the graph being short - and because the one production bug this approach cost me was cheap to find once I went looking, which is not a guarantee, just a result.&lt;/p&gt;

&lt;p&gt;Ask me again when the third binary shows up.&lt;/p&gt;




&lt;p&gt;That's my experience and my price for it. If you've built the check I described, if you run a container in Go and it's been fine, or if you look at this differently - I'd like to hear how it's solved on your side, and what broke when you tried.&lt;/p&gt;

</description>
      <category>go</category>
      <category>php</category>
      <category>symfony</category>
      <category>architecture</category>
    </item>
    <item>
      <title>One Read Core, and the Code That Proves It</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Tue, 15 Sep 2026 20:37:59 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/one-read-core-and-the-code-that-proves-it-39ia</link>
      <guid>https://dev.to/anton_brilliantov/one-read-core-and-the-code-that-proves-it-39ia</guid>
      <description>&lt;p&gt;&lt;em&gt;A generic core is easy to describe and hard to show. So here it is, with the parts I would not put in a talk.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;👋 I'm Anton - a software engineer working mostly in PHP/Symfony and Go, carving a live PHP monolith into Go services. The &lt;a href="https://dev.to/anton_brilliantov/life-before-generics-38i0-temp-slug-3580709"&gt;previous piece&lt;/a&gt; was about why my code ended up on type parameters at all. This one is the code itself: the core, one instantiation of it, and a consumer that uses it badly. 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;Names below are neutral - &lt;code&gt;entity&lt;/code&gt;, &lt;code&gt;order&lt;/code&gt;, &lt;code&gt;Search&lt;/code&gt;, &lt;code&gt;Create&lt;/code&gt;. Shapes, counts and defects are what is in the tree.&lt;/p&gt;




&lt;h2&gt;
  
  
  The core: one shape for everything that has history
&lt;/h2&gt;

&lt;p&gt;The service is bitemporal. Not "has an audit table" - bitemporal in the ordinary sense of the word, as a property of every domain table:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No destructive &lt;code&gt;UPDATE&lt;/code&gt; exists in a domain table.&lt;/strong&gt; A change is a new version row. The single permitted &lt;code&gt;UPDATE&lt;/code&gt; is closing an interval, in the same transaction that opens the next one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No physical &lt;code&gt;DELETE&lt;/code&gt; exists.&lt;/strong&gt; "Deleted" is a new version carrying a new status; "the link is gone" is a closed interval.&lt;/li&gt;
&lt;li&gt;There are no &lt;code&gt;updated_at&lt;/code&gt; / &lt;code&gt;updated_by&lt;/code&gt; columns anywhere, because there is no edit in place. The header row carries &lt;code&gt;created_by&lt;/code&gt;, the version row carries &lt;code&gt;recorded_by&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Every historicised row carries &lt;strong&gt;two axes&lt;/strong&gt;: valid time (&lt;code&gt;valid_from&lt;/code&gt; / &lt;code&gt;valid_to&lt;/code&gt; - when the fact is true in the world) and system time (&lt;code&gt;recorded_at&lt;/code&gt; / &lt;code&gt;superseded_at&lt;/code&gt; - when we wrote it down and when we corrected it). Mixing them is forbidden: fixing a typo must not rewrite the past.&lt;/li&gt;
&lt;li&gt;Current state is a &lt;strong&gt;slice&lt;/strong&gt;, not a table: &lt;code&gt;valid_to IS NULL AND superseded_at IS NULL&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is six rules, and every one of them is a place where a hand-written per-domain implementation goes wrong differently. Written per domain, the fourth entity's &lt;code&gt;Close&lt;/code&gt; forgets &lt;code&gt;superseded_at&lt;/code&gt; and nobody notices until someone asks what a value was in March.&lt;/p&gt;

&lt;p&gt;So there is one core, and one only:&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;Revisioned&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;T&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;spec&lt;/span&gt;   &lt;span class="n"&gt;Spec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Version&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;reader&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;Query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;writer&lt;/span&gt; &lt;span class="n"&gt;Appender&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;T&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;Revisioned&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;T&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;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;key&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;T&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="n"&gt;Revisioned&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="n"&gt;AsOf&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;key&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;at&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;Time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;T&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="n"&gt;Revisioned&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="n"&gt;KnownAsOf&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;key&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;known&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;Time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;T&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="n"&gt;Revisioned&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="n"&gt;Append&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;key&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;from&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;Time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;T&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="n"&gt;Revisioned&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="n"&gt;Correct&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;id&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;fixed&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;T&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="n"&gt;Revisioned&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&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;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;key&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to&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;Time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six methods, one type parameter, and the parameter is narrower than it looks. &lt;code&gt;T&lt;/code&gt; is &lt;strong&gt;the payload&lt;/strong&gt;, not the row. The header group (identity: the Snowflake id, the owner, the code) and the version group (&lt;code&gt;valid_from&lt;/code&gt;, &lt;code&gt;valid_to&lt;/code&gt;, &lt;code&gt;recorded_at&lt;/code&gt;, &lt;code&gt;superseded_at&lt;/code&gt;, &lt;code&gt;recorded_by&lt;/code&gt;) are identical in every domain, so they are not parameters - they are fixed positions in &lt;code&gt;Spec&lt;/code&gt;, which is the second half of the design:&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;Spec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;V&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;Table&lt;/span&gt;   &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Header&lt;/span&gt;  &lt;span class="n"&gt;Columns&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;Payload&lt;/span&gt; &lt;span class="n"&gt;Columns&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;Version&lt;/span&gt; &lt;span class="n"&gt;Columns&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;V&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;&lt;code&gt;Spec[H, P, V]&lt;/code&gt; is what a domain hands over as data, and &lt;code&gt;Revisioned[T]&lt;/code&gt; is what reads and writes it. The split is the whole trick: three column groups, because the three groups behave differently. Identity is written once and never again. Payload is what a new version replaces. Version columns are the only ones the core itself sets, and no domain code is allowed to touch them - if a domain could set &lt;code&gt;recorded_at&lt;/code&gt;, the system axis would be a suggestion.&lt;/p&gt;

&lt;p&gt;Here is &lt;code&gt;Append&lt;/code&gt;, which is the method that carries the invariant:&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;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;Revisioned&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="n"&gt;Append&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;key&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;from&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;Time&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;T&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;var&lt;/span&gt; &lt;span class="n"&gt;zero&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;

    &lt;span class="n"&gt;closed&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;writer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CloseOpen&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;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;from&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;zero&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;"%w: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrCloseInterval&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;if&lt;/span&gt; &lt;span class="n"&gt;closed&lt;/span&gt; &lt;span class="o"&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;key&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;First&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;zero&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrAborted&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;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Insert&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;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;from&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;&lt;code&gt;CloseOpen&lt;/code&gt; is the one legal &lt;code&gt;UPDATE&lt;/code&gt;: it stamps &lt;code&gt;valid_to&lt;/code&gt; on the currently open interval. &lt;code&gt;closed == 0&lt;/code&gt; on a key that is not brand new means somebody else closed it between my read and my write, and the answer to that is &lt;code&gt;ABORTED&lt;/code&gt; - not a retry, not an upsert. There is no &lt;code&gt;ON CONFLICT&lt;/code&gt; anywhere in this service; writes are select-then-insert, and a taken key is &lt;code&gt;ALREADY_EXISTS&lt;/code&gt; from the write layer rather than a constraint violation from Postgres.&lt;/p&gt;

&lt;p&gt;The reason it is safe to concentrate this much in one function: the schema has &lt;strong&gt;no &lt;code&gt;FK&lt;/code&gt;, no &lt;code&gt;UNIQUE&lt;/code&gt;, no &lt;code&gt;CHECK&lt;/code&gt;, no &lt;code&gt;EXCLUDE&lt;/code&gt;&lt;/strong&gt;. Only a primary key on the Snowflake id and ordinary indexes, some partial. Integrity lives in the write layer, in the domain invariants, and in tests. Which means the write layer had better be one piece of code and not forty - and this is exactly the seam where a type parameter earns its place, because between &lt;code&gt;entity&lt;/code&gt; and &lt;code&gt;order&lt;/code&gt; the only thing that differs is &lt;code&gt;T&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ONE ROW, TWO TIME AXES

  valid time — when the fact is true in the world

  ┌───────────────────────────────┐ ┌───────────────────────────────┐
  │ version 1                     │ │ version 2                     │
  │ valid_from   2026-01-01       │ │ valid_from   2026-03-01       │
  │ valid_to     2026-03-01       │ │ valid_to     NULL             │
  └───────────────────────────────┘ └───────────────────────────────┘

  system time — when we wrote it down, and when we corrected it

  ┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
  │ recorded_at        │ │ superseded_at      │ │ recorded_at        │
  │ 2026-03-01         │ │ 2026-04-02         │ │ 2026-04-02         │
  │ we wrote it down   │ │ we corrected it    │ │ the correction     │
  └────────────────────┘ └────────────────────┘ └────────────────────┘

  the correction adds a row on the SYSTEM axis;
  it does not move the VALID axis

  current state is a slice, not a table
      valid_to IS NULL AND superseded_at IS NULL
&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%2Fbmxp40ahxukok1y4l8r5.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%2Fbmxp40ahxukok1y4l8r5.png" alt="Two horizontal time axes for one row - valid time with valid-from and valid-to, system time with recorded-at and superseded-at - with a correction shown as a new row on the system axis that does not move the valid axis, and the current-slice predicate written underneath as valid-to is null and superseded-at is null" width="800" height="508"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the parameter decides, and what it doesn't
&lt;/h2&gt;

&lt;p&gt;The question worth asking about any generic core is how much the parameter actually absorbed. For &lt;code&gt;Revisioned[T]&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The parameter decides:&lt;/strong&gt; the scan target of a version row, the argument type of &lt;code&gt;Append&lt;/code&gt; and &lt;code&gt;Correct&lt;/code&gt;, the return type of all three reads, and - through &lt;code&gt;Columns[P]&lt;/code&gt; - which columns the payload group has.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The spec decides, as data:&lt;/strong&gt; table name, column names, the aliases, which column carries the reference to the header, which carries the label.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The domain still writes by hand:&lt;/strong&gt; its filter type, its payload struct, its sentinel errors, and the rules that are genuinely its own. In the skeleton I designed for generation, that split is explicit per layer - the repository layer has &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; and two method files as fully mechanical, and &lt;code&gt;spec.go&lt;/code&gt; plus three method files (&lt;code&gt;header.go&lt;/code&gt;, &lt;code&gt;code_taken.go&lt;/code&gt;, &lt;code&gt;slug_taken.go&lt;/code&gt;) as hand-written bodies. Four hand-written files per domain, and the fourth is the interesting one: &lt;code&gt;code_taken.go&lt;/code&gt; is where "this key is already used" is defined, and no core can guess that.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ┌──────────────────────┐ ┌──────────────────────┐ ┌────────────────────┐
  │ THE TYPE PARAMETER   │ │ THE SPEC DECIDES,    │ │ THE DOMAIN STILL   │
  │ DECIDES              │ │ AS DATA              │ │ WRITES BY HAND     │
  │                      │ │                      │ │                    │
  │ scan target of a     │ │ table name           │ │ the filter type    │
  │ version row          │ │ column names and     │ │ the payload struct │
  │ argument of Append   │ │ aliases              │ │ the sentinel       │
  │ and Correct          │ │ the reference column │ │ errors             │
  │ return type of all   │ │ the label column     │ │ what counts as a   │
  │ three reads          │ │                      │ │ taken key          │
  │ which columns the    │ │                      │ │                    │
  │ payload has          │ │                      │ │                    │
  │                      │ │                      │ │                    │
  │ Revisioned[T]        │ │ Spec[H, P, V]        │ │ 4 files per domain │
  └──────────────────────┘ └──────────────────────┘ └────────────────────┘

  the fourth hand-written file is the interesting one
      code_taken.go — no core can guess what makes a key already used
&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%2Fejcza5h52mh3783nha17.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%2Fejcza5h52mh3783nha17.png" alt="Three columns - what the type parameter decides, what the spec decides as data, and what is still written by hand for each domain - with the four hand-written files named underneath" width="800" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The ports around it are one interface per file, each with one method, each generic over the same &lt;code&gt;T&lt;/code&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="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Viewer&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;T&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;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;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;key&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;T&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;Historian&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;T&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;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;AsOf&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;key&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;at&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;Time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;T&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;KnownAsOf&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;key&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;known&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;Time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;T&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are eleven of these - &lt;code&gt;Scoped&lt;/code&gt;, &lt;code&gt;Self&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; - so a consumer depends on the two methods it uses rather than on a struct with six. &lt;code&gt;Revisioned[T]&lt;/code&gt; satisfies &lt;code&gt;Viewer[T]&lt;/code&gt; and &lt;code&gt;Historian[T]&lt;/code&gt; without knowing either exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Instantiation: a domain is a declaration
&lt;/h2&gt;

&lt;p&gt;Adding a domain does not create a new package of logic. It creates a spec, a handful of genuinely domain-specific files, and a set of type declarations:&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;package&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Repo&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;Base&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Filter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Patch&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;Mgr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;manager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Patch&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;Domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;.&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;domain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Mgr&lt;/span&gt;&lt;span class="p"&gt;]{&lt;/span&gt;
    &lt;span class="n"&gt;Spec&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;      &lt;span class="n"&gt;Spec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Events&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;Events&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Sentinels&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Errors&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;&lt;code&gt;domain.New&lt;/code&gt; returns &lt;code&gt;Domain[Repo, Mgr]&lt;/code&gt;, built by &lt;code&gt;Builder[Repo, Mgr]&lt;/code&gt;, and the inputs are typed the same way (&lt;code&gt;ManagerInput[Repo]&lt;/code&gt;, &lt;code&gt;HandlerInput[Repo, Mgr]&lt;/code&gt;). Then the assembly file, which is the only place in a domain where you can see the shape of the whole thing at once:&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;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="n"&gt;Domain&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Mgr&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="n"&gt;Calls&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;Calls&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;Calls&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="o"&gt;:&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;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;Search&lt;/span&gt;&lt;span class="o"&gt;:&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;Search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;Walk&lt;/span&gt;&lt;span class="o"&gt;:&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;Walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;Create&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;On&lt;/span&gt;&lt;span class="p"&gt;(&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;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mgr&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;Correct&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;On&lt;/span&gt;&lt;span class="p"&gt;(&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;Correct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mgr&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;Archive&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;On&lt;/span&gt;&lt;span class="p"&gt;(&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;Archive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mgr&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;&lt;code&gt;method.Get&lt;/code&gt;, &lt;code&gt;method.Search&lt;/code&gt;, &lt;code&gt;method.Create&lt;/code&gt; are not this domain's code. They are 3 of &lt;strong&gt;20 generic RPC methods&lt;/strong&gt; that hold every request body in the service: &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;. Each has a single execution method, &lt;code&gt;Handle(ctx, req)&lt;/code&gt;. A domain's handler package contains no request flow at all - a struct with a &lt;code&gt;deps&lt;/code&gt; field, a constructor, and a &lt;code&gt;Call()&lt;/code&gt; returning the lambda the transport wires in.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tx.On&lt;/code&gt; is the transaction decorator, applied only on mutations, with the executor taken from the context - &lt;code&gt;tx pgx.Tx&lt;/code&gt; in a signature is banned, so domain code cannot know it is inside a transaction.&lt;/p&gt;

&lt;p&gt;Why a declaration rather than a package - the numbers make the case better than the argument does. The service is &lt;strong&gt;2733 non-generated &lt;code&gt;.go&lt;/code&gt; files across 252 packages, averaging 39 lines&lt;/strong&gt;. Files over 100 lines: &lt;strong&gt;44, of which 2 are not tests&lt;/strong&gt; (105 and 102 lines). It grows by files and packages, not by files getting fatter, and that only holds while the per-domain content stays a spec plus a few honest files.&lt;/p&gt;

&lt;p&gt;Between tags, code and tests move together:&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;/tr&gt;
&lt;/thead&gt;
&lt;tbody&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;/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;/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;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The ratio sits at 1.25 and hasn't dropped below 1.14 since &lt;code&gt;v1.0.0&lt;/code&gt;. That is what a load-bearing core does to a test suite: tests pile onto the one thing everything depends on instead of spreading thin over N copies.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ┌──────────────────────────────────────────────────────────────────────┐
  │ THE DOMAIN SUPPLIES                                                  │
  │    Spec — handed over as data                                        │
  │    4 hand-written files                                              │
  │    type declarations                                                 │
  └──────────────────────────────────────────────────────────────────────┘
                                     │
                                     ▼
  ┌──────────────────────────────────────────────────────────────────────┐
  │ THE DOMAIN ASSEMBLES — one file, the whole shape visible in it       │
  │    Calls{ Get, Search, Walk }                                        │
  │    Calls{ Create, Correct, Archive }    wrapped in tx.On             │
  └──────────────────────────────────────────────────────────────────────┘
                                     │
                                     ▼
  ┌──────────────────────────────────────────────────────────────────────┐
  │ EVERY DOMAIN SHARES                                                  │
  │    20 generic RPC methods, one Handle(ctx, req) each                 │
  │    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                                                     │
  └──────────────────────────────────────────────────────────────────────┘

  2733 files    252 packages    39 lines average
  44 files over 100 lines, 2 of them not tests
&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%2F8qjkw0ntphu2k7vxas3y.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%2F8qjkw0ntphu2k7vxas3y.png" alt="Three stacked bands showing what a domain supplies - a spec as data, four hand-written files, and type declarations - feeding one assembly file that lists six calls, with a note that the call bodies live in twenty generic RPC methods shared by every domain" width="800" height="640"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The consumer, exactly as it is
&lt;/h2&gt;

&lt;p&gt;In August 2026 I audited my own service for one specific thing: code that re-implements something the shared platform library already gives me - startup and shutdown, migrations, audit, the outbox runner, the messaging driver, transactions, paging, an LRU cache with TTL and metrics, bulk insert, typed message dispatch, test infrastructure. The audit found ten places where the service had written its own version anyway. Four are in one consumer, and here is roughly what it looks like:&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;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;Invalidation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Handle&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;d&lt;/span&gt; &lt;span class="n"&gt;amqp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Delivery&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;var&lt;/span&gt; &lt;span class="n"&gt;msg&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;Type&lt;/span&gt;    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"type"`&lt;/span&gt;
        &lt;span class="n"&gt;Subject&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;Type&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"type"`&lt;/span&gt;
            &lt;span class="n"&gt;ID&lt;/span&gt;   &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"id"`&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="s"&gt;`json:"subject"`&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;json&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;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&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;msg&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;"decode invalidation: %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;if&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;seen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Subject&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="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;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Subject&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;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Drop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Subject&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;msg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Subject&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="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;Four defects, in ascending order of embarrassment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One: the body is decoded by hand.&lt;/strong&gt; The platform has typed dispatch with a serialiser registry - you register a codec per message type and the handler receives a typed value. It is unusable here, and not because I didn't know about it: the &lt;strong&gt;publisher sends with empty headers&lt;/strong&gt;, so the receiver has nothing to select a codec by. The defect is upstream of the code you are reading, which is what makes it expensive - fixing the consumer alone changes nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two: the deduplication is a hand-written ring of 1024 ids.&lt;/strong&gt; A fixed array, a mutex, a write cursor. No TTL, no capacity policy worth the name, no metrics - so when it drops a message it shouldn't have, nothing anywhere records that it happened. Sitting in the platform, imported by this same service, is a cache with exactly TTL, capacity and metrics. The audit also found &lt;strong&gt;three more hand-written caches&lt;/strong&gt; in the service, all &lt;code&gt;map&lt;/code&gt; + &lt;code&gt;RWMutex&lt;/code&gt;, all without TTL, capacity or metrics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three: the subscription declares no redelivery limit, no backoff and no dead-letter queue&lt;/strong&gt;, and that &lt;code&gt;fmt.Errorf&lt;/code&gt; on decode failure is returned as an ordinary error. The driver's contract for an ordinary error is redelivery. So a message that cannot be decoded is redelivered, fails to decode, is redelivered - a poison loop, built out of two individually reasonable lines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Four, and this is the one worth the article:&lt;/strong&gt; this consumer is &lt;strong&gt;not registered in any daemon&lt;/strong&gt;. The subscription list is assembled in one file per daemon, and the constructor for this one is called from nowhere. Which means cache invalidation has never run in production. Not "runs badly" - has never run.&lt;/p&gt;

&lt;p&gt;Nothing caught it. The package compiles, because an unused exported constructor is not an error in Go. Its unit tests are green, because they construct it directly and assert behaviour that is genuinely correct. Coverage is 86.7% and this code is inside that number. Every signal was green about a component wired to nothing.&lt;/p&gt;

&lt;p&gt;That is the honest limit of a well-typed core: &lt;strong&gt;the type system checks the shape of a call, and has nothing to say about a call that is never made.&lt;/strong&gt; All the parameterisation above buys me correctness within a path. It buys me nothing at all about which paths exist.&lt;/p&gt;

&lt;p&gt;Why is it still like this? Because fixing it is not one commit: headers on publish change the publisher and the message contract, typed dispatch changes the handler, redelivery limits and a dead-letter queue change the subscription declaration, and the ring is deleted only after the platform cache is wired in. That work is a set of prompts sitting in a queue - &lt;strong&gt;written, not executed&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;  ┌──────────────────────────────────────────────────────────────────┐
  │ 1  the body is decoded by hand                                   │
  │    json.Unmarshal in the handler                                 │
  │    the publisher sends EMPTY HEADERS — typed dispatch cannot     │
  │    select a codec, so the fix is upstream of this code           │
  ├──────────────────────────────────────────────────────────────────┤
  │ 2  the deduplication is a hand-written ring of 1024 ids          │
  │    a fixed array, a mutex, a write cursor                        │
  │    no TTL, no capacity policy, no metrics — a wrong drop is      │
  │    recorded nowhere.  Three more hand-written caches exist.      │
  ├──────────────────────────────────────────────────────────────────┤
  │ 3  no redelivery limit, no backoff, no dead-letter queue         │
  │    an undecodable message is redelivered, fails, is redelivered  │
  │    a poison loop, built out of two individually reasonable lines │
  ├──────────────────────────────────────────────────────────────────┤
  │ 4  the constructor is called from nowhere                        │
  │    registered in NO daemon — cache invalidation has NEVER run    │
  │    in production                                                 │
  └──────────────────────────────────────────────────────────────────┘

  and every signal about it was green
      compiles        unit tests pass        coverage 86.7%

  the type system checks the shape of a call;
  it says nothing about a call that is never made
&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%2Fr5zmjhugepxq91y7bkkd.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%2Fr5zmjhugepxq91y7bkkd.png" alt="A consumer drawn as one card with four defects marked along it - hand-rolled JSON decode caused by empty publish headers, a fixed 1024-id dedup ring instead of the platform cache with TTL and metrics, a subscription with no redelivery limit or dead-letter queue producing a poison loop, and the constructor called from nowhere so the consumer has never run - with a footer noting compilation, unit tests and 86.7% coverage were all green" width="800" height="659"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Could this have been declarative?
&lt;/h2&gt;

&lt;p&gt;This is the part I have no answer to, left open rather than dressed up as a conclusion.&lt;/p&gt;

&lt;p&gt;Everything above is code that &lt;em&gt;builds&lt;/em&gt; a thing from a description: &lt;code&gt;Spec[H, P, V]&lt;/code&gt; is a description, &lt;code&gt;Calls()&lt;/code&gt; is a list, a domain's package is six declarations and four hand-written files. The obvious next step is to write only the description.&lt;/p&gt;

&lt;p&gt;I have a design for that: a generator taking two inputs - a &lt;strong&gt;YAML description of the domain&lt;/strong&gt; (name and plural, Go types, whether it is owner-scoped, whether it has a slug, header table, revision table, reference column, label column, table aliases, payload columns with Go type, SQL type, nullability and comment, domain event types, the set of sentinel errors, the number of the first migration) and the &lt;strong&gt;&lt;code&gt;.proto&lt;/code&gt; descriptor&lt;/strong&gt; (full gRPC service name, RPCs with request and response types, the entity message, the batch messages). Neither input covers the skeleton alone. Rendering is string concatenation with token replacement, not a template engine, and each &lt;code&gt;.go&lt;/code&gt; output goes through the formatter. Generated files carry a &lt;code&gt;DO NOT EDIT&lt;/code&gt; header and a drift check that fails the build if the tree and the generator disagree.&lt;/p&gt;

&lt;p&gt;It is 12 iterations of design, and it is &lt;strong&gt;not written&lt;/strong&gt;. So this section is a question, not a report.&lt;/p&gt;

&lt;p&gt;The case &lt;em&gt;for&lt;/em&gt;: the description is shorter than the code, it is checkable, and drift becomes a build failure instead of a code review. And I have one place where declarative already won unambiguously - categories. Anything that is a category (a vertical, a type, a status, a binding level) is a &lt;strong&gt;dictionary row plus a string code&lt;/strong&gt;, not an enum. A new attribute, a new dictionary entry, a new binding level is added &lt;strong&gt;as data&lt;/strong&gt;: no schema migration, no contract change, no rollout. Enums survive only for things that change together with code - a value's state, its source, its type, an entity's status.&lt;/p&gt;

&lt;p&gt;The case &lt;em&gt;against&lt;/em&gt; is the one I keep bumping into: &lt;strong&gt;declarative doesn't remove complexity, it moves it into the description and into the validation of the description.&lt;/strong&gt; The YAML above has around fifteen fields per domain, several of which are only meaningful in combination. Every one of those combinations needs a rule, an error message, and a test - which is code, just code you can't step through with a debugger. And the failure mode changes character: a wrong Go declaration is a compile error on a line; a wrong YAML field is a generated file that compiles and does the wrong thing.&lt;/p&gt;

&lt;p&gt;There is a second cost specific to how I work: most of the code in this service is written by an agent from a written spec. A declarative layer means the agent writes the description instead of the code - fewer signatures to anchor on, and no compiler telling it immediately that the shape is wrong. An early prompt set burned about &lt;strong&gt;350,000 tokens per iteration&lt;/strong&gt; because the executor went looking for context it wasn't given, and a sparse description is an invitation to exactly that.&lt;/p&gt;

&lt;p&gt;So: probably yes, this could have been meaningfully more declarative. Whether it should be, I don't know, and the honest reason is that I've never paid the second bill - the one for validating the description - on this codebase.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ┌────────────────────────────────┐ ┌──────────────────────────────────┐
  │ TODAY: A GO DECLARATION        │ │ DESCRIBED: YAML + PROTO          │
  │                                │ │                                  │
  │ Spec, four hand-written files, │ │ 12 iterations designed,          │
  │ aliases                        │ │ NOT WRITTEN                      │
  │ checked by the compiler at     │ │ shorter, and drift fails the     │
  │ the call site                  │ │ build                            │
  │ a wrong field does not build   │ │ ~15 fields, meaningful only in   │
  │ more lines than the            │ │ combination                      │
  │ description would be           │ │ a wrong field compiles and       │
  │                                │ │ misbehaves                       │
  │                                │ │                                  │
  │ domain.New(domain.Input[...])  │ │ not written                      │
  └────────────────────────────────┘ └──────────────────────────────────┘

  one place where the declarative form already won, without argument
      a category is a dictionary row — added as data,
      no migration, no contract change

  declarative does not remove the complexity;
  it moves it into validating the description
&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%2Fu90utuiua9r9mmx6itll.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%2Fu90utuiua9r9mmx6itll.png" alt="Two columns comparing the shape today - a Go declaration checked by the compiler with a wrong field failing to compile - against a described domain in YAML plus a proto descriptor, marked as designed but not written, with the complexity that moves into validating the description listed under it" width="800" height="508"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the cores cost me
&lt;/h2&gt;

&lt;p&gt;Six of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Revisioned[T]&lt;/code&gt; reads as if &lt;code&gt;T&lt;/code&gt; were the row.&lt;/strong&gt; It isn't - &lt;code&gt;T&lt;/code&gt; is the payload. Everyone who instantiates it for the first time gets that wrong, and the compiler's message names the core's parameters, not theirs - so one naming decision, made once, costs a briefing on every new domain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Spec[H, P, V]&lt;/code&gt; is three letters that require a briefing.&lt;/strong&gt; Header, payload, version. Once you know it, the signature is a compressed statement of the whole table model. Before you know it, it is noise, and there is nowhere in the code to look it up, because there are &lt;strong&gt;no comments in &lt;code&gt;.go&lt;/code&gt; files&lt;/strong&gt; - the meaning has to live in names and tests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Twenty generic RPC methods means twenty places nothing can be domain-specific.&lt;/strong&gt; When one of them needs a behaviour a single domain wants, the pressure is to add a flag - and a flag on a generic method is a second implementation hiding inside the first. Saying no costs a longer conversation every time than saying yes would.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ban on a domain-local copy is enforced, and enforcement has a bill.&lt;/strong&gt; A guard test holds the invariant that the row-loop primitives appear in exactly one package; a tool hook blocks writing a duplicate of an existing generic. When an executor hits that block mid-task, it stops and starts hunting for why - one of the recurring token sinks in my prompt sets. The check is worth it; "add a linter rule" is just usually costed as free, and it isn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Integrity moved from the database into the code&lt;/strong&gt;, which is a direct consequence of one core owning writes. No foreign keys, no unique constraints. Uniqueness is a read-then-write in the write layer returning &lt;code&gt;ALREADY_EXISTS&lt;/code&gt;; a lost interval race is &lt;code&gt;ABORTED&lt;/code&gt;. Both are correct and both are mine to keep correct. A &lt;code&gt;UNIQUE&lt;/code&gt; index would have been checked by a system with far better testing than mine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the boundary, which is the important one:&lt;/strong&gt; this pays off because every table in this service has the same three column groups and the same two time axes. Take away the historicity and half of &lt;code&gt;Revisioned[T]&lt;/code&gt; is ceremony around a plain read. Take away the rigid package layout - component type first, domain nested - and instantiation stops being a declaration and becomes a per-domain wiring exercise, which is a framework being maintained rather than a parameter being used. The core isn't the achievement. The uniformity that made a core possible is, and it was decided long before any of this code existed.&lt;/p&gt;




&lt;p&gt;That's the code and the bill. If you want a specific piece of it - the write path with the interval close, the twenty generic methods and how a request finds one, or the consumer rewrite once the publisher sends headers - tell me which and I'll write that one next. And if you've built the declarative version of this and know what validating the description actually costs: I'd like to hear how it's solved on your side, and what broke when you tried.&lt;/p&gt;

</description>
      <category>go</category>
      <category>generics</category>
      <category>architecture</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Life Before Generics</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Tue, 15 Sep 2026 20:35:55 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/life-before-generics-35ci</link>
      <guid>https://dev.to/anton_brilliantov/life-before-generics-35ci</guid>
      <description>&lt;p&gt;&lt;em&gt;The type parameter didn't make my code clever. It moved a class of checking out of review and into the compiler - and it charged me for 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 one is personal history plus the code it produced: where I learned to live without generics, what I built instead, and what a service looks like now that I don't have to. 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 do on one codebase, with the costs I actually pay. Not advice for yours.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where I come from
&lt;/h2&gt;

&lt;p&gt;PHP has no generics. Not "has them awkwardly" - has none. There is no syntax for a type parameter, and there is no runtime that could check one if there were.&lt;/p&gt;

&lt;p&gt;So we suffered a little, and then we solved it differently. Four tools, in roughly the order you reach for them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Interfaces.&lt;/strong&gt; The thing you pass in is described by what it can do, and the concrete type is nobody's business.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Base classes.&lt;/strong&gt; The shared behaviour goes into an abstract parent; each child fills in the parts that differ. Inheritance stands in for parameterisation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Arrays of mixed things.&lt;/strong&gt; A collection is &lt;code&gt;array&lt;/code&gt;, and what's inside it is a matter of convention.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duck typing at the edges.&lt;/strong&gt; At the boundary you accept what arrives and check it yourself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And then, holding all of it together: &lt;strong&gt;a lot of discipline&lt;/strong&gt;. A repository looked like this - and I want to be fair to it, because it worked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AbstractRepository&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/** @return object[] */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;findBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$criteria&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// one implementation, shared by everything&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderRepository&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;AbstractRepository&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/** @return Order[] */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;findBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$criteria&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;findBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$criteria&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;Look at what is carrying the type in that snippet. It isn't the signature - the signature says &lt;code&gt;array&lt;/code&gt;, which is to say "anything". It's the docblock. &lt;code&gt;@return Order[]&lt;/code&gt; is the type parameter, written in a comment, enforced by a static analyser that runs in CI if someone set one up, and by nothing at all at runtime.&lt;/p&gt;

&lt;p&gt;The ecosystem did eventually build the missing feature in comments. Psalm and PHPStan understand &lt;code&gt;@template&lt;/code&gt;, and you can write something that reads almost like the real thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cd"&gt;/**
 * @template T of object
 */&lt;/span&gt;
&lt;span class="k"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AbstractRepository&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/**
     * @param class-string&amp;lt;T&amp;gt; $class
     * @return list&amp;lt;T&amp;gt;
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;findBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$criteria&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&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;I used this, I liked it, and I want to be precise about what it is and isn't. It &lt;strong&gt;is&lt;/strong&gt; a real type system - it catches real mistakes, and a codebase at max analyser level is a genuinely different place to work. It &lt;strong&gt;is not&lt;/strong&gt; the compiler:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it runs where someone installed it, at the level someone configured, with the baseline someone generated when the errors got inconvenient;&lt;/li&gt;
&lt;li&gt;it describes the code rather than constraining it, so a &lt;code&gt;@return list&amp;lt;Order&amp;gt;&lt;/code&gt; above a method that returns &lt;code&gt;list&amp;lt;Entity&amp;gt;&lt;/code&gt; is a lie the runtime is perfectly happy to execute;&lt;/li&gt;
&lt;li&gt;and it disappears the moment a value crosses a boundary the annotations don't cover - a decoded JSON body, a row out of the database, a &lt;code&gt;mixed&lt;/code&gt; from a queue.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The failure mode is not "the analyser is wrong". It's that the type lives in a place a person can edit without the program changing, so it drifts, and it drifts silently.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  four substitutes for a type parameter, in a language that has none

  ┌────────────────┐ ┌────────────────┐ ┌──────────────┐ ┌──────────────┐
  │ interfaces     │ │ base classes   │ │ arrays of    │ │ duck typing  │
  │                │ │                │ │ mixed things │ │ at the edges │
  │ interface      │ │ abstract class │ │ array        │ │ is_a()       │
  └────────────────┘ └────────────────┘ └──────────────┘ └──────────────┘

  and one thing holding all four up

  ┌────────────────────────┐
  │       DISCIPLINE       │
  └────────────────────────┘
       │              │
       ▼              ▼
    review          tests

  the compiler is not on this diagram
&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%2Fo3k69596zm3okjbwe9ev.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%2Fo3k69596zm3okjbwe9ev.png" alt="On the left, four substitutes for a type parameter in a language that has none - interfaces, base classes, arrays of mixed things, duck typing at the edges - each drawn as a small card; on the right, a single card labelled discipline, larger than the others, with two arrows leaving it towards review and tests" width="800" height="424"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is the whole cost, and it's worth stating plainly rather than as a complaint:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The compiler could not help, so the checking moved into review and into tests.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Both of those are real mechanisms and both of them work - they just cost a person's attention every time, forever, on every change. A wrong type in an array is caught by a test if someone wrote that test, or by a reviewer if someone was looking. Neither is a property of the code. Both are properties of the team's current level of care, which is not a stable quantity.&lt;/p&gt;

&lt;p&gt;The second cost is subtler and I only noticed it after leaving: &lt;strong&gt;the substitutes are not free of their own gravity.&lt;/strong&gt; Base classes accumulate. What starts as "shared read behaviour" becomes a parent with eleven protected methods, four of which two children override in incompatible ways. The inheritance chain becomes the place where the polymorphism lives, and it is much harder to delete than a type parameter is. You can remove a type parameter in an afternoon. Removing a base class is a project with a name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Go before generics
&lt;/h2&gt;

&lt;p&gt;Then I came to Go, which at the time didn't have generics either. Different language, same missing thing, and - this is the part that surprised me - a &lt;em&gt;worse&lt;/em&gt; set of substitutes, because Go has neither inheritance nor a static-analyser culture that reads comments.&lt;/p&gt;

&lt;p&gt;There were four honest options and everybody knew all four.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option one: &lt;code&gt;interface{}&lt;/code&gt; and a type assertion.&lt;/strong&gt; You accept anything, and at the point of use you assert what you actually have.&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;func&lt;/span&gt; &lt;span class="n"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="n"&gt;pgx&lt;/span&gt;&lt;span class="o"&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;scan&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;pgx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;interface&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;interface&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;Every caller then unpacks the result:&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;items&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;collect&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;scanOrder&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="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="n"&gt;Order&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="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&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;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;item&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;items&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;order&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;item&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Order&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;ErrBadType&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="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&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&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;That is the PHP docblock again, except now the failure is at runtime and the unpacking is code you have to write, review and test. You didn't remove the copy - you moved it, and made it longer. There's a second bill people forget: every element goes through an interface box on the way in and a type assertion on the way out, so a hot read path pays allocation and indirection for the privilege of being untyped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option two: reflection.&lt;/strong&gt; You can write the loop once and fill a &lt;code&gt;*[]Order&lt;/code&gt; handed in as &lt;code&gt;any&lt;/code&gt;, matching columns to struct fields at runtime. It works - several well-known libraries are built on it. It also moves every mistake to runtime, makes the code unreadable in exactly the place you most want to read it, and costs enough per row that you notice on a large result set.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option three: code generation.&lt;/strong&gt; Write the shape once as a template, generate a typed copy per entity, commit the output. It genuinely works, and the typed result is exactly what you wanted. What you buy it with: a build step, generated files in the repository, a &lt;code&gt;DO NOT EDIT&lt;/code&gt; header that people edit anyway, and debugging that happens in a file nobody wrote.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option four - the honest one: write it once per type.&lt;/strong&gt; No cleverness, no runtime failure mode, no build step. Just the same function again, with &lt;code&gt;Order&lt;/code&gt; where &lt;code&gt;Entity&lt;/code&gt; used to be.&lt;/p&gt;

&lt;p&gt;Most of my Go, and most of the Go I read, was option four. It reads fine. Here is one read path, written the honest way:&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;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;OrderRepository&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Search&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;f&lt;/span&gt; &lt;span class="n"&gt;OrderFilter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;([]&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="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;orderSearchSql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&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;f&lt;/span&gt;&lt;span class="o"&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;f&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 orders: %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;Order&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;f&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;o&lt;/span&gt; &lt;span class="n"&gt;Order&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;o&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;o&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="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Code&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;o&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreatedAt&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 order: %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;o&lt;/span&gt;&lt;span class="p"&gt;)&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;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="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;"iterate orders: %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;return&lt;/span&gt; &lt;span class="n"&gt;out&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;Nothing is wrong with that function. It is clear, it handles its errors, &lt;code&gt;errors.Is&lt;/code&gt; will still match through the wraps. The problem is that there is another one exactly like it for &lt;code&gt;Entity&lt;/code&gt;, and another for &lt;code&gt;Account&lt;/code&gt;, and the fourth one is being written right now in a branch you haven't seen.&lt;/p&gt;

&lt;p&gt;And the copies rot at different speeds. That is the part that costs money. One of them learns to call &lt;code&gt;rows.Close()&lt;/code&gt; on the early-return path; the others don't. One of them starts checking &lt;code&gt;rows.Err()&lt;/code&gt; after the loop; two of them still treat "zero rows" and "the connection died mid-iteration" as the same answer. You don't find that by reading - all four look right in isolation. You find it when a query returns eleven rows out of a thousand and nothing anywhere says why.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
  │ interface{} +        │ │ go:generate          │ │ one copy per type    │
  │ assertion            │ │                      │ │                      │
  │                      │ │                      │ │                      │
  │ GIVES                │ │ GIVES                │ │ GIVES                │
  │ one implementation   │ │ typed output         │ │ nothing to explain   │
  │                      │ │                      │ │                      │
  │ CHARGES              │ │ CHARGES              │ │ CHARGES              │
  │ runtime failure,     │ │ a build step and     │ │ the same function    │
  │ unpacking at every   │ │ files nobody wrote   │ │ again, and again     │
  │ caller               │ │                      │ │                      │
  └──────────────────────┘ └──────────────────────┘ └──────────────────────┘
                                                     the one most Go
                                                     actually picked
&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%2Fgzziubgjff08u7b4n8hl.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%2Fgzziubgjff08u7b4n8hl.png" alt="Three columns headed interface braces plus assertion, code generation, and one copy per type; under each, two lines - what it gives you and what it charges - with the third column marked as the one most people actually pick" width="800" height="358"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the copies actually differ in
&lt;/h2&gt;

&lt;p&gt;This isn't a hypothetical for me. In August 2026 I ran an audit over my own service looking for hand-written ways to read rows out of Postgres. It found &lt;strong&gt;44 copies of four 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;Then I lined them up next to each other and looked at what was actually different between any two of them. Two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the result type;&lt;/li&gt;
&lt;li&gt;the wording of the error wrapper.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Twelve of the forty-four differed only in the second one.&lt;/strong&gt; Twelve functions, identical down to the loop structure, whose entire distinguishing content was that one said &lt;code&gt;scan order&lt;/code&gt; and another said &lt;code&gt;scanning entity row&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That detail sounds like a joke about naming. It isn't. Error text is an interface: something upstream matches on it, a dashboard groups by it, a person searches the logs for it. Twelve spellings of one failure means the log query that finds the incident finds a third of it.&lt;/p&gt;

&lt;p&gt;A thing that differs from its neighbour only in a type - that is not a family of functions. That is one function and a parameter, typed out by hand forty-four times. The copies existed because at the time there was no way to say it, and then they kept existing because by the time there was, nobody went back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  44 copies of four forms

  many-row loop     ██████████████████  18
  single row        ███████              6
  COUNT(*) counter  █████████            8
  existence probe   ████████████        12
                                        ──
                                        44

  of those 44, TWELVE differ only in the wording of the error wrapper

  the result type and the error text are the whole difference
&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%2Fu71it224du7i2oizgu00.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%2Fu71it224du7i2oizgu00.png" alt="Four bars sized 18, 6, 8 and 12 under the heading forty-four copies of four forms; a bracket over the twelve-bar labelled differ only in the wording of the error wrapper; below, one line - the result type and the error text are the whole difference" width="799" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How I write a service now
&lt;/h2&gt;

&lt;p&gt;So here is the current shape. It's the point of this piece, and every part of it is a place where the type parameter sits.&lt;/p&gt;

&lt;p&gt;A domain in my service is six things. All names below are neutral (&lt;code&gt;entity&lt;/code&gt;, &lt;code&gt;order&lt;/code&gt;) - the shapes are real.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The entity is the typed unit
&lt;/h3&gt;

&lt;p&gt;Everything below is parameterised over it. It's a plain struct plus its identifiers - no framework, no base type, no marker interface, no embedded &lt;code&gt;Model&lt;/code&gt;. It's the thing the type parameter &lt;em&gt;is&lt;/em&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="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;entity&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Row&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;ID&lt;/span&gt;        &lt;span class="kt"&gt;int64&lt;/span&gt;
    &lt;span class="n"&gt;TenantId&lt;/span&gt;  &lt;span class="kt"&gt;int64&lt;/span&gt;
    &lt;span class="n"&gt;Code&lt;/span&gt;      &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Revision&lt;/span&gt;  &lt;span class="kt"&gt;int32&lt;/span&gt;
    &lt;span class="n"&gt;CreatedAt&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;Time&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That it's a plain struct matters more than it looks. The moment an entity has to implement something to participate, the constraint stops describing data and starts describing a framework, and every new domain pays an initiation fee.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The repository holds the read operations, generic over the entity
&lt;/h3&gt;

&lt;p&gt;The read side is where the copy-paste lived, so it's where the cores are. Three small interfaces carry the whole thing:&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;// what can execute a query — a pool, or a transaction, and nothing else knows which&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Executor&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;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;sql&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;args&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pgx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rows&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;// how a typed input becomes SQL&lt;/span&gt;
&lt;span class="k"&gt;type&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="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;]&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;Build&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;sql&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;args&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&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;// how one row becomes a typed value&lt;/span&gt;
&lt;span class="k"&gt;type&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="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;]&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;Scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="n"&gt;pgx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rows&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the core those three make possible - this is the one that forty-four functions collapsed onto:&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;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="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&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;statement&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Build&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="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;"%w: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrStatement&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;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;executor&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;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;...&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;"%w: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrQuery&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;Out&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="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="n"&gt;item&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;scanner&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="n"&gt;rows&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;"%w: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrScan&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;item&lt;/span&gt;&lt;span class="p"&gt;)&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;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="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;"%w: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrIterate&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;return&lt;/span&gt; &lt;span class="n"&gt;out&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;Four sentinels - &lt;code&gt;ErrStatement&lt;/code&gt;, &lt;code&gt;ErrQuery&lt;/code&gt;, &lt;code&gt;ErrScan&lt;/code&gt;, &lt;code&gt;ErrIterate&lt;/code&gt; - and every caller everywhere matches them with &lt;code&gt;errors.Is&lt;/code&gt; through however many wraps sit on top. That's the twelve spellings problem closed by construction rather than by a naming convention.&lt;/p&gt;

&lt;p&gt;The other three forms are the same core with a different tail: one row, a count, an existence probe. Each is ten lines, each exists once.&lt;/p&gt;

&lt;p&gt;A domain's read path becomes a declaration:&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;var&lt;/span&gt; &lt;span class="n"&gt;searchOrders&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;NewReader&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;Filter&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;Row&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;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SearchStatement&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;RowScanner&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;Three lines instead of twenty-two, and - this matters more - the loop, the close, the &lt;code&gt;rows.Err()&lt;/code&gt; check and the error wording are now in &lt;strong&gt;one&lt;/strong&gt; place, so twelve functions can no longer disagree about how to phrase the same failure or about whether to check the iteration error at all.&lt;/p&gt;

&lt;p&gt;Above the reader sits the part a domain actually configures. &lt;code&gt;Spec&lt;/code&gt; is the description of a table: what the header row is, what the payload is, how the version is carried.&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;Spec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;V&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;Table&lt;/span&gt;   &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Header&lt;/span&gt;  &lt;span class="n"&gt;Columns&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c"&gt;// identity: id, tenant, code&lt;/span&gt;
    &lt;span class="n"&gt;Payload&lt;/span&gt; &lt;span class="n"&gt;Columns&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c"&gt;// the changing part&lt;/span&gt;
    &lt;span class="n"&gt;Version&lt;/span&gt; &lt;span class="n"&gt;Columns&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;V&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c"&gt;// revision, valid-from, valid-to&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three parameters look like a lot until you notice they're the three groups every table in this service actually has, and that separating them is what lets one core do keyset pagination, optimistic locking and partial updates without a domain writing any of them.&lt;/p&gt;

&lt;p&gt;Paging is generic too, and it's the sort of type that must exist exactly once in a codebase:&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;Page&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;T&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;Items&lt;/span&gt;         &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;
    &lt;span class="n"&gt;NextPageToken&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I've seen the entity-shaped version of this three times in one repository under three names. A page is not about orders; it is about pages.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The manager holds the write operations, generic over the entity
&lt;/h3&gt;

&lt;p&gt;One layer, one job: writes, including find-or-create. Repository reads, manager writes, and there is no third thing - no &lt;code&gt;store&lt;/code&gt;, no &lt;code&gt;service&lt;/code&gt;, no &lt;code&gt;dao&lt;/code&gt;. If a manager needs to read, it calls the repository rather than growing its own SQL.&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;Manager&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="n"&gt;C&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;U&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;spec&lt;/span&gt;     &lt;span class="n"&gt;Spec&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="n"&gt;C&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;U&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;reader&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;Key&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="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;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="n"&gt;Manager&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="n"&gt;C&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;U&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="n"&gt;FindOrCreate&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;key&lt;/span&gt; &lt;span class="n"&gt;Key&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="n"&gt;create&lt;/span&gt; &lt;span class="n"&gt;C&lt;/span&gt;&lt;span class="p"&gt;)&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="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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="n"&gt;Manager&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="n"&gt;C&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;U&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="n"&gt;Update&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;key&lt;/span&gt; &lt;span class="n"&gt;Key&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="n"&gt;patch&lt;/span&gt; &lt;span class="n"&gt;U&lt;/span&gt;&lt;span class="p"&gt;)&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="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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="n"&gt;Manager&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="n"&gt;C&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;U&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="n"&gt;Archive&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;key&lt;/span&gt; &lt;span class="n"&gt;Key&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="kt"&gt;error&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;FindOrCreate&lt;/code&gt; is the one worth pausing on, because it's where a copy-per-domain hurts most. It is a select, then a conditional insert, then a re-read on conflict - and every hand-written instance of it gets the conflict branch subtly differently. Written once, the race is thought about once.&lt;/p&gt;

&lt;p&gt;Note what is &lt;strong&gt;not&lt;/strong&gt; in these signatures: no &lt;code&gt;tx pgx.Tx&lt;/code&gt;. The executor is a field, and a transaction-scoped instance is a new value, not a new argument:&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;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="n"&gt;Manager&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="n"&gt;C&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;U&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="n"&gt;WithTx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx&lt;/span&gt; &lt;span class="n"&gt;pgx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Manager&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="n"&gt;C&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;U&lt;/span&gt;&lt;span class="p"&gt;]&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;executor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. The query builder is declarative
&lt;/h3&gt;

&lt;p&gt;This is the piece that actually made the read side stop being copy-paste, and it's easy to under-sell. All SQL in the service goes through a builder; raw SQL in Go is forbidden, with migration DDL and test fixtures as the only exemptions.&lt;/p&gt;

&lt;p&gt;The point of the builder isn't string safety. It's that you &lt;strong&gt;describe what you want, not how to assemble it&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="n"&gt;spec&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;Select&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;Row&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;Columns&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;From&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;Table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;Where&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;TenantEq&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;NotArchived&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;OrderBy&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;CreatedAtDesc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;Page&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare that with the alternative, which is a function that concatenates a &lt;code&gt;WHERE&lt;/code&gt; clause conditionally and gets the placeholder numbering right by hand. Written once, that function is fine. Written once per domain, it is the same bug eight times - and the bug is always the same bug, some variation of a filter that silently didn't apply. Silently is the operative word: a filter that doesn't apply returns &lt;em&gt;more&lt;/em&gt; rows, and more rows look like data, not like a failure.&lt;/p&gt;

&lt;p&gt;Because the builder is declarative, the reader above can be generic at all. &lt;code&gt;Statement[In]&lt;/code&gt; doesn't need to know how a filter becomes SQL; it needs a description it can turn into one.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Processors plug into the builder instead of being baked into each query
&lt;/h3&gt;

&lt;p&gt;A read is rarely just "select these columns". It's "select these columns, apply whatever filters the caller sent, sort by whatever they asked for, clamp the page size". Every one of those is a transformation of the query, and every one of them used to be an &lt;code&gt;if&lt;/code&gt; inside a hand-written method.&lt;/p&gt;

&lt;p&gt;Now they're values:&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;Processor&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Q&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;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="n"&gt;Q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Q&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;Chain&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Q&lt;/span&gt; &lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;Processor&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Q&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="n"&gt;Chain&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Q&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="n"&gt;Apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="n"&gt;Q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Q&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;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&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="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&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;q&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A read declares which processors it runs and in what order; the builder applies them. Adding a new filter to a domain is adding a processor to a list, not editing a query. Adding a sort that four domains need is writing one processor, not four &lt;code&gt;ORDER BY&lt;/code&gt; branches.&lt;/p&gt;

&lt;p&gt;The rule that keeps this from becoming its own mess: &lt;strong&gt;a processor is generic over the query, not over the domain.&lt;/strong&gt; The moment I see &lt;code&gt;OrderStatusProcessor&lt;/code&gt; and &lt;code&gt;EntityStatusProcessor&lt;/code&gt; with the same body, that's the same forty-four-copies smell in a new costume.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. The gRPC handler may or may not be transactional - and that's declared, not implemented
&lt;/h3&gt;

&lt;p&gt;Transactions sit outside the read/write split entirely: a decorator applied from the outside, only on mutations, with the executor taken from the context. &lt;code&gt;tx pgx.Tx&lt;/code&gt; in a signature is forbidden, and the domain code never learns it is running inside a transaction.&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;func&lt;/span&gt; &lt;span class="n"&gt;Transactional&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Res&lt;/span&gt; &lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&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;pgxpool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt; &lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Res&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Res&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;HandlerFunc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Res&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;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;req&lt;/span&gt; &lt;span class="n"&gt;Req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Res&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;var&lt;/span&gt; &lt;span class="n"&gt;zero&lt;/span&gt; &lt;span class="n"&gt;Res&lt;/span&gt;
        &lt;span class="n"&gt;tx&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;pool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Begin&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="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;zero&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;"%w: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrBegin&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;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rollback&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;res&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;next&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WithExecutor&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;tx&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;req&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;zero&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="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;tx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Commit&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;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;zero&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;"%w: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrCommit&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;return&lt;/span&gt; &lt;span class="n"&gt;res&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So "is this call transactional?" is a &lt;strong&gt;property declared where the domain's calls are assembled&lt;/strong&gt;, alongside the handler, not a thing re-implemented per 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;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="n"&gt;Domain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Calls&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;Calls&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;Calls&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Search&lt;/span&gt;&lt;span class="o"&gt;:&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;Search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;                          &lt;span class="c"&gt;// read: no decorator&lt;/span&gt;
        &lt;span class="n"&gt;Create&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Transactional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;,&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;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;manager&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="c"&gt;// write: wrapped here&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 method body is the same code either way. That's the whole benefit: the decision is visible in one file per domain, and a method cannot quietly acquire or lose transactionality by someone editing its body.&lt;/p&gt;

&lt;p&gt;The bodies themselves aren't in the domain at all. The RPC flow lives in one of 20 generic methods, each with a single &lt;code&gt;Handle(ctx, req)&lt;/code&gt;. What the domain's handler package contains is assembly: a type with a &lt;code&gt;deps&lt;/code&gt; field, a constructor, and a &lt;code&gt;Call()&lt;/code&gt; that returns the lambda the transport wires in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ONE DOMAIN, SIX PLACES — and the column that says what varies

  ┌──────────────────────────────────────────────────────────┐ ┌───────┐
  │ entity          Order                                    │ │  [T]  │
  │ manager         Manager[E, C, U]      writes             │ │  [T]  │
  │ repository      Reader[In, Out]       reads              │ │  [T]  │
  │ query builder   Select[Row]()         declarative        │ │  [Q]  │
  │ processors      Processor[Q]          plugged in         │ │  [Q]  │
  │ handler         Call()                transactionality   │ │  [T]  │
  │                                       declared, not      │ │       │
  │                                       implemented        │ │       │
  └──────────────────────────────────────────────────────────┘ └───────┘

  [T]  the domain's entity — the type parameter
  [Q]  the query — generic over the query, not over the domain
&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%2Fw9bn3di8r8amdh46h575.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%2Fw9bn3di8r8amdh46h575.png" alt="A vertical stack of six labelled bands for one domain - entity, manager, repository, query builder, processors, handler - with a narrow column down the right-hand side marked type parameter, filled at every band except the query builder and processors, which are marked generic over the query instead" width="799" height="569"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How I test a core that everything depends on
&lt;/h2&gt;

&lt;p&gt;A generic core is load-bearing by definition, so the test shape changes with it. Three things I do that I didn't do when the code was forty-four copies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test the core once, across instantiations.&lt;/strong&gt; The reader's test doesn't test orders. It instantiates the reader over two deliberately different types - one with a nullable column, one without - and runs the same table of cases: zero rows, one row, many rows, a scan failure mid-iteration, an iteration error after the last row, a cancelled context. Each of those cases used to be untested in forty-three places and tested in one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assert the failure paths that the copies used to get wrong&lt;/strong&gt;, and assert them by identity rather than by string:&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;_&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;reader&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;filter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;require&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrorIs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&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="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrIterate&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;Hold the invariant with a test, not with a review comment.&lt;/strong&gt; The closing condition for the whole forty-four migration is mechanical: &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; may appear in exactly &lt;strong&gt;one&lt;/strong&gt; package in the tree. That's a test that walks the source and fails the build if a forty-fifth copy appears - which is the only form of "we agreed not to do that" that survives a busy week.&lt;/p&gt;

&lt;p&gt;And because the core is on every read path, it carries the benchmark weight too: allocations per row are a property of one function now, so measuring it is worth doing and comparing it release to release is meaningful.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ┌────────────────────────────────────────────────────────────────┐
  │ 1  one table of cases, run across TWO instantiations           │
  │    zero rows · one row · many rows · a scan failure mid-loop · │
  │    an iteration error after the last row · a cancelled context │
  ├────────────────────────────────────────────────────────────────┤
  │ 2  assert the failure by IDENTITY, not by string               │
  │    require.ErrorIs(t, err, rows.ErrIterate)                    │
  ├────────────────────────────────────────────────────────────────┤
  │ 3  a guard test walks the source and fails the build           │
  │    for rows.Next() · rows.Err() · rows.Close() · QueryRow(     │
  │    allowed in EXACTLY ONE package                              │
  └────────────────────────────────────────────────────────────────┘
                                 │
                                 ▼
                        one generic core

  the same table used to be untested in forty-three places
&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%2F78t23tsxedqvv2bjfpmd.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%2F78t23tsxedqvv2bjfpmd.png" alt="Three test bands over one generic core - a table of cases run across two instantiations, error identity assertions against sentinels, and a guard test that allows the row-loop primitives in exactly one package - with a note that the same table used to be untested in forty-three places" width="800" height="461"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the type parameter is the right seam
&lt;/h2&gt;

&lt;p&gt;Everything above only works because of a layout decision I made earlier, and the two are the same idea seen twice.&lt;/p&gt;

&lt;p&gt;Packages are laid out &lt;strong&gt;by component type first, with the domain as a nested package&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;internal/repository/entity
internal/repository/order
internal/manager/entity
internal/manager/order
internal/model/entity
internal/model/order
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not the inverse - &lt;code&gt;internal/domain/&amp;lt;area&amp;gt;/repository&lt;/code&gt;, one package per business area holding every role.&lt;/p&gt;

&lt;p&gt;Put the two layouts next to each other and ask what stays the same as you move from &lt;code&gt;entity&lt;/code&gt; to &lt;code&gt;order&lt;/code&gt;. In the type-first layout, the answer is: everything except the type. The read path for orders is the read path for entities with &lt;code&gt;Order&lt;/code&gt; substituted. There is a single thing that varies, it has a name, and the language now has a way to spell it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Between two domains' read paths&lt;/th&gt;
&lt;th&gt;Differs?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;the loop that walks the rows&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the error wrapping and its wording&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cursor handling and page clamping&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;how a filter becomes SQL&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the table, columns and their scan targets&lt;/td&gt;
&lt;td&gt;yes - the spec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the entity type&lt;/td&gt;
&lt;td&gt;yes - &lt;strong&gt;the type parameter&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two rows on the right. One of them is data - a spec the domain hands over. The other is the parameter. Everything else is one core with N instantiations.&lt;/p&gt;

&lt;p&gt;The rule I hold myself to is one line: &lt;strong&gt;if two things differ only in a type parameter, they are one generic.&lt;/strong&gt; A domain-local copy sitting next to an existing generic is forbidden, and that ban is held by the guard test above rather than by review etiquette.&lt;/p&gt;

&lt;p&gt;The practical consequence is the one I care about: &lt;strong&gt;adding a domain is declaring a type, not writing a package.&lt;/strong&gt; It's a spec, a handful of files that carry genuinely domain-specific rules, and instantiations of everything else.&lt;/p&gt;

&lt;h3&gt;
  
  
  The effect is visible in the numbers
&lt;/h3&gt;

&lt;p&gt;None of this is a claim about elegance. Over the tag history of the service, generated code excluded:&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.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;&lt;strong&gt;v1.0.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;25097&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;35368&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.41&lt;/strong&gt;&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;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At &lt;code&gt;v1.0.0&lt;/code&gt;, lines of code &lt;strong&gt;dropped&lt;/strong&gt; by roughly three thousand while test lines grew - that step is repeated domain files going into generic cores. The tests-to-code ratio went 1.14 → 1.41 and hasn't been below 1.14 since.&lt;/p&gt;

&lt;p&gt;The other number worth reading: the service is 2733 files across 252 packages, averaging 39 lines. It grows by files and packages, not by files getting fatter - which is what you'd expect when the shared behaviour has somewhere to live.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  each █ = 1000 lines above a 20000 baseline

            v0.4.0            v1.0.0            v1.1.0
  tests     ████████████      ███████████████   ████████████████
            31952             35368             36272
  code      ████████          █████             █████████
            28095             25097             29443
                              ▲
                              └── the dip: 28095 ──▶ 25097

  tests/code   1.14   ──▶   1.41   ──▶   1.23

  the dip is repeated domain files going into generic cores
&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%2Fylvs2g8oi9e5x7jzk6g1.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%2Fylvs2g8oi9e5x7jzk6g1.png" alt="A small line chart of lines of code across four tags with a visible dip at the third point, annotated twenty-eight thousand down to twenty-five thousand, and a second line for tests continuing upward through the dip" width="799" height="442"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;Generics are not free, and I'd rather list the bill than pretend the migration was a clean win.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Signatures get harder to read.&lt;/strong&gt; &lt;code&gt;Reader[In, Out]&lt;/code&gt; is fine. &lt;code&gt;Spec[H, P, V]&lt;/code&gt; is three single letters that mean header, payload and version, and you have to know that. A generic core's signature is a compressed description of a design - which is efficient once you hold the design, and opaque before you do. New readers hit it before they hit anything friendly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error messages get worse.&lt;/strong&gt; When an instantiation doesn't satisfy a constraint, the compiler points at the core, not at the line you wrote. You get told something about a type set, in terms of the core's parameter names, and you have to walk back to your own call site yourself. In the concrete, copy-per-type world, the error was on the line with the mistake.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Constraints leak into every caller.&lt;/strong&gt; This is the one I underestimated. The moment a core takes &lt;code&gt;[In, Out any]&lt;/code&gt;, every wrapper around it takes them too, and every wrapper around &lt;em&gt;that&lt;/em&gt;. Type parameters propagate outward through signatures until something concrete stops them. Pick the stopping point deliberately - mine is the domain's assembly file, where &lt;code&gt;Domain[Repo, Mgr]&lt;/code&gt; becomes a plain &lt;code&gt;Domain&lt;/code&gt; - or you'll find the parameters have reached the transport layer, and a gRPC handler is arguing about type sets.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  type parameters propagate OUTWARD until something concrete stops them

  ┌──────────────────────────────────────────────────────────────┐
  │  transport — concrete: no type parameter reaches here        │
  │  ┌────────────────────────────────────────────────────────┐  │
  │  │  domain assembly   Domain[Repo, Mgr]  ──▶  Domain      │  │
  │  │  the deliberate stop — the parameters end here         │  │
  │  │  ┌──────────────────────────────────────────────────┐  │  │
  │  │  │  wrapper   Manager[E, C, U]                      │  │  │
  │  │  │  ┌────────────────────────────────────────────┐  │  │  │
  │  │  │  │  core      Reader[In, Out]                 │  │  │  │
  │  │  │  └────────────────────────────────────────────┘  │  │  │
  │  │  └──────────────────────────────────────────────────┘  │  │
  │  └────────────────────────────────────────────────────────┘  │
  └──────────────────────────────────────────────────────────────┘
&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%2Fo56z93hvb8fqcuctc7qd.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%2Fo56z93hvb8fqcuctc7qd.png" alt="Type parameters spreading outward through four rings of signatures - core, wrapper, domain assembly, transport - with a deliberate stop marked at the domain assembly ring and the transport ring left concrete" width="799" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The core becomes load-bearing, and therefore risky.&lt;/strong&gt; Forty-four call sites depending on one &lt;code&gt;Query&lt;/code&gt; method is exactly the deduplication I wanted, and it also means one careless change there is a service-wide change. The copies had a property the core doesn't: a bug in one of them was a bug in one place. What I trade that for is that a &lt;em&gt;fix&lt;/em&gt; is also in one place - but the blast radius is real and it's the reason the core carries disproportionate test weight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Instantiation isn't free at build time.&lt;/strong&gt; The compiler does real work per instantiation, and a core with three parameters used across a few dozen domains is measurably more to compile than the concrete code it replaced. It hasn't been a problem at my size. It's a line in the bill, not a footnote.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the temptation.&lt;/strong&gt; The failure mode of generics isn't writing too few, it's making one abstraction serve two things that were never the same. The tell is specific and I've learned to watch for it: a type parameter &lt;strong&gt;plus a boolean flag&lt;/strong&gt;, or a type parameter plus a &lt;code&gt;switch&lt;/code&gt; inside the core. That combination means the two instantiations wanted different behaviour and I forced them through one door. Two clear functions would have been better than one parameterised one with a mode.&lt;/p&gt;

&lt;h3&gt;
  
  
  When I don't reach for a type parameter
&lt;/h3&gt;

&lt;p&gt;The list I actually use, in the order the questions come up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The things differ in behaviour, not only in type.&lt;/strong&gt; That's an interface. A constraint whose methods each implementation fills in differently is polymorphism wearing a type parameter's clothes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There is exactly one instantiation and no second one in sight.&lt;/strong&gt; A concrete type reads better, and the generic can be extracted the day the second one arrives - it's a five-minute refactor, not an architectural decision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The constraint would have to enumerate half the language.&lt;/strong&gt; If pinning down what the parameter can do takes longer than writing both versions, the abstraction isn't there yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It exists to save typing rather than to remove a decision.&lt;/strong&gt; Deduplicating twelve identical error strings removes a decision - which wording is right. Deduplicating two four-line functions saves keystrokes and costs a reader.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The parameter is at the wrong level.&lt;/strong&gt; Generic over one operation is usually right. Generic over an entire service is usually someone building a framework by accident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's the second abstraction over the same thing.&lt;/strong&gt; A new generic next to an existing generic that nearly fits is the copy-paste problem again, one level up.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ┌──────────────────────────────────┐ ┌──────────────────────────────────┐
  │ REACH FOR IT                     │ │ DON'T                            │
  │                                  │ │                                  │
  │ differs only in the type         │ │ differs in behaviour             │
  │ the same shape three or more     │ │ exactly one instantiation        │
  │ times                            │ │ a constraint longer than the     │
  │ one wording for one failure      │ │ code                             │
  │                                  │ │ it saves typing, not a decision  │
  │                                  │ │ the parameter is at the wrong    │
  │                                  │ │ level                            │
  │                                  │ │ a second abstraction over the    │
  │                                  │ │ same thing                       │
  │                                  │ │                                  │
  │                                  │ │ [T] + bool                       │
  │                                  │ │ two instantiations wanted        │
  │                                  │ │ different behaviour, forced      │
  │                                  │ │ through one door                 │
  └──────────────────────────────────┘ └──────────────────────────────────┘
&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%2Fgomjjhva2qdapn2dh66k.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%2Fgomjjhva2qdapn2dh66k.png" alt="Two columns headed reach for it and do not; the left lists differs only in the type, the same shape three or more times, one wording for one failure; the right lists differs in behaviour, one instantiation, a constraint longer than the code, a type parameter next to a boolean flag - the last item marked in the broken accent colour" width="800" height="353"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;The type parameter didn't make me a better designer. It moved a specific, narrow class of checking - &lt;em&gt;is this the type I said it was&lt;/em&gt; - out of review and tests and into the compiler, and it charged me harder-to-read signatures and a load-bearing core for it.&lt;/p&gt;

&lt;p&gt;That's a good trade on repetitive, shape-heavy code with a rigid layout, which is exactly the code I write now. It's a bad trade the moment the things I'm unifying differ in anything other than the type - and the years in PHP, where I had to solve this without the tool at all, are what taught me to tell those two cases apart.&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 it, or if you look at it differently - I'd like to hear how it's solved on your side, and what broke when you tried.&lt;/p&gt;

</description>
      <category>go</category>
      <category>php</category>
      <category>generics</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Wiring a New Service Into the Server and the Worker</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Tue, 15 Sep 2026 20:33:34 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/wiring-a-new-service-into-the-server-and-the-worker-3ed0</link>
      <guid>https://dev.to/anton_brilliantov/wiring-a-new-service-into-the-server-and-the-worker-3ed0</guid>
      <description>&lt;p&gt;&lt;em&gt;Two binaries. One layout. Four ways to make a new unit of work exist in either of them - and what each way costs.&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. Earlier parts of this series were about the manifest as the single&lt;br&gt;
declaration a service makes about itself, the environment-variable catalog that falls out of it,&lt;br&gt;
the metrics that arrive with the runtime, and deploy as a consequence of the same file. This part&lt;br&gt;
is the seam underneath all of them: &lt;strong&gt;a new handler or a new consumer exists in the repository -&lt;br&gt;
now how does it end up running?&lt;/strong&gt; 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;This is what I do on one codebase, with the reasons and the price. You may already do it better,&lt;br&gt;
you may have been here years ago, or you may look at it and disagree - all three are useful to me.&lt;/p&gt;


&lt;h2&gt;
  
  
  Two binaries out of one image
&lt;/h2&gt;

&lt;p&gt;The service declares its daemons in the manifest, and there are exactly two of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;service&amp;gt;&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.1.0&lt;/span&gt;
  &lt;span class="na"&gt;daemons&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;server&lt;/span&gt;
      &lt;span class="na"&gt;handlers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;grpc&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;worker&lt;/span&gt;
      &lt;span class="na"&gt;handlers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;scheduler&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;infra&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
      &lt;span class="na"&gt;migrations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;tx&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
      &lt;span class="na"&gt;pool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
  &lt;span class="na"&gt;grpc&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;
      &lt;span class="na"&gt;daemons&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;scheduler&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
      &lt;span class="na"&gt;daemons&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;worker&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;messaging&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That file is not documentation. Three things read it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The build.&lt;/strong&gt; One &lt;code&gt;Dockerfile&lt;/code&gt;, &lt;code&gt;--build-arg BINARY=&lt;/code&gt;, one image per daemon, pushed on a &lt;code&gt;vX.Y.Z&lt;/code&gt;
tag with the version and commit linked in through ldflags.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The deploy.&lt;/strong&gt; A manual pipeline run with two parameters: which version, and which services -
&lt;code&gt;server&lt;/code&gt;, &lt;code&gt;worker&lt;/code&gt;, or &lt;code&gt;all&lt;/code&gt;. Those three values exist because the &lt;code&gt;daemons:&lt;/code&gt; list has two
entries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The runtime.&lt;/strong&gt; Resource declarations turn into environment-variable names by one convention,
&lt;code&gt;&amp;lt;TYPE&amp;gt;_&amp;lt;NAME&amp;gt;_&amp;lt;FIELD&amp;gt;&lt;/code&gt;: &lt;code&gt;postgres:main&lt;/code&gt; becomes &lt;code&gt;POSTGRES_MAIN_DSN&lt;/code&gt;, &lt;code&gt;grpc:api&lt;/code&gt; becomes
&lt;code&gt;GRPC_API_PORT&lt;/code&gt;, &lt;code&gt;messaging:main&lt;/code&gt; becomes &lt;code&gt;MESSAGING_MAIN_RABBITMQ_URL&lt;/code&gt;.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="s"&gt;┌────────────────────────────────────────────────────────┐&lt;/span&gt;
  &lt;span class="s"&gt;│ the manifest                                           │&lt;/span&gt;
  &lt;span class="s"&gt;│   daemons&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;                                             &lt;span class="s"&gt;│&lt;/span&gt;
  &lt;span class="s"&gt;│     - name&lt;/span&gt;&lt;span class="na"&gt;: server    handlers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;grpc&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;                 &lt;span class="s"&gt;│&lt;/span&gt;
  &lt;span class="na"&gt;│     - name: worker    handlers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;scheduler&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;            &lt;span class="s"&gt;│&lt;/span&gt;
  &lt;span class="s"&gt;└────────────────────────────────────────────────────────┘&lt;/span&gt;
     &lt;span class="s"&gt;│&lt;/span&gt;
     &lt;span class="s"&gt;├──▶ the build     one Dockerfile, --build-arg BINARY=,&lt;/span&gt;
     &lt;span class="s"&gt;│                  one image per daemon, pushed on a tag&lt;/span&gt;
     &lt;span class="s"&gt;├──▶ the deploy    a manual pipeline run&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;version, and&lt;/span&gt;
     &lt;span class="s"&gt;│                  server | worker | all&lt;/span&gt;
     &lt;span class="s"&gt;└──▶ the runtime   POSTGRES_MAIN_DSN · GRPC_API_PORT ·&lt;/span&gt;
                        &lt;span class="s"&gt;MESSAGING_MAIN_RABBITMQ_URL&lt;/span&gt;

  &lt;span class="na"&gt;Resource names follow one convention&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;TYPE&amp;gt;_&amp;lt;NAME&amp;gt;_&amp;lt;FIELD&amp;gt;.&lt;/span&gt;
  &lt;span class="s"&gt;A binary nobody declared is a binary nobody ships.&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%2F0lj8ll50mgkf2yh7zwll.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%2F0lj8ll50mgkf2yh7zwll.png" alt="A manifest declaring two daemons, and the three consumers of that declaration below it: the build producing two images from one Dockerfile, the deploy pipeline offering server, worker or all, and the runtime deriving environment-variable names by convention" width="800" height="518"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The composition of that list is the service owner's decision and nobody else's. A new &lt;code&gt;cmd/*&lt;/code&gt; is&lt;br&gt;
not something an implementer adds because a new kind of background work showed up - new background&lt;br&gt;
work goes into an existing daemon. That rule is written into every control prompt in this repo, and&lt;br&gt;
it exists because the alternative is a binary that builds fine and has no deploy target: the&lt;br&gt;
pipeline only knows &lt;code&gt;server&lt;/code&gt;, &lt;code&gt;worker&lt;/code&gt; and &lt;code&gt;all&lt;/code&gt;, and a third image nobody declared is an image&lt;br&gt;
nobody ships.&lt;/p&gt;

&lt;p&gt;The cost of that rule, stated plainly: &lt;strong&gt;&lt;code&gt;worker&lt;/code&gt; becomes a mixed bag.&lt;/strong&gt; The outbound relay, audit&lt;br&gt;
partition retention, derived calculations and the gauge observer all live in one process. They have&lt;br&gt;
different failure modes and different appetites, and they scale together whether that suits them or&lt;br&gt;
not. Splitting them is a decision with a manifest change, a new image and a new deploy target&lt;br&gt;
behind it - not a refactor someone does on a Friday.&lt;/p&gt;
&lt;h2&gt;
  
  
  Different units, the same shape
&lt;/h2&gt;

&lt;p&gt;The two binaries drive different things. The server drives incoming calls; the worker drives&lt;br&gt;
messages and schedules. The packages that hold them sit in the same layout, one folder per daemon:&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/&amp;lt;domain&amp;gt;     assembly of a domain's Calls
internal/daemon/worker/&amp;lt;...&amp;gt;        relay, retention, derived, observe
internal/consumer/&amp;lt;domain&amp;gt;          subscriptions
internal/publisher/&amp;lt;domain&amp;gt;         outbound publication
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the server side, a domain's assembly is four files, and none of them contains a handler body. The&lt;br&gt;
RPC flow lives in a generic method with a single execution method, &lt;code&gt;Handle(ctx, req)&lt;/code&gt; - there are&lt;br&gt;
twenty of those generic RPCs covering &lt;code&gt;create&lt;/code&gt;, &lt;code&gt;get&lt;/code&gt;, &lt;code&gt;list_revisions&lt;/code&gt;, &lt;code&gt;search&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, &lt;code&gt;walk&lt;/code&gt;&lt;br&gt;
and the rest. What the domain package holds is a type with a &lt;code&gt;deps&lt;/code&gt; field, a &lt;code&gt;NewXxx(deps)&lt;/code&gt;&lt;br&gt;
constructor, and a &lt;code&gt;Call()&lt;/code&gt; method returning the lambda the generic flow will invoke.&lt;/p&gt;

&lt;p&gt;On the worker side, the unit is a consumer bound to a queue, or a job on a schedule. Different&lt;br&gt;
trigger, same skeleton: something is constructed with its dependencies, and then handed to a runtime&lt;br&gt;
that will drive it.&lt;/p&gt;

&lt;p&gt;That symmetry is the reason registration can have one shape across both binaries. It is also why the&lt;br&gt;
question "where is this declared" has one answer instead of two. In a tree of &lt;strong&gt;2733 non-generated&lt;br&gt;
&lt;code&gt;.go&lt;/code&gt; files across 252 packages, averaging 39 lines each&lt;/strong&gt;, the assembly point is the only place&lt;br&gt;
where the whole picture of what runs is visible at all. Nobody reads 252 packages to find out what&lt;br&gt;
the worker does; they read one folder.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ┌─────────────────────────────────┐ ┌─────────────────────────────────┐
  │ server                          │ │ worker                          │
  │ one unit per domain             │ │ one unit per queue or schedule  │
  │                                 │ │                                 │
  │ internal/daemon/server/         │ │ internal/daemon/worker/&amp;lt;...&amp;gt;    │
  │ &amp;lt;domain&amp;gt;                        │ │ relay · retention · derived ·   │
  │ four files of assembly,         │ │ observe                         │
  │ no handler body                 │ │ internal/consumer/&amp;lt;domain&amp;gt;      │
  │ Handle(ctx, req) — twenty       │ │ internal/publisher/&amp;lt;domain&amp;gt;     │
  │ generic RPCs                    │ │ bound to a queue, or run on     │
  │ NewXxx(deps) -&amp;gt; Call()          │ │ a schedule                      │
  └─────────────────────────────────┘ └─────────────────────────────────┘
                   │                                   │
                   ▼                                   ▼
  ┌─────────────────────────────────────────────────────────────────────┐
  │ the same three steps on both sides                                  │
  │ construct with deps  ──▶  register in the set  ──▶  the runtime     │
  │ drives it                                                           │
  └─────────────────────────────────────────────────────────────────────┘

  2733 non-generated files in 252 packages — the assembly point
  is the only place the whole picture is visible.
&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%2F3csbl3qebaqymzkbpx47.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%2F3csbl3qebaqymzkbpx47.png" alt="Two columns under one manifest: the server column showing a domain assembly of four files with the generic RPC flow holding the handler body, and the worker column showing consumers and scheduled jobs, both reduced to the same three steps - construct with dependencies, register, let the runtime drive it" width="800" height="499"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Four shapes registration can take
&lt;/h2&gt;

&lt;p&gt;Here are the four I've actually weighed, with the form each one takes in Go, what it buys, and what&lt;br&gt;
it bills.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. An explicit list at the assembly point
&lt;/h3&gt;

&lt;p&gt;Everything the binary runs is written out by hand, in one place, in order.&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;func&lt;/span&gt; &lt;span class="n"&gt;serverUnits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="n"&gt;Deps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;Unit&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;entity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewCalls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&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;NewCalls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewCalls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&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;&lt;strong&gt;What it buys.&lt;/strong&gt; The order is visible as source order. Nothing happens by itself - if a name isn't&lt;br&gt;
in that slice, it isn't running, and you can see that without a debugger. When something starts in&lt;br&gt;
the wrong sequence, the fix is moving a line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it costs.&lt;/strong&gt; The list is edited by hand, and there are two of them - one per daemon. A unit&lt;br&gt;
that belongs in both gets added to both, or gets added to one and quietly forgotten in the other.&lt;br&gt;
This is the shape whose failure mode I'll come back to below, because it's the one that bit me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it fits.&lt;/strong&gt; Small sets, and any set where the order genuinely matters and is not derivable.&lt;br&gt;
A list of five is easier to read than any machinery for generating a list of five.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Registration through an interface
&lt;/h3&gt;

&lt;p&gt;Every unit implements a common registration contract, and the assembly point iterates a set.&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;Unit&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;Register&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;rt&lt;/span&gt; &lt;span class="n"&gt;Runtime&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;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&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;units&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;u&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Register&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;rt&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What it buys.&lt;/strong&gt; Adding a unit means writing one implementation, and every unit has the same shape -&lt;br&gt;
which matters a lot when the tree already carries generic cores for domain assembly (&lt;code&gt;Builder[Repo,&lt;br&gt;
Mgr]&lt;/code&gt;, &lt;code&gt;Domain[Repo, Mgr]&lt;/code&gt;, &lt;code&gt;HandlerInput[Repo, Mgr]&lt;/code&gt;). A uniform contract means the assembly loop is&lt;br&gt;
written once and never touched again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it costs.&lt;/strong&gt; Somebody still builds &lt;code&gt;units&lt;/code&gt;. The interface moves the uniformity problem; it does&lt;br&gt;
not remove the list. And the initialization order becomes implicit - it's now the order of a slice&lt;br&gt;
someone constructed elsewhere, which reads like a detail and behaves like a dependency graph.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it fits.&lt;/strong&gt; When the set is large enough that per-unit boilerplate is the dominant cost, and&lt;br&gt;
when the units genuinely are peers - no unit needs another one to be up first.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Self-declaration at import time
&lt;/h3&gt;

&lt;p&gt;Each package registers itself in an &lt;code&gt;init()&lt;/code&gt;, and the binary blank-imports the packages.&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;func&lt;/span&gt; &lt;span class="n"&gt;init&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"entity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;newEntityUnit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="s"&gt;"internal/consumer/entity"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What it buys.&lt;/strong&gt; Zero edits at the assembly point. The package that owns the behaviour also owns the&lt;br&gt;
fact that it runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it costs.&lt;/strong&gt; I'll say this one as a risk rather than a convenience: &lt;strong&gt;presence and order both&lt;br&gt;
become properties of the import graph.&lt;/strong&gt; Whether a unit runs depends on whether some file, possibly&lt;br&gt;
in a third package, still has a blank import that a tidy-up tool has no reason to keep. Order depends&lt;br&gt;
on Go's package initialization order, which is a real rule but not one anybody reads a diff against.&lt;br&gt;
And the failure is a runtime failure - the binary starts, the registry is short one entry, and&lt;br&gt;
nothing anywhere says so. Compare that with shape 1, where the same failure is a missing line in a&lt;br&gt;
slice you can read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When it fits.&lt;/strong&gt; Plugin-shaped sets where the members are genuinely independent and the cost of one&lt;br&gt;
missing member is low - codec registries, format handlers. Not the set that decides whether cache&lt;br&gt;
invalidation happens.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Declared in the manifest, assembled from the declaration
&lt;/h3&gt;

&lt;p&gt;The unit is declared once, declaratively, and the wiring code is produced from that declaration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;daemons&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;worker&lt;/span&gt;
    &lt;span class="na"&gt;handlers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;scheduler&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;consumers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;entity_invalidation&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What it buys.&lt;/strong&gt; Declaration and operation become the same fact. That's not hypothetical here: the&lt;br&gt;
manifest already produces environment-variable names, deploy targets and the resource wiring, and&lt;br&gt;
two machine-written snapshots already exist as checked-in artifacts - a catalog of &lt;strong&gt;59 environment&lt;br&gt;
variables&lt;/strong&gt; (45 from the platform, 14 from the service config, each service variable carrying the&lt;br&gt;
file and line it's defined in), and a snapshot of &lt;strong&gt;67 metric records&lt;/strong&gt;. Both fail CI on drift. A&lt;br&gt;
consumer declared in the same file would inherit the same property.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it costs.&lt;/strong&gt; You need a generator or reflection, and manifest-versus-code drift becomes a new&lt;br&gt;
class of bug - one that is invisible until you build the checker that looks for it. There's a&lt;br&gt;
specific trap in that checker, learned the expensive way on the metrics snapshot: the tool has to be&lt;br&gt;
built &lt;strong&gt;at the platform version pinned in the service's module file&lt;/strong&gt;, resolved from the modules and&lt;br&gt;
compiled at that ref. Build it from the latest platform instead and it compares your snapshot against&lt;br&gt;
a catalog you don't have, and the failure looks both confusing and correct.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest status.&lt;/strong&gt; The generator that would produce daemon assembly this way is &lt;strong&gt;designed and not&lt;br&gt;
written&lt;/strong&gt;: a twelve-iteration specification, with domain assembly listed as fully generated - all four&lt;br&gt;
files, no hand-written bodies - and an explicit ban on starting it without a direct instruction. I'm&lt;br&gt;
describing a shape I've specified and costed, not one I've run.&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 registration can take
  ──────────────────────────────────────────────────────────────────
  1  an explicit list at the assembly point            in use
     buys   order is source order; nothing runs by itself
     bills  two lists edited by hand — one per daemon
  ──────────────────────────────────────────────────────────────────
  2  a common Register interface                       uniform
     buys   one implementation per unit, one shape for all
     bills  someone still builds the set; order goes implicit
  ──────────────────────────────────────────────────────────────────
  3  init() at import time                             a risk
     buys   zero edits at the assembly point
     bills  presence and order follow the import graph;
            the failure is at runtime
  ──────────────────────────────────────────────────────────────────
  4  declared in the manifest, generated from it       not written
     buys   the declaration and the operation are one fact
     bills  needs a generator; manifest-vs-code drift is a new
            bug class
&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%2Fs1fdqdnoappi6i30bjvp.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%2Fs1fdqdnoappi6i30bjvp.png" alt="Four registration shapes as four rows: an explicit list at the assembly point, a common Register interface, init-time self-declaration, and manifest-driven generation; each row carrying what it buys and what it bills, with the init-time row marked as a risk and the manifest row marked as designed but not written" width="800" height="551"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Initialization order is not a detail
&lt;/h2&gt;

&lt;p&gt;Whatever shape you pick, something decides what comes up before what. The dependency chain in this&lt;br&gt;
service is short and completely mundane:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pool  →  tx registry  →  repository / manager  →  domain assembly  →  handler | consumer | job
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two wrinkles in it are worth naming, because both are places where "it happens to work" and "it is&lt;br&gt;
specified to work" look identical from the outside.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The migrations resource resolves its DSN from the pool's variable.&lt;/strong&gt; It has no variable of its own.&lt;br&gt;
So the pool declaration has to be real before migrations mean anything - and if you were tempted to&lt;br&gt;
give migrations their own DSN "just to be explicit", you'd have created two sources of truth for one&lt;br&gt;
connection string, which is the same failure as a duplicated metric name in a different costume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The scheduler resource holds the cron expressions.&lt;/strong&gt; The service reads no &lt;code&gt;CRON_*&lt;/code&gt; variables of its&lt;br&gt;
own. A job's schedule is therefore a property of a declared resource, not of the job's code - which&lt;br&gt;
is what you want, and which is also why the job cannot start before the resource it's scheduled by.&lt;/p&gt;

&lt;p&gt;Where this went wrong for real, found during an audit of my own service against the platform: a&lt;br&gt;
&lt;strong&gt;scheduler initialized lazily through &lt;code&gt;sync.Once&lt;/code&gt; with captured variables&lt;/strong&gt;, instead of using the&lt;br&gt;
platform's worker init-state store. It worked. It also meant the initialization moment was whenever&lt;br&gt;
the first tick happened to fire, which is not a moment you can point at, order against, or test. The&lt;br&gt;
platform ships &lt;code&gt;worker&lt;/code&gt; and &lt;code&gt;worker/initstate&lt;/code&gt; precisely so that "this worker is initialized" is a&lt;br&gt;
state you can read rather than an event you hope already happened.&lt;/p&gt;

&lt;p&gt;Two more ordering rules that come from operations rather than from the compiler:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One publisher per gauge.&lt;/strong&gt; The outbound-queue and audit gauges are published by &lt;code&gt;worker&lt;/code&gt; only.
&lt;code&gt;server&lt;/code&gt; does not publish them, even though the code that could is in the same module. Two
processes publishing one gauge produce a graph that jumps between two values depending on which
replica was scraped last - it reads as a real oscillation, and it costs an afternoon every time
somebody new sees it. That is a &lt;strong&gt;registration&lt;/strong&gt; decision: same code, registered in one daemon and
not the other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One drainer for the outbound queue.&lt;/strong&gt; The relay reads rows strictly in &lt;code&gt;id&lt;/code&gt; order and
&lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; is deliberately not used, because parallel draining breaks event order,
and event order is the thing a consumer relies on to rebuild a correct replica. So the relay is
registered once, in one daemon, and "let's run two for throughput" is a data-model change wearing a
performance costume.
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  pool                     declared as postgres:main; migrations
    │                      resolve their DSN from the pool variable
    ▼
  tx registry              a decorator on mutations; no tx in any
    │                      signature
    ▼
  repository | manager     the manager calls the repository,
    │                      never copies SQL
    ▼
  domain assembly          NewXxx(deps) and Call(); the handler
    │                      body is not in the domain package
    ▼
  handler | consumer | job the scheduler resource holds the cron;
                           the service reads no CRON_* variables

  ┌───────────────────────────────────┐ ┌───────────────────────────────────┐
  │ one publisher per gauge           │ │ one drainer for the outbox        │
  │                                   │ │                                   │
  │ worker publishes them;            │ │ rows strictly in id order;        │
  │ server does not                   │ │ no SKIP LOCKED                    │
  └───────────────────────────────────┘ └───────────────────────────────────┘

  Same code, registered in one daemon and not in the other —
  a wiring decision, not a code decision.
&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%2Fsh6ae0fpelgckczz37u0.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%2Fsh6ae0fpelgckczz37u0.png" alt="The initialization chain from pool to tx registry to repository and manager to domain assembly to the three drivers, each step carrying a side note - migrations resolve their DSN from the pool variable, transactions are a decorator, the manager calls the repository, the handler body is not in the domain package, and the scheduler resource holds the cron - and two closing rules: the gauges and the outbound relay are each registered in exactly one daemon" width="799" height="616"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The error you want at build time
&lt;/h2&gt;

&lt;p&gt;Here is the one that made me care about this topic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A cache-invalidation consumer, fully written, registered in no daemon at all.&lt;/strong&gt; The package&lt;br&gt;
compiled. Its unit tests were green - the handler logic was tested directly, and it was correct. The&lt;br&gt;
subscription was simply never added to either daemon's set, so in production cache invalidation did&lt;br&gt;
not happen. Not intermittently. At all.&lt;/p&gt;

&lt;p&gt;Nothing in the toolchain had an opinion about this, and it's worth being precise about why:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The compiler is satisfied: the package is imported by its own tests, so it isn't even dead code.&lt;/li&gt;
&lt;li&gt;Unit tests are satisfied: they construct the unit directly and exercise it.&lt;/li&gt;
&lt;li&gt;Coverage is satisfied: the package is covered. Coverage measures whether lines ran under test, not
whether they run in production.&lt;/li&gt;
&lt;li&gt;The metrics were satisfied too, in the worst way. There is no metric for a subscription that
doesn't exist. Broker delivery metrics are per-queue and per-handler, and a handler that never
registered has no queue and no series - so the dashboard shows nothing, which is exactly what a
healthy idle consumer also shows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mirror image of that bug is just as real: &lt;strong&gt;registered but not declared.&lt;/strong&gt; Background work added&lt;br&gt;
to a daemon that the manifest doesn't describe. It runs on your machine and in tests. It has no&lt;br&gt;
deploy target if it needed its own binary, no environment-variable names for the resource it wanted,&lt;br&gt;
and no row in anything that would tell an operator it exists. Where the first bug is silent in&lt;br&gt;
production, this one is silent in the paperwork - and it surfaces at 3am when someone needs to know&lt;br&gt;
what that process is.&lt;/p&gt;

&lt;p&gt;Both bugs have the same anatomy: &lt;strong&gt;the declaration and the reality are two facts, kept in two places,&lt;br&gt;
with nothing comparing them.&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;  ┌─────────────────────────────────┐ ┌─────────────────────────────────┐
  │ written, registered nowhere     │ │ registered, never declared      │
  │                                 │ │                                 │
  │ the package compiles            │ │ it runs locally and in tests    │
  │ its unit tests are green        │ │ no deploy target for it         │
  │ coverage counts the lines       │ │ no environment-variable names   │
  │ no subscription -&amp;gt; no queue     │ │ nothing an operator can read    │
  │ -&amp;gt; no series                    │ │                                 │
  │                                 │ │                                 │
  │ invalidation never ran in       │ │ silent in the paperwork,        │
  │ production                      │ │ not in the logs                 │
  └─────────────────────────────────┘ └─────────────────────────────────┘

  ┌─────────────────────────────────────────────────────────────────────┐
  │ the check that closes both                                          │
  │ every registration type appears in exactly one daemon's set         │
  └─────────────────────────────────────────────────────────────────────┘

  Not "is tested", not "is imported" — is in a set that
  a daemon iterates.
&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%2Fd0847bdkhg0187tcqnng.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%2Fd0847bdkhg0187tcqnng.png" alt="Two failure modes side by side: on the left a consumer that is written, compiled, unit-tested and covered but registered in no daemon, so invalidation does not run and no metric exists to show it; on the right work registered in a daemon the manifest does not declare, with no deploy target, no environment-variable names and no operator-visible record" width="800" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Reminder, rule, check
&lt;/h2&gt;

&lt;p&gt;The ladder I use for anything in this class is three rungs, and only the third one holds weight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A reminder&lt;/strong&gt; lives in one conversation and dies with it. Telling a person - or an agent - "don't&lt;br&gt;
forget to register the consumer" works exactly once, for exactly that consumer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A rule&lt;/strong&gt; in a written instruction works for as long as people read the instruction. That's longer&lt;br&gt;
than a reminder and still not a guarantee; the failure mode is quiet and looks like nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A check&lt;/strong&gt; is the only rung that holds: a linter, a forbidding test, a structural test, a CI drift&lt;br&gt;
check, a blocking hook. In this repository the checks that already exist and already fail builds are&lt;br&gt;
these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the environment-variable catalog is regenerated in &lt;code&gt;--check&lt;/code&gt; mode on any change to a &lt;code&gt;.go&lt;/code&gt; file,
the manifest, the module files or the snapshot itself - drift fails the build;&lt;/li&gt;
&lt;li&gt;the metrics snapshot has an identically shaped check of its own;&lt;/li&gt;
&lt;li&gt;a forbidding test asserts that a particular row-reading construct appears in &lt;strong&gt;exactly one
package&lt;/strong&gt; - it was written after an audit found &lt;strong&gt;44 copies of four row-reading forms&lt;/strong&gt; in the tree
and consolidated them onto one generic core;&lt;/li&gt;
&lt;li&gt;a coverage ratchet that can only go up. Fair disclosure: the threshold currently sits at &lt;code&gt;0&lt;/code&gt; while
actual coverage is 86.7% - the mechanism is wired, the bar hasn't been raised yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The registration check that follows from this article's bug is the same shape, and it's cheap: assert&lt;br&gt;
that every type implementing the registration contract appears in exactly one daemon's set. Not "is&lt;br&gt;
tested". Not "is imported". &lt;strong&gt;Is in a set that a daemon iterates.&lt;/strong&gt; With shape 4, the same assertion&lt;br&gt;
becomes a comparison between the manifest and the generated assembly, which is why the drift checker&lt;br&gt;
is the interesting part of that shape and the generator is the boring part.&lt;/p&gt;

&lt;p&gt;Notice what all four of those checks have in common: none of them are about code being correct. They&lt;br&gt;
are about &lt;strong&gt;a declaration and a reality being the same object.&lt;/strong&gt; That is the entire subject of this&lt;br&gt;
article, and it turns out to be the entire subject of the manifest too.&lt;/p&gt;

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

&lt;p&gt;The bill for the way I've landed, without the sales pitch attached.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The assembly point is a file people edit by hand.&lt;/strong&gt; Two of them, one per daemon. Adding a unit&lt;br&gt;
means touching a list that is not where the interesting code is, and the diff looks trivial, which is&lt;br&gt;
exactly the kind of diff a reviewer skims. That's the shape of the bug above; keeping the shape means&lt;br&gt;
keeping the exposure and paying for a check instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Uniformity has a floor.&lt;/strong&gt; Making every unit implement the same contract means the odd unit - the&lt;br&gt;
one that genuinely needs to come up before another - either doesn't fit the contract, or fits it by&lt;br&gt;
being placed carefully in a slice, which is order-as-a-side-effect and reads as an accident to&lt;br&gt;
anyone who wasn't there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A rule that forbids new binaries pushes work into &lt;code&gt;worker&lt;/code&gt;.&lt;/strong&gt; I described that above and I'll count&lt;br&gt;
it here as a cost, not a design win: one process is doing relay, retention, derived calculations and&lt;br&gt;
gauge observation, and the reason they share a process is a governance rule about who gets to add a&lt;br&gt;
&lt;code&gt;cmd/*&lt;/code&gt;, not a claim that they belong together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A check is another artifact to keep honest.&lt;/strong&gt; The env catalog check and the metrics snapshot check&lt;br&gt;
both fail builds, and some of those failures are real drift while others are somebody regenerating on&lt;br&gt;
the wrong platform version. They pay for themselves. They are not free, and pretending otherwise is&lt;br&gt;
how a team ends up with a check nobody trusts and everybody re-runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I picked
&lt;/h2&gt;

&lt;p&gt;An explicit list at the assembly point, with every unit built to the same constructor-and-&lt;code&gt;Call()&lt;/code&gt;&lt;br&gt;
shape so the list stays boring, and a check that compares the list against what exists - moving&lt;br&gt;
toward shape 4 when the generator is actually written, because a declaration that is already&lt;br&gt;
producing deploy targets and variable names should be producing this too.&lt;/p&gt;

&lt;p&gt;That is a choice with a cost I've just spent four paragraphs describing, not a verdict on the other&lt;br&gt;
three. Shape 2 is better than what I have if your unit count is large. Shape 3 is right for genuinely&lt;br&gt;
plugin-shaped sets and wrong for anything whose absence is silent. Shape 4 is where I think this&lt;br&gt;
ends up, and I'd rather say that than pretend I'm already there.&lt;/p&gt;

&lt;p&gt;The one thing I'd argue for regardless of which shape you pick: &lt;strong&gt;whatever declares that a unit runs&lt;br&gt;
should be comparable, by a machine, against what actually runs.&lt;/strong&gt; Every failure in this article -&lt;br&gt;
the unregistered consumer, the undeclared job, the two-source DSN, the lazily initialized scheduler -&lt;br&gt;
is one fact stored twice with nothing checking the copies against each other.&lt;/p&gt;




&lt;p&gt;So - how is this solved where you are? I'm specifically interested in two things: whether you use&lt;br&gt;
&lt;code&gt;init()&lt;/code&gt;-time registration for anything load-bearing and how that's gone, and whether you have a&lt;br&gt;
check that a written unit is actually wired into a running process. If you generate your wiring from&lt;br&gt;
a declaration, I'd like to know what your drift checker catches most often - I suspect it isn't what&lt;br&gt;
you built it for.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Operations out of the box.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Next in this series: what a service owes the rest of the fleet once it exists - the card that gets&lt;br&gt;
assembled from the same declaration, and who owns it.&lt;/p&gt;

</description>
      <category>go</category>
      <category>architecture</category>
      <category>microservices</category>
      <category>devops</category>
    </item>
    <item>
      <title>Generation and Operations Are One Thing</title>
      <dc:creator>Anton Brilliantov</dc:creator>
      <pubDate>Mon, 14 Sep 2026 19:41:56 +0000</pubDate>
      <link>https://dev.to/anton_brilliantov/generation-and-operations-are-one-thing-ei9</link>
      <guid>https://dev.to/anton_brilliantov/generation-and-operations-are-one-thing-ei9</guid>
      <description>&lt;p&gt;&lt;em&gt;Add a service and you get code, observability and deploy. If the second half is done by hand after the first, the description of the system and the system itself drift apart — and you always find out later than it happened.&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 last part of a block about what a service gets for free the moment it exists. Notes and code: &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;Here's the thought I want to leave the block on. It may be useful to you, or you may look at it from a completely different angle — both are interesting to me. As always: this is one codebase, my constraints, my price. Not a recommendation for yours.&lt;/p&gt;




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

&lt;p&gt;Generation and operations are not two neighbouring processes. They are one.&lt;/p&gt;

&lt;p&gt;Add a service and you get the code, the observability and the deploy — in one motion, from one declaration. The alternative is not "generate the code first, wire up operations after". The alternative is that the environment-variable catalogue, the metrics snapshot and the pipelines get written by hand, some time after the code, by someone who remembers most of it.&lt;/p&gt;

&lt;p&gt;And then the platform's description of the system stops matching the system. Not loudly. Quietly, one file at a time, and the discovery happens much later than the divergence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways it drifts
&lt;/h2&gt;

&lt;p&gt;Every drift I've hit looks like one of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A variable appeared, and the catalogue doesn't have it.&lt;/strong&gt; The code reads it. The document that is supposed to list every variable this service needs does not mention it. The service starts on your machine and doesn't start in an environment nobody re-read the document for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A metric was deleted, and the dashboard still draws it.&lt;/strong&gt; The panel is there, the line is flat, and flat reads as &lt;em&gt;healthy&lt;/em&gt; until someone asks why it has been exactly zero since spring.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A daemon was declared, and there's nothing to deploy it.&lt;/strong&gt; The manifest says there are two binaries. The build job knows about one. The new one exists in the repository and nowhere else.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;code                        description of the system
─────────────────────       ─────────────────────────────
env var added          ✗    catalogue missing it
metric removed         ✗    dashboard still draws it
daemon declared        ✗    nothing to deploy it

              ✗ = the gap nobody is told about
&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%2Fjefwij3r8u8eygmb0oim.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%2Fjefwij3r8u8eygmb0oim.png" alt="Three drift pairs: an env var added but missing from the catalogue, a metric removed but still drawn, a daemon declared with nothing deploying it" width="800" height="381"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;They have the same shape. In all three the &lt;strong&gt;code is right and the description of the system is wrong&lt;/strong&gt; — and in all three you learn about it from production, not from the repository.&lt;/p&gt;

&lt;p&gt;That last part is what makes this worth engineering around. A wrong description that fails loudly is a bug. A wrong description that fails silently is a habit: after the second time, nobody trusts the document, and then there is no point maintaining it at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What holds it together, part one: a single declaration
&lt;/h2&gt;

&lt;p&gt;There is exactly one place where a service says what it is — its manifest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;service&amp;gt;&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.1.0&lt;/span&gt;
  &lt;span class="na"&gt;daemons&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;server&lt;/span&gt;
      &lt;span class="na"&gt;handlers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;grpc&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;worker&lt;/span&gt;
      &lt;span class="na"&gt;handlers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;scheduler&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;infra&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
      &lt;span class="na"&gt;migrations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;tx&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
      &lt;span class="na"&gt;pool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
  &lt;span class="na"&gt;grpc&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;
      &lt;span class="na"&gt;daemons&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;scheduler&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
      &lt;span class="na"&gt;daemons&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;worker&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;messaging&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That file is short, and almost everything operational is downstream of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Environment variable names are derived, not chosen.&lt;/strong&gt; The convention is &lt;code&gt;&amp;lt;TYPE&amp;gt;_&amp;lt;NAME&amp;gt;_&amp;lt;FIELD&amp;gt;&lt;/code&gt;, applied mechanically:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Declared in the manifest&lt;/th&gt;
&lt;th&gt;Variable the platform reads&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;postgres: main&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POSTGRES_MAIN_DSN&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;grpc: api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GRPC_API_PORT&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;messaging: main&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MESSAGING_MAIN_RABBITMQ_URL&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Once you know the rule, a double-looking name like &lt;code&gt;OUTBOX_EVENT_OUTBOX_BATCH_SIZE&lt;/code&gt; stops being a typo and becomes readable: type &lt;code&gt;OUTBOX&lt;/code&gt;, resource named &lt;code&gt;event_outbox&lt;/code&gt;, field &lt;code&gt;BATCH_SIZE&lt;/code&gt;. The scheduler resource carries the cron expression, so the service never reads a &lt;code&gt;CRON_*&lt;/code&gt; variable of its own. The migration resource resolves its DSN from the pool's variable and has none of its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pipelines are downstream of the same file.&lt;/strong&gt; The build job builds per declared daemon out of a single &lt;code&gt;Dockerfile&lt;/code&gt; with &lt;code&gt;--build-arg BINARY=&lt;/code&gt;; the deploy job takes a version and which of the declared services to move. Nobody maintains a second list of binaries anywhere.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    ┌──────────────────────┐
                    │  the service manifest │
                    └───────────┬───────────┘
             ┌──────────────────┼──────────────────┐
             ▼                  ▼                  ▼
      ┌────────────┐   ┌─────────────────┐   ┌────────────┐
      │ env names  │   │ metrics snapshot│   │ pipelines  │
      └─────┬──────┘   └────────┬────────┘   └─────┬──────┘
            ▼                   ▼                  ▼
      ┌────────────┐   ┌─────────────────┐   ┌──────────────────┐
      │  --check   │   │ snapshot check  │   │ build by declared│
      │            │   │                 │   │ daemons          │
      └────────────┘   └─────────────────┘   └──────────────────┘
        drift check       drift check           the build itself
&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%2Fyw09itp536jahpnkj1fm.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%2Fyw09itp536jahpnkj1fm.png" alt="One manifest fanning out into env names, metrics snapshot and pipelines, each with its own drift check underneath" width="800" height="419"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One declaration, one source. The point isn't elegance — it's that there is no second place for a human to forget.&lt;/p&gt;

&lt;h2&gt;
  
  
  What holds it together, part two: drift checks
&lt;/h2&gt;

&lt;p&gt;A single source only stays single if something enforces it. So the derived artefacts are generated, committed, and then guarded.&lt;/p&gt;

&lt;h3&gt;
  
  
  The environment-variable catalogue
&lt;/h3&gt;

&lt;p&gt;The catalogue is a generated file in the repository — every variable this service can read, where it came from, whether it's required, whether it's secret:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# AUTO-GENERATED by &amp;lt;snapshot tool&amp;gt;. DO NOT EDIT.&lt;/span&gt;
&lt;span class="na"&gt;schema_version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;service&amp;gt;&lt;/span&gt;
&lt;span class="na"&gt;generated_at&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-08-16T01:24:46Z&lt;/span&gt;
&lt;span class="na"&gt;vars&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;APP_ENV&lt;/span&gt;
    &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;platform&lt;/span&gt;
    &lt;span class="na"&gt;catalog&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;platform package&amp;gt;&lt;/span&gt;
    &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;help&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prod/staging/dev label exposed in platform_info&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AUDIT_RECORDS_RETENTION_MONTHS&lt;/span&gt;
    &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_config&lt;/span&gt;
    &lt;span class="na"&gt;defined_in&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;internal/daemon/worker/retention/config.go:16&lt;/span&gt;
    &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;59 variables&lt;/strong&gt; — 45 declared by the platform, 14 by the service's own configuration. A service variable records &lt;code&gt;defined_in&lt;/code&gt; with the file &lt;em&gt;and line number&lt;/em&gt;; a platform variable records the catalogue package that owns it. So the answer to "where does this come from" is in the file, not in someone's memory.&lt;/p&gt;

&lt;p&gt;Drift is a build failure. CI runs the same generator in &lt;code&gt;--check&lt;/code&gt; mode on a change to &lt;strong&gt;any &lt;code&gt;.go&lt;/code&gt; file, the manifest, the modules file, or the snapshot itself&lt;/strong&gt;. If the regenerated catalogue differs from the committed one, the pull request is red.&lt;/p&gt;

&lt;p&gt;One detail in that check took a while to get right, and it's the difference between the check working and the check lying: &lt;strong&gt;the generator is built at the same platform version the service's modules file pins.&lt;/strong&gt; The version is resolved from the modules file, the platform is fetched at that ref, the binary is built from it. Skip that, and CI compares your snapshot against somebody else's version of the catalogue — and reports a difference that is not yours.&lt;/p&gt;

&lt;p&gt;Locally there's a script that does the same regeneration and &lt;strong&gt;reverts the file if the only change is the generation timestamp&lt;/strong&gt;. Timestamp-only noise in history is worse than no history.&lt;/p&gt;

&lt;p&gt;And an honest gap in this one: &lt;strong&gt;resource variables don't make it into the snapshot.&lt;/strong&gt; The platform builds their names by concatenation at runtime, while the snapshot reads configuration declarations. So resource names are documented in a table instead, and the snapshot's test asserts that the platform names are present and that forbidden names are &lt;em&gt;absent&lt;/em&gt; — for example, the names of a broker this service does not use.&lt;/p&gt;

&lt;h3&gt;
  
  
  The metrics snapshot
&lt;/h3&gt;

&lt;p&gt;The second machine-written artefact: name, type, help, labels, histogram buckets, source (&lt;code&gt;platform&lt;/code&gt; or &lt;code&gt;service&lt;/code&gt;), and where it's declared. &lt;strong&gt;67 entries&lt;/strong&gt;, including a &lt;code&gt;&amp;lt;dynamic&amp;gt;&lt;/code&gt; record standing in for the dynamic-metric factory.&lt;/p&gt;

&lt;p&gt;Drift in it fails CI too, by a separate check built the same way.&lt;/p&gt;

&lt;p&gt;That snapshot is also where "a metric was deleted and the dashboard still draws it" gets caught: the deletion shows up as a diff in a reviewable file rather than as a flat line on a panel nobody is looking at.&lt;/p&gt;

&lt;h3&gt;
  
  
  The generated skeleton — specified, not running
&lt;/h3&gt;

&lt;p&gt;The third check is designed and not built, so let me be precise about it.&lt;/p&gt;

&lt;p&gt;The plan is that every generated file carries the &lt;code&gt;Code generated by … DO NOT EDIT.&lt;/code&gt; header — that header is a contract, not decoration — and that a comparison mode returns a non-zero exit code when what the generator produces differs from what's committed. Editing a generated file by hand is forbidden by the same rule.&lt;/p&gt;

&lt;p&gt;That work exists as a &lt;strong&gt;12-iteration specification&lt;/strong&gt; and a standing instruction not to start it without an explicit go-ahead. It is not a tool anyone can run today.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Watches&lt;/th&gt;
&lt;th&gt;On mismatch&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Env catalogue &lt;code&gt;--check&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;any &lt;code&gt;.go&lt;/code&gt;, the manifest, the modules file, the snapshot&lt;/td&gt;
&lt;td&gt;red build&lt;/td&gt;
&lt;td&gt;running&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Metrics snapshot check&lt;/td&gt;
&lt;td&gt;metric declarations&lt;/td&gt;
&lt;td&gt;red build&lt;/td&gt;
&lt;td&gt;running&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generated-skeleton compare&lt;/td&gt;
&lt;td&gt;files carrying the &lt;code&gt;DO NOT EDIT.&lt;/code&gt; header&lt;/td&gt;
&lt;td&gt;non-zero exit&lt;/td&gt;
&lt;td&gt;specified&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Drift checks are the whole mechanism. They are how "declared" and "actual" stay the same thing instead of two things that agree for a while.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is one piece with the generator
&lt;/h2&gt;

&lt;p&gt;The generator does not only produce code. It produces the things operations are assembled from: the declarations that environment variable names are derived from, the metric declarations the snapshot reads, the daemon list the pipelines build and deploy.&lt;/p&gt;

&lt;p&gt;Which means you can separate "generation" from "operations" only by paying for it in manual synchronisation — of three artefacts, forever, by hand, each with its own way of being quietly wrong.&lt;/p&gt;

&lt;p&gt;There's a small example that shows how quiet that failure can be, from the build side. The linker silently ignores &lt;code&gt;-X&lt;/code&gt; pointing at a symbol that doesn't exist. The build is green, the version stays &lt;code&gt;dev&lt;/code&gt;, and &lt;code&gt;dev&lt;/code&gt; travels into the audit trail and into the trace attribute for as long as nobody looks. So there's a build target that compiles both binaries with a test tag and greps the binary for that tag; empty output means the symbol is wrong. The ldflags target has to be correct in &lt;strong&gt;both&lt;/strong&gt; the &lt;code&gt;Makefile&lt;/code&gt; and the &lt;code&gt;Dockerfile&lt;/code&gt; — the template historically carried a dead target, and a dead target is exactly the kind of defect that never announces itself.&lt;/p&gt;

&lt;p&gt;Same class of problem as the drift checks: something declares a fact, nothing verifies it, and the mismatch surfaces months later in a place you weren't looking.&lt;/p&gt;

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

&lt;p&gt;What this block described is not uniformly finished, and the difference matters more than the story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;in place                                still a plan
──────────────────────────────────      ────────────────────────────────
the manifest - one declaration          skeleton generator - 12 iterations
env catalogue - 59 variables, --check   coverage threshold - 0 vs 86.7%
metrics snapshot - 67 entries           wait-for-health step in deploy
three pipelines - build, tests, deploy
&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%2Fh2qaisb7lad6f4o4qywe.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%2Fh2qaisb7lad6f4o4qywe.png" alt="Two columns: what is in place — manifest, env catalogue, metrics snapshot, three pipelines; what is still a plan — skeleton generator, coverage threshold, wait-for-health step" width="800" height="379"&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;In place&lt;/th&gt;
&lt;th&gt;Still a plan&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The manifest as the single declaration&lt;/td&gt;
&lt;td&gt;The skeleton generator — a 12-iteration specification, not started&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The env catalogue: 59 variables, &lt;code&gt;--check&lt;/code&gt; in CI&lt;/td&gt;
&lt;td&gt;The coverage threshold: the ratchet is &lt;code&gt;0&lt;/code&gt; today&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The metrics snapshot: 67 entries, its own CI check&lt;/td&gt;
&lt;td&gt;A "wait for health" step in the deploy pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Three pipelines: build, tests, deploy&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;To spell out the ones that are easy to overstate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The skeleton generator is not written.&lt;/strong&gt; There is a specification of 12 iterations and an explicit rule not to begin it without a direct instruction. Everything above about generated headers and compare mode is design, not a tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There are no dashboard or alert files in this repository&lt;/strong&gt;, and there won't be. The dashboard is assembled by a generator alongside the rest of the fleet's dashboards; the alert rules are described in text and live on the fleet's monitoring side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The deploy pipeline has no "wait for health" step.&lt;/strong&gt; The platform serves &lt;code&gt;/health&lt;/code&gt; and &lt;code&gt;/ready&lt;/code&gt;; checking after a rollout is the environment's job, not the pipeline's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The coverage threshold is &lt;code&gt;0&lt;/code&gt;.&lt;/strong&gt; The ratchet exists and can only go up, but the bar hasn't been set. Actual line coverage is &lt;strong&gt;86.7%&lt;/strong&gt; — which is the number that makes leaving the threshold at &lt;code&gt;0&lt;/code&gt; slightly embarrassing rather than harmless.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Three drift checks are three places where a pull request goes red because of a file that looks like documentation. That reads as bureaucracy right up until the first time one of them catches a variable you added and forgot. It still reads as bureaucracy to whoever didn't add the variable.&lt;/p&gt;

&lt;p&gt;A single declaration means &lt;strong&gt;there is nowhere to cut a corner&lt;/strong&gt;. You can't add a quick variable "just for now" without the catalogue noticing, can't drop a metric without the snapshot noticing, can't add a binary the pipelines don't know about. That is the entire benefit, and it is also the entire complaint.&lt;/p&gt;

&lt;p&gt;And the coupling earns its keep in proportion to how many services you have. On one service, generating the catalogue, guarding the snapshot and deriving pipelines from a manifest is more machinery than the problem deserves — you'd know all 59 variables by heart. The value shows up when the same shape repeats across services and nobody can hold any of them in their head.&lt;/p&gt;

&lt;p&gt;For scale, the service these numbers come from is &lt;strong&gt;2733 &lt;code&gt;.go&lt;/code&gt; files across 252 packages, averaging 39 lines per file&lt;/strong&gt;. Nothing about that is memorable by hand. That's the point.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;"Declared" and "actual" should match by construction, not by agreement.&lt;/strong&gt; An agreement holds until the first hurried Friday; construction doesn't need anyone to remember.&lt;/p&gt;




&lt;p&gt;That's my experience and my price for it, on one codebase, with the boundary above drawn as honestly as I can draw it.&lt;/p&gt;

&lt;p&gt;If you do this better — if your platform derives more from one declaration than mine does — I'd like to hear how. If you've been through this and came out the other side deciding the checks weren't worth it, that's just as interesting. And if you look at it from a different angle entirely and think the coupling is a mistake, say so.&lt;/p&gt;

&lt;p&gt;The question I'd actually like answered: &lt;strong&gt;how is this solved on your side, and what broke before you solved it that way?&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Operations out of the box — Part 5.&lt;/strong&gt; That closes this block: what a service gets the moment it's declared, and what keeps the declaration honest.&lt;/p&gt;

&lt;p&gt;Next block: data and scaling — starting with treating a database as the unit of tenancy, and what that costs once there's more than one of them.&lt;/p&gt;

</description>
      <category>platform</category>
      <category>devops</category>
      <category>go</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
