<?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: Hideki Mori</title>
    <description>The latest articles on DEV Community by Hideki Mori (@hidekimori).</description>
    <link>https://dev.to/hidekimori</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%2F3903757%2F075e1d6e-d17b-4641-be11-2a3d22b3cebe.jpeg</url>
      <title>DEV Community: Hideki Mori</title>
      <link>https://dev.to/hidekimori</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hidekimori"/>
    <language>en</language>
    <item>
      <title>Count what you can predict, retry what you can't</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 02 Nov 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/count-what-you-can-predict-retry-what-you-cant-3574</link>
      <guid>https://dev.to/hidekimori/count-what-you-can-predict-retry-what-you-cant-3574</guid>
      <description>&lt;p&gt;&lt;em&gt;A schema bug, a retry that couldn't help, and a small design rule I'm keeping.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The error
&lt;/h2&gt;

&lt;p&gt;One day a StructFlow job — the structured-extraction service on my document-processing API — failed with an error I had never seen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Schemas contains too many parameters with union types (27 parameters with
type arrays or anyOf). This causes exponential compilation cost. Reduce the
number of nullable or union-typed parameters (limit: 16 parameters with unions).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My first guess was length — the schema must be too big. Wrong. The input wasn't unusually long. More to the point, I had never written this schema at all: on this API, the output schema is derived from each user's &lt;code&gt;example_output&lt;/code&gt; — a JSON example of the shape they want. That detail matters. A fixed internal schema either fits under a limit forever or fails on day one; my services built that way had run quietly for months. This error could only come from the open-ended part of the system — whatever example a user decides to send. And even in that user's example, nobody had written 27 anywhere.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where 27 comes from
&lt;/h2&gt;

&lt;p&gt;Two mechanisms multiply, and neither is visible on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, the strict-conversion rules.&lt;/strong&gt; The shared schema generator derives &lt;code&gt;required&lt;/code&gt; as the intersection of keys present across all example items; keys that don't appear in every item are simply left out of &lt;code&gt;required&lt;/code&gt;. No unions yet — this intermediate schema is byte-identical for every provider. The unions are manufactured one step later, by the provider-specific strict conversion. Providers whose strict contract demands that &lt;em&gt;every&lt;/em&gt; property be listed in &lt;code&gt;required&lt;/code&gt; leave the converter only one way to express "optional": a nullable union —&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"null"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a heterogeneous example — sections that are sometimes tables, sometimes procedures, sometimes notices, sometimes figures — quietly produces nine union-typed parameters in one schema. Nobody typed the word "union": not the user who wrote the example, not me. The provider's contract forced my converter to manufacture them, following rules I wrote once and then stopped thinking about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, batch composition.&lt;/strong&gt; To use tokens efficiently, the layer above packs multiple input units into one request, and the response schema is repeated per unit (&lt;code&gt;index0&lt;/code&gt;, &lt;code&gt;index1&lt;/code&gt;, ...). Nine unions per unit × three units = 27.&lt;/p&gt;

&lt;p&gt;The constraint isn't about schema length. It's &lt;em&gt;per-unit unions × batch size&lt;/em&gt; — a multiplication where I controlled both factors separately and had never once looked at the product.&lt;/p&gt;




&lt;h2&gt;
  
  
  The retry that couldn't help
&lt;/h2&gt;

&lt;p&gt;Here is the embarrassing part. The error &lt;em&gt;did&lt;/em&gt; trigger my retry logic.&lt;/p&gt;

&lt;p&gt;My pipeline is built on a principle I've kept for 24 years: absorb failures with deliberate retries and always return a result to the user. The retries fired exactly as designed.&lt;/p&gt;

&lt;p&gt;And they were completely useless.&lt;/p&gt;

&lt;p&gt;The retry path this error landed on resends the same request — same units, same schema. But this failure is deterministic: the schema structure itself violates the limit, so the same input produces the same 400 every single time. The pipeline retried to its limit, burned every attempt on identical rejections, and failed anyway. That exhausted-retry failure is what surfaced as the bug.&lt;/p&gt;

&lt;p&gt;The lesson stung a little. A retry can only fix failures where the next attempt might go differently — network hiccups, rate limits, transient provider errors. Against a structural error, retrying is just paying to watch the same rejection again. Twenty-four years of trusting retries, and I had never drawn that boundary explicitly.&lt;/p&gt;




&lt;h2&gt;
  
  
  The fix, in two stages
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Stage 1 — route the error to the right shelf.&lt;/strong&gt; I already had a separate retry shelf for a related class of errors: the provider's &lt;em&gt;"The compiled grammar is too large."&lt;/em&gt; That shelf's retry is different in one crucial way — it splits the batch into smaller unit groups before retrying. Splitting changes the input: fewer units per request means fewer total unions, so the retry can actually succeed. One added string match moved the union error from the same-request retry path onto the splitting path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;retryable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;errorMessage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"The compiled grammar is too large"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
         &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;errorMessage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"too many parameters with union types"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ugly, observational, effective. The pipeline stopped failing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage 2 — count before you send.&lt;/strong&gt; But stage 1 still burns a request to learn what I could have computed. The union limit is a &lt;em&gt;published, predictable number&lt;/em&gt;: 16. If I count the unions in the strict schema before sending, I never enter the failure path at all. And when the schema space is user-defined, counting is the &lt;em&gt;only&lt;/em&gt; defense: I can't test in advance against examples nobody has sent yet, but I can always count the one in front of me.&lt;/p&gt;

&lt;p&gt;I implemented it as a capability method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// GenerativeAICore (base class)&lt;/span&gt;
&lt;span class="kd"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;getMaxUnionParameters&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// no limit&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// AnthropicV1Core (override)&lt;/span&gt;
&lt;span class="nd"&gt;@Override&lt;/span&gt;
&lt;span class="kd"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;getMaxUnionParameters&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A recursive counter walks the schema &lt;em&gt;after&lt;/em&gt; strict conversion — counting &lt;code&gt;type&lt;/code&gt; arrays and &lt;code&gt;anyOf&lt;/code&gt; branches, the exact two constructs the error message itself names — and the batching layer checks the product:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;maxUnions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;core&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMaxUnionParameters&lt;/span&gt;&lt;span class="o"&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;maxUnions&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;perUnitUnions&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;batchLimit&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;maxUnions&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;batchLimit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// fall back to one unit per request&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what I did &lt;em&gt;not&lt;/em&gt; do: a proportional formula like &lt;code&gt;batchLimit = maxUnions / perUnitUnions&lt;/code&gt;. That would squeeze out more batching efficiency — and add a formula I'd have to trust in production. Binary and safe first; clever later, if ever.&lt;/p&gt;

&lt;p&gt;And note what I &lt;em&gt;could not&lt;/em&gt; do: pre-flight the "grammar too large" error the same way. That limit depends on unpublished internals — enum counts, string constraints, nesting depth, property totals — quantities nobody outside the provider can compute. It stays on the observational retry shelf, where splitting handles it.&lt;/p&gt;

&lt;p&gt;That's the whole rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Count what you can predict. Retry what you can't.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Published numeric limits get counted before the request. Opaque complexity limits get observed, matched by error string, and absorbed by a retry that actually changes the input.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Is this an Anthropic thing? (Yes — and here's the proof)
&lt;/h2&gt;

&lt;p&gt;Before shipping, I ran the same heterogeneous workload — nine unions per unit, batched — through every provider path in my system:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Schema shape sent&lt;/th&gt;
&lt;th&gt;Union limit&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Anthropic&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;object-keyed, nullable unions&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;pre-flight fires → 1-unit batches → completes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI (direct)&lt;/td&gt;
&lt;td&gt;array, nullable unions&lt;/td&gt;
&lt;td&gt;none observed&lt;/td&gt;
&lt;td&gt;full batches, completes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure OpenAI&lt;/td&gt;
&lt;td&gt;array, nullable unions&lt;/td&gt;
&lt;td&gt;none observed&lt;/td&gt;
&lt;td&gt;completes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini / Vertex&lt;/td&gt;
&lt;td&gt;array, subset-&lt;code&gt;required&lt;/code&gt; — &lt;strong&gt;no unions manufactured&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;completes (fastest)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon Nova (Bedrock)&lt;/td&gt;
&lt;td&gt;object-keyed, subset-&lt;code&gt;required&lt;/code&gt; — &lt;strong&gt;no unions manufactured&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;completes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;xAI Grok&lt;/td&gt;
&lt;td&gt;object-keyed, nullable unions — &lt;strong&gt;identical shape to Anthropic&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;none observed&lt;/td&gt;
&lt;td&gt;completes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Grok row settles it. Grok receives the &lt;em&gt;exact same&lt;/em&gt; schema shape as Anthropic — same object-keyed structure, same nullable unions — and sails through 9 × 10 unions without complaint. Same schema, different verdict. The 16-union limit is Anthropic-specific, not a property of strict decoding in general.&lt;/p&gt;

&lt;p&gt;The Gemini and Nova rows are interesting for the opposite reason: their dialects accept &lt;code&gt;required&lt;/code&gt; as a subset of &lt;code&gt;properties&lt;/code&gt;, so the intermediate schema's optionality-by-omission passes straight through. No all-required rule, no manufactured unions — the constraint class is never violated because the thing it constrains is never created. Which sharpens the Anthropic row into a small irony: the same strict contract that forces the converter to manufacture nullable unions is the one that caps them at 16.&lt;/p&gt;

&lt;p&gt;The general lesson: strict-decoding constraints are per-provider, expressed in per-provider schema dialects, and they don't transfer. The capability-method pattern keeps the special case where it belongs — one override in one provider's subclass — and every other path runs on the default &lt;code&gt;-1&lt;/code&gt;, untouched. (Even the object-keyed vs. array split in the table is decided this way, by an older capability that checks whether a provider's strict grammar honors &lt;code&gt;minItems&lt;/code&gt;/&lt;code&gt;maxItems&lt;/code&gt;. &lt;code&gt;getMaxUnionParameters&lt;/code&gt; is the second capability of its kind here, not the first.) All six paths passed with the fix in place, zero regressions.&lt;/p&gt;




&lt;h2&gt;
  
  
  One more thing the model taught me
&lt;/h2&gt;

&lt;p&gt;While testing, I caught the model inventing a section type I never gave it. My example defined four discriminator values — &lt;code&gt;table&lt;/code&gt;, &lt;code&gt;procedure&lt;/code&gt;, &lt;code&gt;notice&lt;/code&gt;, &lt;code&gt;figure&lt;/code&gt; — and the model added a fifth, &lt;code&gt;"section"&lt;/code&gt;, for heading-only passages. Reasonable behavior — but a quiet trap. It doesn't break the schema I already sent: that schema was fixed before generation began, and a bare string &lt;code&gt;type&lt;/code&gt; accepts &lt;code&gt;"section"&lt;/code&gt; just fine, so no limit is tripped and nothing errors. The damage is downstream — my code branched on four types and had no case for the fifth — and one step further out. If I ever fold these real outputs back into my example to "improve" it, that new shape brings new optional keys, which become new nullable unions, which push the &lt;em&gt;next&lt;/em&gt; request toward the 16. The model doesn't inflate the count directly; the human habit of learning from its output does.&lt;/p&gt;

&lt;p&gt;The fix is one sentence in the prompt: enumerate the allowed values explicitly — &lt;em&gt;"use only 'table', 'procedure', 'notice', or 'figure' — no other types."&lt;/em&gt; With that line, the same workload came back with exactly four types. If your schema derives from examples, tell the model the example is closed, not open-ended.&lt;/p&gt;

&lt;p&gt;The model wasn't violating any published limit. It was exploiting ambiguity I had left in the prompt. That's the same lesson once more: count what you can predict, constrain what you can define, and reserve retries for the rest.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Twenty-four years of "always return a result" taught me to trust retries. This bug taught me the boundary of that trust: retries belong to the non-deterministic world. For the deterministic part — the part with a published number attached — the honest move is to count first and never fail at all.&lt;/p&gt;

&lt;p&gt;Since writing this, I've met the harder sibling of that boundary: a failure that never announces itself at all. The request succeeds, the JSON validates, the totals reconcile — and the content is fiction. Nothing to count, nothing a retry can change. I published the measurements in July: &lt;a href="https://dev.to/hidekimori/when-ai-cant-read-it-invents-but-it-still-sees-the-shape-18ac"&gt;When AI can't read, it invents — but it still sees the shape&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>architecture</category>
      <category>backend</category>
      <category>ai</category>
    </item>
    <item>
      <title>No sentence, no component</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 26 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/no-sentence-no-component-1iim</link>
      <guid>https://dev.to/hidekimori/no-sentence-no-component-1iim</guid>
      <description>&lt;p&gt;I keep watching the same movie. A component gets added for speed. Speed needs durability, so a persistence mechanism gets bolted onto it. The bolt-on is what fails. And the proposed fix is another component.&lt;/p&gt;

&lt;p&gt;Whenever I find myself in that audience again, I run a private exercise: design the system from scratch with the dullest tools I trust, and log every decision — not just what I picked, but what each option would have poisoned me with. Usually the log stays in my notes. This time I'm publishing it, because the design ended up small enough to be embarrassing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The problem.&lt;/strong&gt; One synchronous HTTP request arrives. Split it into N independent segments — say 100. Process them in parallel on a fleet of workers, where one segment costs seconds of real compute (machine translation is my running example; any splittable job fits). Return whatever finished by the deadline, mark the rest timed out. Bill for what was done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rule I imposed on myself.&lt;/strong&gt; Every component must justify its existence in one sentence naming the condition that requires it. No sentence, no component.&lt;/p&gt;

&lt;p&gt;Stack: Java 21 (virtual threads) on Tomcat, MySQL 8 / InnoDB. Numbers like "100 workers" and "500 ms" are design values, not benchmarks.&lt;/p&gt;




&lt;h2&gt;
  
  
  Decision 1 — Pull, not push
&lt;/h2&gt;

&lt;p&gt;The push design: a dispatcher assigns segments to workers. To assign, it must know which workers are idle — so somewhere there is a live ledger of worker state. That ledger wants to be fast and shared, and that is exactly the shape of the in-memory KV store from the opening movie. It doesn't get added out of stupidity; it gets added because push &lt;em&gt;requires&lt;/em&gt; it.&lt;/p&gt;

&lt;p&gt;The pull design: workers come to the work. An idle worker asks "anything for me?" — and the ledger doesn't get replaced by something better. It stops existing. Nobody needs to know who is idle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Traded:&lt;/strong&gt; workers now poll, and a poll can come back empty. &lt;strong&gt;Got:&lt;/strong&gt; one HA-critical component deleted before it was born.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Rule: the cheapest component is the one whose job disappears.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Decision 2 — The queue is a table
&lt;/h2&gt;

&lt;p&gt;What does queue middleware actually give you? Strip away the features you won't use, and the essence is one primitive: exclusive takeout — N consumers, each item handed to exactly one. SQL has had that primitive for years:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;segments&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt;  &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&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;SKIP LOCKED&lt;/code&gt; is the whole trick: a row someone else is holding is skipped, not waited on, so a hundred workers grab a hundred different rows with zero lock contention.&lt;/p&gt;

&lt;p&gt;The objection writes itself: "MySQL will be slower than a purpose-built queue." Per operation, true. And irrelevant — each segment costs seconds of compute, so even 100 workers in a tight loop ask the database for a few hundred tiny index operations per second. InnoDB's ceiling is orders of magnitude above that. The bottleneck is the work, not the takeout. A throughput objection has to name the actual bottleneck, or it's an aesthetic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Traded:&lt;/strong&gt; headline RPS I will never use. &lt;strong&gt;Got:&lt;/strong&gt; transactions, durability, and a storage system I already operate.&lt;/p&gt;




&lt;h2&gt;
  
  
  Decision 3 — Lease, not held lock
&lt;/h2&gt;

&lt;p&gt;This is the decision I got wrong first.&lt;/p&gt;

&lt;p&gt;The held-lock design: claim a row with &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; and &lt;em&gt;keep the transaction open&lt;/em&gt; for the whole job. It has a beautiful crash story — worker dies, connection drops, lock releases itself, the next worker picks the row up. No flags, no janitor. I almost kept it for the elegance alone.&lt;/p&gt;

&lt;p&gt;Then the poisons. A transaction held open for tens of seconds makes InnoDB's history list grow and purge lag behind. Concurrency gets capped by connection count — free back-pressure if you're feeling generous, your connection pool doing scheduling if you're not.&lt;/p&gt;

&lt;p&gt;And the killer: cleanup. When a request is cancelled and you &lt;code&gt;DELETE ... WHERE request_id = ?&lt;/code&gt;, that statement &lt;em&gt;blocks&lt;/em&gt; on every in-flight row. &lt;code&gt;SKIP LOCKED&lt;/code&gt; is a SELECT modifier; DELETE has no such escape.&lt;/p&gt;

&lt;p&gt;The lease design: claim in a millisecond transaction.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;START&lt;/span&gt; &lt;span class="n"&gt;TRANSACTION&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;segments&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;
   &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'processing'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;lease_until&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;segments&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt;    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'processing'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lease_token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;-- fresh UUID&lt;/span&gt;
       &lt;span class="n"&gt;lease_until&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="k"&gt;SECOND&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&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;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- lock held for milliseconds, not for the job&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While the job runs, nothing is locked. "Processing" is just data — which is why any other transaction, cleanup included, can act on the table without waiting.&lt;/p&gt;

&lt;p&gt;Lease has its own poison and it isn't small: at-least-once. A slow worker whose lease expires gets its segment re-claimed by someone else, and now two workers hold the same job. So every write-back must be fenced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;segments&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'done'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;lease_token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- 0 rows: you lost the lease; drop your result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And expired leases need reaping — which the claim query above already does, in its &lt;code&gt;WHERE&lt;/code&gt; clause.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Traded:&lt;/strong&gt; exactly-once, and the elegant auto-release. &lt;strong&gt;Got:&lt;/strong&gt; short transactions, cleanup that never blocks, and crash recovery expressed as a query condition instead of a connection artifact.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Rule: pick the poison you can operate, not the story that sounds elegant.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Decision 4 — Fan-in by polling, not notification
&lt;/h2&gt;

&lt;p&gt;Now the collecting side. The request thread is parked on Tomcat, waiting for 100 results or a deadline.&lt;/p&gt;

&lt;p&gt;I wrote the event-driven version first. Workers call back the owning node; a &lt;code&gt;CountDownLatch(N)&lt;/code&gt; wakes the request thread the instant the last segment lands; not a single wasted read. As Java it's lovely — &lt;code&gt;latch.await(deadline)&lt;/code&gt; answers "all done, or timed out?" in one blocking call, virtual threads make one-thread-per-request free, and the latch's happens-before even hands you memory visibility on the result slots for nothing.&lt;/p&gt;

&lt;p&gt;Then the topology bill arrives. The worker must reach &lt;em&gt;a specific instance&lt;/em&gt; behind the load balancer. That means instance addressing, a security-group edge from workers to app nodes, service discovery for tasks whose IPs churn — and an infrastructure team that, correctly, hates every item on that list. (Postgres would dissolve this with &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt; — the database itself routes the wake-up to whoever is listening — but I'm on MySQL, and adopting a different database to avoid a polling query fails my own one-sentence test, badly.)&lt;/p&gt;

&lt;p&gt;The polling version: every 500 ms, the owning node runs one lock-free, indexed SELECT —&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;index_no&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;result&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;segments&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;request_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&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 counts terminal rows. The waste is a range scan over ~100 rows, twice a second. The request's total latency is dominated by its &lt;em&gt;slowest segment&lt;/em&gt;, which is seconds; 500 ms of granularity is noise on that tail.&lt;/p&gt;

&lt;p&gt;Here's what I didn't expect: choosing polling deleted things in bulk. The latch — gone. The callback endpoint — gone. The direct network path, the SG rule, the discovery mechanism — gone. "Reduce the hops," taken seriously, terminated at &lt;em&gt;the database is the only rendezvous&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Traded:&lt;/strong&gt; sub-second wake-up and a satisfying event-driven diagram. &lt;strong&gt;Got:&lt;/strong&gt; a topology with no arrows that bypass the load balancer.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Rule: "no wasted work" and "no extra paths" are different goals. When they conflict, price each path by what it costs to operate, not what it costs to run.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Decision 5 — Cancellation stops the future, not the present
&lt;/h2&gt;

&lt;p&gt;Requests get a master row: &lt;code&gt;requests(id, status, deadline, ...)&lt;/code&gt; with &lt;code&gt;active / cancelled / completed&lt;/code&gt;. A user reset is one statement — &lt;code&gt;UPDATE requests SET status = 'cancelled' WHERE id = ?&lt;/code&gt; — and because the claim query joins on &lt;code&gt;r.status = 'active'&lt;/code&gt;, cancelled requests instantly stop being claimable. That includes expired-lease re-claims: no zombie segments of a dead request coming back to life.&lt;/p&gt;

&lt;p&gt;What cancellation deliberately does &lt;em&gt;not&lt;/em&gt; do is preempt in-flight segments. A running segment has already spent most of its cost; killing it mid-flight would save a tail while requiring cancellation plumbing all the way into the compute call. So in-flight work runs to completion and writes through the fence. Yes, there's a race — a segment claimed in the same instant as the cancel still runs. That waste is bounded to one claim window per worker, and I accept it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Rule: cancellation is cheap where work hasn't started and expensive where it has. Put the boundary exactly there.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Decision 6 — Settlement is a batch, because billing is a business function
&lt;/h2&gt;

&lt;p&gt;Someone has to count input/output characters, write the metering record, and delete the rows. The hot path is the wrong someone. Its SLA is "fast, and losing an in-flight request in a crash is survivable." Billing's SLA is "accurate, auditable, never lost." Two SLAs don't share a process.&lt;/p&gt;

&lt;p&gt;So a batch loops forever, claiming settled requests with the same trick as Decision 2, one level up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'completed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'cancelled'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;segments&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
                   &lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
                     &lt;span class="k"&gt;AND&lt;/span&gt;  &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'processing'&lt;/span&gt;
                     &lt;span class="k"&gt;AND&lt;/span&gt;  &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lease_until&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt;  &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;NOT EXISTS&lt;/code&gt; (live lease) is the handshake with Decision 5: a cancelled request isn't settled until its in-flight stragglers finish or their leases die. Then, in one transaction: sum the done segments, insert the metering record (idempotent on &lt;code&gt;request_id&lt;/code&gt;), delete segments and request. Record first, delete second; a crash in between just re-runs.&lt;/p&gt;

&lt;p&gt;The billing policy fits in one clause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;segments&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;request_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'done'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cancelled requests still pay for finished segments — cancelling is the user's prerogative, not a refund mechanism. If that policy ever changes, it changes in one place.&lt;/p&gt;

&lt;p&gt;A property I only noticed after drawing the sequence diagram: you can stop the batch server entirely and translation keeps flowing. That is what "different SLA" looks like when it's real.&lt;/p&gt;




&lt;h2&gt;
  
  
  Decision 7 — The in-memory itch
&lt;/h2&gt;

&lt;p&gt;Last temptation: the segments table is transient by design — shouldn't it live in RAM?&lt;/p&gt;

&lt;p&gt;MySQL's MEMORY engine fails mechanically before it fails philosophically: table-level locks only (there goes &lt;code&gt;SKIP LOCKED&lt;/code&gt;), no TEXT columns (there go the results), and a restart wipes every in-flight request. Disqualified in three sentences.&lt;/p&gt;

&lt;p&gt;And the itch itself is misdiagnosed. A small, hot InnoDB table effectively &lt;em&gt;is&lt;/em&gt; in memory — the buffer pool serves the reads; disk sees sequential redo writes. If measurement ever shows commit fsyncs as the bottleneck, the honest lever is &lt;code&gt;innodb_flush_log_at_trx_commit = 2&lt;/code&gt; — but that variable is instance-global, and the metering table must stay durable, so "fast segments, durable billing" would force a second MySQL instance. Adding a component to accelerate something that isn't the bottleneck: rejected by the same test as everything else.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Rule: before making storage faster, check whether storage is in the top two costs at all.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The test, and when it flips
&lt;/h2&gt;

&lt;p&gt;Final inventory: Tomcat, workers, one MySQL, one batch. MySQL passes the one-sentence test three times over — it is the exclusivity primitive (Decision 2), the fan-in channel (Decision 4), and the source of truth for billing (Decision 6). One component, three jobs. Nothing else in the candidate pile could produce a sentence.&lt;/p&gt;

&lt;p&gt;None of this argues that queues, caches, and notification systems are bad. It argues they are answers to &lt;em&gt;conditions&lt;/em&gt;: a worker fleet large enough that empty polls get expensive; an asynchronous outer API that makes pending state worth persisting independently of any node; a dequeue rate that actually approaches the store's ceiling. When a condition shows up, add the component — and write the sentence down, so future-you knows when to delete it again.&lt;/p&gt;

&lt;p&gt;Until then, the row lock you already own is the best middleware you'll never install.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>mysql</category>
      <category>java</category>
      <category>backend</category>
    </item>
    <item>
      <title>The list is a scar</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 19 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/the-list-is-a-scar-c8n</link>
      <guid>https://dev.to/hidekimori/the-list-is-a-scar-c8n</guid>
      <description>&lt;p&gt;Every external thing I depend on betrays me eventually. Not out of malice. It just fails, on its own schedule, for reasons that are none of my business and entirely my problem.&lt;/p&gt;

&lt;p&gt;The cleanest example I have is a billing failure. I pay a provider by auto-charge — the balance tops itself up when it runs low. Except the charge and the ledger the API checks don't agree in real time. The money goes through; a beat later the gateway reads a stale balance and rejects the call. Nothing on my side is wrong. The card worked. The top-up worked. And the job still dies in my code, with my name on the stack trace.&lt;/p&gt;

&lt;p&gt;That's the whole contract with the outside. It isn't my fault, and it's still my job.&lt;/p&gt;




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

&lt;p&gt;The fix is unglamorous, and I keep it that way on purpose. Wait a few seconds, try again, a fixed number of times. No exponential backoff, no decorrelated jitter, no circuit breaker with half-open probes. A handful of tries, a few seconds apart. Then succeed, or give up. I could make it cleverer. I keep choosing not to, because every clever piece is one more thing that can be wrong at three in the morning, and the dumb version has been wrong far less often than the smart ones I've torn out.&lt;/p&gt;

&lt;p&gt;There's exactly one place the dumb rule bends. When retries pile onto a shared, expensive resource — a heavy process I let through one at a time — fixed delays make them all collide on the way back in. There, and only there, the wait is randomized. The boring rule isn't dogma. It just knows the single place it has an exception, and nowhere else.&lt;/p&gt;




&lt;h2&gt;
  
  
  The list
&lt;/h2&gt;

&lt;p&gt;Here's the part that took me years to understand. The intelligence in a retry isn't how many times it tries. It's what it refuses to retry.&lt;/p&gt;

&lt;p&gt;Some failures are answers. When I ask storage whether a file exists and it tells me the key isn't there, that's not a hiccup — that's a no. Retrying asks the same settled question five times and gets the same no. A bad configuration is the same kind of thing: it will never work, and trying again is just a way of lying to yourself about that. Some failures are about the input, not the system — this one document is malformed — and the move isn't to retry, it's to set that one aside and keep going. Some failures mean the service itself is down, which is not "this file is bad" and must not be filed as if it were. That's a different alarm. It goes up, not into the skip pile.&lt;/p&gt;

&lt;p&gt;And then everything else — every failure I haven't named — gets retried. That's the inversion that matters. The default for an &lt;em&gt;unknown&lt;/em&gt; error is not to stop. It's to try again, because I've already carved out the ones I know are final, and whatever is left, in my experience, is usually just the outside having a bad second.&lt;/p&gt;

&lt;p&gt;That carving is not design. It's sediment. Each line that says &lt;em&gt;if it failed in this exact way, don't retry — skip it, or escalate it, or give up&lt;/em&gt; is there because one night it failed in that exact way, and I sat watching the same call burn through every attempt for nothing, and learned that this one does not come back. The next morning I added a line. The list of definite failures isn't a taxonomy I sat down and drew. It's a record of every way the outside has already hurt me.&lt;/p&gt;

&lt;p&gt;Every entry is a scar. You can read the history in it: the ones I commented out and rewrote, the place where a precise error code wasn't enough so I gave up and matched a word in the log instead. None of it was foreseen. All of it was paid for once, in a wasted loop, before it earned its line.&lt;/p&gt;




&lt;h2&gt;
  
  
  A success that isn't
&lt;/h2&gt;

&lt;p&gt;The worst betrayals don't even look like failures. They arrive wearing a success.&lt;/p&gt;

&lt;p&gt;The call returns. Status two hundred. Everything green. And the body is half an answer — cut off at a token limit, or quietly refused by a content filter, or technically complete but with the pieces in an order that doesn't line up with what I asked for. Treat "it came back" as "it worked," and I've shipped garbage with a clean receipt.&lt;/p&gt;

&lt;p&gt;So a response arriving is not success. A response I can use is success, and those are two different checks. That one also cost me a loop or two before it became a line — the outside handed me a perfect-looking answer, I trusted the envelope, and the thing inside was hollow.&lt;/p&gt;




&lt;h2&gt;
  
  
  The ceiling isn't mine
&lt;/h2&gt;

&lt;p&gt;Left to myself, I would retry forever. Finishing the job and reporting back is the one thing I have never been willing to give up; it has been the whole point for as long as I've done this. If trying again might work, I want to try again.&lt;/p&gt;

&lt;p&gt;But someone is waiting. Not in a queue I can drain on my own time — on the other side of a screen, watching a spinner, having pressed a button and expecting an answer. There is a number of minutes past which that person gives up on me, and it is smaller than I'd like. Five or six, for something synchronous, and that's the edge. So the work that could use more tries doesn't get them. I cap it below what the failure deserves, because the failure is not the thing I'm serving.&lt;/p&gt;

&lt;p&gt;That is the part I didn't expect. The brake on my finish-at-all-costs instinct was never the outside being truly hopeless. It was a person's patience. The ceiling on how hard I fight the outside is drawn by someone who isn't even thinking about the outside — who just wants their document back before they stop caring.&lt;/p&gt;




&lt;h2&gt;
  
  
  So I keep a list
&lt;/h2&gt;

&lt;p&gt;The outside breaks. I absorb it. That's the arrangement, and I stopped resenting it a long time ago.&lt;/p&gt;

&lt;p&gt;But when you look at what &lt;em&gt;absorb&lt;/em&gt; comes down to in the code, it's smaller and stranger than the word makes it sound. It's a dumb loop that waits and tries again. It's a list of every way the outside has already failed me, each line a scar I added the morning after. And it's a ceiling — on how long I'll fight — that I didn't set, that someone waiting set for me.&lt;/p&gt;

&lt;p&gt;I never trusted the outside. I just kept a record of how it failed me, and a clock that was never mine.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>reliability</category>
      <category>architecture</category>
      <category>backend</category>
    </item>
    <item>
      <title>A design I didn't write</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 12 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/a-design-i-didnt-write-1hen</link>
      <guid>https://dev.to/hidekimori/a-design-i-didnt-write-1hen</guid>
      <description>&lt;p&gt;I built a feature that turns text into other text. It started as translation: text in one language, out in another. That was the name it got, and names are sticky.&lt;/p&gt;

&lt;p&gt;I didn't stay literal about it for long, though. The engine underneath took an arbitrary instruction, so I started elaborating. I extended the translation side into style — feed it a flat document and ask for more shape, plain prose in and prose in the manner of a literary novelist out. Murakami, say. Same words, different weather. And the other obvious move, pulling structure out of unstructured text, I gave its own tool, with its own name. By the time the story I'm about to tell happened, I had a tidy little map: translation here, style as its good clothes, extraction over there in a room of its own. I was proud of the map.&lt;/p&gt;




&lt;h2&gt;
  
  
  Through the wrong door
&lt;/h2&gt;

&lt;p&gt;Then a customer did something that wasn't on my map.&lt;/p&gt;

&lt;p&gt;They had a spreadsheet — thousands of rows of text in a single column. They pasted it into the &lt;em&gt;translation&lt;/em&gt; box and, instead of asking for another language or another voice, typed an instruction to categorize each row. What came back wasn't prose. It was labels: a tag, a code, the one field worth pulling out of each messy line. They downloaded the result and pasted it into the next column over, beside the original.&lt;/p&gt;

&lt;p&gt;I laughed when I saw it. Then I stopped laughing, because it was exactly the job I'd built the other tool for — and they'd done it in the translation box, without ever opening the room I'd furnished for precisely this.&lt;/p&gt;

&lt;p&gt;They hadn't gone somewhere I'd never imagined. I'd imagined it well enough to build a product for it. They'd gone through a wall.&lt;/p&gt;




&lt;h2&gt;
  
  
  The walls were mine
&lt;/h2&gt;

&lt;p&gt;The uncomfortable part is where that wall had come from.&lt;/p&gt;

&lt;p&gt;The engine never had it. Underneath, the thing took text and an instruction and handed back whatever the instruction asked for — translate this, restyle this, classify this. It didn't care which, and it didn't know there was supposed to be a difference. The line between "translation" and "extraction" wasn't in the capability. I'd painted it on, with two names and two products and a tidy map. The customer couldn't see my paint. They saw one box that took text and an instruction, and they used it as the thing it actually was: undivided.&lt;/p&gt;

&lt;p&gt;So the limit that mattered was never the reach of my imagination. I'd reached the idea fine — I'd built a whole tool for it. It was the structure I'd laid over a capability that didn't have any. I'd spent real care drawing rooms, and the user walked through them as if the walls were paper.&lt;/p&gt;




&lt;h2&gt;
  
  
  The dirty version was right
&lt;/h2&gt;

&lt;p&gt;There's a smaller version of the same move, and it landed in a different spot.&lt;/p&gt;

&lt;p&gt;When I saw the copy-and-paste workflow, my instinct was to fix it. &lt;em&gt;You shouldn't have to do that,&lt;/em&gt; I said, more or less. &lt;em&gt;I'll give you an API. Wire it straight into your process.&lt;/em&gt; Clean. Proper. The engineer's idea of a favor.&lt;/p&gt;

&lt;p&gt;They didn't want it. The current shape was best, they said — and they were right, the same way they'd been right about the box. My API was another wall I'd built: &lt;em&gt;the correct way in.&lt;/em&gt; Their copy-paste went around it, because to them there was no correct way in, only &lt;em&gt;don't make me leave the spreadsheet I already live in.&lt;/em&gt; Once more I'd imposed a structure, and once more the user reached past it for the thing underneath. "Better," measured against my structure, was worse against their day.&lt;/p&gt;




&lt;h2&gt;
  
  
  The truer map
&lt;/h2&gt;

&lt;p&gt;So I've stopped policing my own boundaries.&lt;/p&gt;

&lt;p&gt;When a customer crosses one — pulls data out of the box, routes around the API — they haven't misused anything. They've shown me a wall that doesn't need to be there. The line between translation and extraction was a decision I'd made, not a fact about the work, and the customer who never agreed to it is the one person placed to tell me it was optional. The translation box used as a classifier wasn't an error to fix. It was a map of the capability with my walls left off — and a truer map than the one I'd been proud of.&lt;/p&gt;

&lt;p&gt;Use isn't feedback on your design. It's a design you didn't write — and, often, the better one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>ai</category>
      <category>architecture</category>
      <category>product</category>
    </item>
    <item>
      <title>My own worst user</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 05 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/my-own-worst-user-1h2l</link>
      <guid>https://dev.to/hidekimori/my-own-worst-user-1h2l</guid>
      <description>&lt;p&gt;I use the things I build. Not the way you use something to test it — open it, click around, confirm it works, close the tab. I use them to do the actual work, more than anyone else does, for as long as I own them. It isn't discipline. I don't have the temperament for discipline. It's that I can't stand a tool I have to fight, and the tools I end up fighting hardest are always my own.&lt;/p&gt;

&lt;p&gt;That's the whole method, if it even is one. I'm my own worst user.&lt;/p&gt;




&lt;h2&gt;
  
  
  What use does to a screen
&lt;/h2&gt;

&lt;p&gt;Here is the thing I noticed once and can't un-notice. The first time I sit down and actually use a screen to get something done, its shape stops being a decision I make. It decides itself.&lt;/p&gt;

&lt;p&gt;Take a usage screen — the kind that tells you how much of something got used, by whom, over what window. The first time I use it, I want a total, so "everything" has to be a state the screen can show. The next time, I want one customer, so I narrow. I don't sit and reason about any of this. The friction of trying to get an answer drags the shape out of me. Whatever stands between me and the number gets filed down, because I'm the one standing there waiting for the number.&lt;/p&gt;

&lt;p&gt;That's the part most "use your own software" advice skips over. It isn't that using the thing reminds you to care. It's that using the thing changes what you're able to think. In front of a table, you can think in tables — rows, columns, keys, joins. In front of a &lt;em&gt;task&lt;/em&gt;, you can't, because the table was never the thing you wanted. The answer was. You can't keep thinking in records once you've had to do the job.&lt;/p&gt;




&lt;h2&gt;
  
  
  The tenant was never a constant
&lt;/h2&gt;

&lt;p&gt;Because I use it, the tenant was never a constant.&lt;/p&gt;

&lt;p&gt;When I write the query behind that usage screen, I have to write the part that scopes it — which groups this person is allowed to see. And the moment I've written that, the operator's view is already sitting in front of me. It's the same query with the limit taken off. I didn't design two screens, one for the customer and one for whoever runs the place. I wrote one line about who's asking, and the second screen fell out of the first, for free.&lt;/p&gt;

&lt;p&gt;So a regular user is an admin with a list of one. (Sometimes the list is longer than one — someone who belongs to a few groups sees a few. Same idea.) "Fixed" and "everything" aren't two screens; they're one control whose options come from permission. A list of one needs no control at all — there's nothing to choose. A list of all is the same control with everything in it.&lt;/p&gt;

&lt;p&gt;You get to the same place from the other end. Turn the screen's hard-coded label into a selector, and the selector's options &lt;em&gt;are&lt;/em&gt; the set the query is allowed to range over. The dropdown and the WHERE clause are the same set, seen from two sides. It makes no difference whether you start from the database or the widget. You land on one screen.&lt;/p&gt;




&lt;h2&gt;
  
  
  The other shape
&lt;/h2&gt;

&lt;p&gt;I've seen it built the other way. You sign in, you get a list of customers, you pick one, you land on that customer's page, and to see another you go back to the list and pick again. Nobody — not even the person running the whole thing — can see the whole. It looks like navigation. It's the foreign keys wearing a coat. You aren't navigating; you're walking the schema's containment, one row at a time.&lt;/p&gt;

&lt;p&gt;The most naked version I ever saw went further: the screen &lt;em&gt;was&lt;/em&gt; the table. A picker per column. A menu that made me pick the engine by UUID. Empty boxes asking me to choose the axes of a pivot. I was being handed the GROUP BY to write myself.&lt;/p&gt;

&lt;p&gt;Here's what I keep coming back to, though. You don't need to be clever to fix any of this. You don't need to understand multi-tenancy, or hold a theory about navigation, or read an essay. You need to use it. Use it once, for one real task, and the &lt;em&gt;huh?&lt;/em&gt; arrives before any understanding does — wait, I have to go back to the list again? I can't see the total? I'm choosing an engine by UUID? The friction is louder than any explanation.&lt;/p&gt;

&lt;p&gt;So the diagnosis isn't that the people who built it didn't get it. It's that nobody sat in the seat.&lt;/p&gt;

&lt;p&gt;And the scary one isn't the sloppy version. A sloppy tool just means someone ran out of time; you can see where the rush went. The one that should frighten you is the polished one nobody used — clean layout, even spacing, consistent buttons, and unusable, because careful people shipped it and not one of them tried to do the job with it. Care didn't save them. Only use would have.&lt;/p&gt;

&lt;p&gt;Use isn't a magic word, either. You can use a thing every day and go numb — run the back-to-the-list dance a thousand times and stop noticing it's a dance. So it isn't "use it." It's: use it for the real task, and refuse to let the friction become normal.&lt;/p&gt;




&lt;h2&gt;
  
  
  What you can't hand over
&lt;/h2&gt;

&lt;p&gt;So the fix looks easy. Tell people to use what they build. I've said it. It doesn't take.&lt;/p&gt;

&lt;p&gt;Not because they're slow. Because it isn't a technique, and you can't hand someone a technique they have no slot for. It's nearer to a disposition — using the thing, being unable to tolerate fighting your own tool — and a disposition doesn't move from one person to another by being explained. I didn't learn it. I just can't not.&lt;/p&gt;

&lt;p&gt;I'm not trying to dunk on anyone. I'd rather it transferred. It doesn't. Maybe that's too strong. But every dead tool I've ever seen was built by someone who could have felt the &lt;em&gt;huh?&lt;/em&gt; on the first day and didn't, because they never sat down. The screens I'm afraid of aren't the ugly ones. They're the careful ones nobody ever used.&lt;/p&gt;

&lt;p&gt;You can't teach someone to be their own worst user. You can only be one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>architecture</category>
      <category>backend</category>
      <category>reliability</category>
    </item>
    <item>
      <title>I'd rather not ask</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 28 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/id-rather-not-ask-12me</link>
      <guid>https://dev.to/hidekimori/id-rather-not-ask-12me</guid>
      <description>&lt;p&gt;From the outside, the translation layer I maintain has one interface. You hand it a set of segments. For each segment you get back one of two things: a translation, or an error code. That is the whole contract. It looks calm.&lt;/p&gt;

&lt;p&gt;It is not calm. The calm is something I make.&lt;/p&gt;




&lt;h2&gt;
  
  
  None of them agree
&lt;/h2&gt;

&lt;p&gt;Behind that interface is a row of engines, and no two of them agree on anything. One returns HTTP 200 for its own failures and hides the real status as an integer in the body. One answers in JSON until the input gets large and then switches to XML. One returns its results as two unordered lists you reconcile by id. One has a job state that runs backwards. One isn't on the network at all — it's a native library in my own process that, on a bad day, becomes a zombie in the process table and never returns.&lt;/p&gt;

&lt;p&gt;Not one of them produces the shape my interface promises. The single result-or-error the caller sees does not exist out there. I assemble it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The contract is something I make
&lt;/h2&gt;

&lt;p&gt;So the contract isn't something I found by reading documentation. It's something I manufacture, at the boundary, one engine at a time. Every lie gets caught and rewritten into the one vocabulary the caller speaks. The 200 that means failure becomes an error code. The two lists become per-segment results, matched by id. The zombie becomes a clean "this didn't finish." The caller never learns any of it. They asked for translations; they get translations, or honest errors, in the order they expect. They stay naive because I decided they would.&lt;/p&gt;




&lt;h2&gt;
  
  
  The hard part is the half
&lt;/h2&gt;

&lt;p&gt;The easy version of this is the engine that wholly succeeds or wholly fails. The real work is the middle: the batch that translated forty segments, errored on three, and then dropped the connection. The engine that returned results and a charge for work it never finished.&lt;/p&gt;

&lt;p&gt;An honest interface has to be granular about that. This segment succeeded. This one carries an error. And — the part I care about most — you do not bill for what didn't come back. If a segment was skipped, it's skipped. If the engine billed me for work that never returned, I take it off. Absence is not billable. The tempting thing is to bill the request; it's simpler, and the number is right there. The discipline is to bill only the results. Partial failure is where most of the integrity lives, because it's the case nobody downstream can see — so it's the case only I can get wrong.&lt;/p&gt;




&lt;h2&gt;
  
  
  I'd rather not ask
&lt;/h2&gt;

&lt;p&gt;After enough years of this I stopped pretending it was only an engineering decision. It's a personality. I am bad at asking people for things — bad enough that I'll route around a favor rather than ask for one. I have never been comfortable handing someone a problem I already understand. And I don't mind in the least being asked. Those two facts are most of how I work, and they're sitting right there in the code.&lt;/p&gt;

&lt;p&gt;The interface is bad at asking, too. It never turns to the caller and says: handle this engine's lie for me, reconcile these lists for me, decide what to do about this zombie. It passes none of that outward. And it is endlessly available to be asked — hand it anything, it takes the request and returns a clean answer. I would rather absorb a mess than hand it to someone else. The contract is uniform not because the engines cooperate, but because I'd rather not ask the people downstream to deal with what I can deal with myself.&lt;/p&gt;




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

&lt;p&gt;This has an edge I've learned not to romanticize. A seam that absorbs everything is also the one place everything collects. The interface gets clean; the load doesn't vanish — it concentrates. Every quirk that doesn't reach the caller reaches me, and stays. The same instinct that keeps the people downstream naive makes the one upstream the place all of it lands. A clean interface isn't free. It's paid for, in a single spot, by whoever decided not to pass the bill along.&lt;/p&gt;




&lt;h2&gt;
  
  
  The same seam, from the inside
&lt;/h2&gt;

&lt;p&gt;I wrote once, about the systems on the far side of the wire, that an integration is the one system you can never close, because the other side keeps reopening it. This is that same boundary, seen from the inside. I can't stop the chaos out there from changing. But I can decide where it stops — that it stops at me, and not one step further. The caller gets a translation, or an honest error. They never find out what it cost to make that true.&lt;/p&gt;

&lt;p&gt;That's the part I'd rather not ask anyone else to do.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>architecture</category>
      <category>backend</category>
      <category>reliability</category>
    </item>
    <item>
      <title>The contract is a rumor</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 21 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/the-contract-is-a-rumor-c12</link>
      <guid>https://dev.to/hidekimori/the-contract-is-a-rumor-c12</guid>
      <description>&lt;p&gt;Every client I write assumes nothing about the shape of what comes back. If I read a field, I handle the case where it's null. If I read a list, I handle the case where it's empty. I do this for every integration, including the ones that have put a value in that field every single time, for years.&lt;/p&gt;

&lt;p&gt;Not because the other side is careless. Because I didn't write the value. The moment a piece of data crosses a boundary I don't control, "it has always been there" stops being a statement about the future and becomes a statement about the past. A field that has never been null is a sample, not a guarantee. So I handle the null. There are no exceptions to this. It's the floor.&lt;/p&gt;




&lt;h2&gt;
  
  
  More than the floor
&lt;/h2&gt;

&lt;p&gt;Most of the time the floor never triggers. The field is present, the list is full, the call succeeds, and all that defensive code sits unused like a smoke detector. Good. That's what it's for.&lt;/p&gt;

&lt;p&gt;But some dependencies ask for more than the floor — not more handling of a good response's shape, but handling of the fact that the response lies about itself.&lt;/p&gt;

&lt;p&gt;I have integrated an API that returns HTTP 200 for everything, its own failures included. To learn whether the call actually worked you parse an integer out of the body — and that integer means different things depending on which deployment you're talking to, and it has quietly changed meaning from one date to another. For a large enough request the same endpoint sometimes answers in XML instead of JSON, and you scrape the status out of that instead. The transport already has a field for "did this work." The API declines to use it.&lt;/p&gt;

&lt;p&gt;I have integrated an API that returns its results as two separate lists — the ones that succeeded and the ones that failed — in no guaranteed order, to be reconciled against your request by id. That one is not a sin; serious platforms do it, and if you expect it, it's clean. But it punishes the obvious reading. Match by position instead of by id and you will, eventually, hand someone the wrong answer with complete confidence.&lt;/p&gt;

&lt;p&gt;I have integrated an API whose job state could run backwards: a job that was further along could fall back to an earlier state mid-flight, so "queued," "processing," and "done" were not the monotonic staircase the words implied.&lt;/p&gt;

&lt;p&gt;And I have integrated the other kind — the kind that reinvents a convention that already exists, inconsistently. Language codes that aren't language codes: one letter per language, concatenated, with a constant letter stuck on the end, so an ordinary pair becomes a three-character string you would never guess. Error messages written in a human language, as prose, for a human to read, inside an interface only a program will ever call. And no endpoint to ask what the service supports — so you keep a table of the supported options pasted by hand off a documentation page, with a comment recording the day you last checked it, because that table is the only copy you have, and it rots.&lt;/p&gt;

&lt;p&gt;The kind that unsettles me most is none of those. It's the major, reputable API — the one everyone would call well-behaved — that occasionally, silently, returns the wrong thing. You ask for the list of target languages and once in a while it hands you the source list. A field comes back subtly malformed. There is no error. The status is green, the shape is valid, the content is wrong. The only defense is to notice and retry, which means the bad response is invisible twice over: to you, because the retry papers over it, and to the vendor, because you never report the thing you quietly worked around.&lt;/p&gt;




&lt;h2&gt;
  
  
  None of that is the cost
&lt;/h2&gt;

&lt;p&gt;Here is the part it took me years to say plainly: writing all of that handling is not the cost. You write it once.&lt;/p&gt;

&lt;p&gt;The cost is that none of it stays put. The status code you special-cased may have been fixed already — and you won't know, because nothing tells you; you have to go and look. A behavior keyed to a date. A capability that changed between one version of a model and the next. The integration test that passed this morning told you about this morning. It did not tell you about the contract, because there is no stable contract underneath. There is only current behavior, and current behavior is a moving target.&lt;/p&gt;

&lt;p&gt;I once wrote that a system of mine is finished when there is nothing I can throw at it that reopens it. That is reachable for code I own. An integration is the mirror image — the system I can never close, because the other side keeps reopening it. My own logic I can make correct. Someone else's behavior I can only watch. The contract is a rumor. The behavior is the fact. And the fact changes while I'm not looking.&lt;/p&gt;




&lt;h2&gt;
  
  
  Not only on the wire
&lt;/h2&gt;

&lt;p&gt;For a long time I filed this under "APIs" — a property of things at the far end of a network call. It isn't. The dependency is not always an HTTP endpoint.&lt;/p&gt;

&lt;p&gt;Some of the worst-behaved things I integrate run inside my own process: a native engine, loaded through a binding, doing heavy work in C. When one of those fails there is no response to inspect, so the failure signal isn't a status code. It might be an exception carrying a cryptic hexadecimal code. A line on standard error. A process that exits with a number that means "aborted." Or a defunct entry in the process table that is never going to return at all. I have written code whose entire job is to watch the process list for a zombie and give up on its behalf.&lt;/p&gt;

&lt;p&gt;When the dependency is like that, "make it finish" stops meaning "retry the request." It comes to mean: convert the input into another format, run it through a different engine, then repair the output by passing it through a third — holding a license semaphore the whole way, because even the repair tool is metered. And the drift is the same as on the wire. A library that, in a new major version, will quietly corrupt a file if you read from it while you're writing to it. A document format a parser still won't fully accept, with a bug number you learn to swallow. Behavior that moved between versions, and a dated comment marking the spot where I noticed.&lt;/p&gt;

&lt;p&gt;So the subject was never "APIs." It's dependencies. A weird one makes you keep watching it, by whatever instrument it leaves you. Sometimes that instrument is an HTTP status code. Sometimes it's &lt;code&gt;ps&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The ugly client is the point
&lt;/h2&gt;

&lt;p&gt;This is why I am slow to adopt an official SDK when one appears after I've already built the integration by hand.&lt;/p&gt;

&lt;p&gt;An SDK encodes what the vendor believes their API does. My hand-written client encodes what it actually did — including the workarounds, and an SDK carries no workaround for behavior its authors don't believe in. Adopting it discards that record and hides the raw responses, which is exactly where the drift becomes visible. The convenience layers that promise to abstract away the differences between providers are the same trade in a larger box: they keep a table of who-supports-what, the table falls behind, and you end up hand-listing the exceptions on top of the abstraction anyway.&lt;/p&gt;

&lt;p&gt;And I should be honest about what this code looks like. It is not clean. It is a heap of special cases, string matches against error signatures, fallbacks to other engines, retries, and semaphores. The base classes under it carry compromises I would make differently today. I don't grade any of it on how it reads. I grade it on one thing: whether the class at the very edge — the one actually making the call — holds the next time the dependency does something new. It can be wrestling a dozen ugly truths into submission inside, and that's fine, as long as it doesn't break. The code is allowed to be as ugly as the reality it describes. Making it pretty usually means pretending the reality is prettier than it is.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two axes
&lt;/h2&gt;

&lt;p&gt;The floor and the watching are the same instinct aimed in two directions.&lt;/p&gt;

&lt;p&gt;The floor says: assume nothing about the shape of what comes back. The watching says: assume nothing about the stability of how it behaves. Shape and time. A well-behaved dependency lets you stop watching one axis — the shape is honest, or the behavior holds still. I have never found one that lets you stop watching both.&lt;/p&gt;

&lt;p&gt;So the work was never "handle the weird API." It is narrower than that, and more permanent. It is refusing to let "it has worked every time so far" quietly turn into "it will work." That sentence is true of a single null field and it is true of an entire dependency. A run that passes is a sample. It is never a proof.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>architecture</category>
      <category>backend</category>
      <category>reliability</category>
    </item>
    <item>
      <title>You can't tune your way to correct</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 14 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/you-cant-tune-your-way-to-correct-l7m</link>
      <guid>https://dev.to/hidekimori/you-cant-tune-your-way-to-correct-l7m</guid>
      <description>&lt;p&gt;I run a platform that spends most of its day calling other people's APIs. Translation engines, document services, model providers — for years my code has been the thing on the outside, sending work to systems I did not build and waiting to see what comes back. You learn a lot from that seat. Mostly you learn what other people's systems do under conditions their authors never pictured. And one pattern shows up so often that I have stopped treating it as bad luck and started treating it as the default failure of how we build.&lt;/p&gt;

&lt;p&gt;It comes with a lie I keep catching myself in, too. So this is about both: the failure I see from the outside, and the comfortable thing I tell myself when I am the one trying to fix it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Busy, and for no one
&lt;/h2&gt;

&lt;p&gt;The failure looks like this. I send a batch of work. It is slow. Some of it times out, so I do the obvious thing and send it again — and instead of getting better, everything gets worse. More requests time out, the answers come slower, and when I can see anything about the server at all, it is pinned at full load while almost nothing comes back.&lt;/p&gt;

&lt;p&gt;That last part is the strange one. The machine is busy. Every signal says it is working as hard as it can. And the useful output — the answers that actually reach me — has fallen to almost nothing. It is doing enormous work for no one: grinding through jobs I already gave up on, while the requests I still care about wait behind them. Full effort, no use. A server can run that way for a long time, and from the outside it looks alive right up until it doesn't.&lt;/p&gt;

&lt;p&gt;Once you have watched a system be busy and produce nothing, "CPU is at ninety percent" stops sounding like good news.&lt;/p&gt;




&lt;h2&gt;
  
  
  It was never mine to fix — and my test can't vouch for it
&lt;/h2&gt;

&lt;p&gt;My instinct, the first few times, was to fix it from my side, the way a caller does. None of it worked, and it all failed for the same reason.&lt;/p&gt;

&lt;p&gt;I can cancel my own work when I give up. I cannot make the next caller do it. I can set myself a sane limit on how long to wait; that does nothing about some other caller that sets none and keeps the server's hands full. I can slow myself down, and my own corner settles — until someone else shows up and undoes it. Every lever I have moves only me. The thing that is actually breaking lives in one place, and it is not the place I am sitting.&lt;/p&gt;

&lt;p&gt;But there is a subtler version of the same helplessness, and it cuts toward the real point. Even when my work goes through cleanly, my success proves almost nothing about the server. It tells me the server survived &lt;em&gt;my&lt;/em&gt; pattern — my sizes, my rate, my particular shape of load. It says nothing about the next caller's pattern. From the outside I can sometimes feel this directly: the server's behavior depends on what I happen to throw at it. Which is only another way of saying the thing was never closed.&lt;/p&gt;




&lt;h2&gt;
  
  
  The lie
&lt;/h2&gt;

&lt;p&gt;Here is the lie, and I tell it to myself more than I would like to admit. When something is overloaded, every instinct reaches for a number. A bigger buffer. A longer wait. A limit of two-at-a-time on this component instead of three. And the dangerous part is that one of them often makes the symptom go away. The alert clears, the dashboard turns green, and I get to say I fixed it.&lt;/p&gt;

&lt;p&gt;I didn't. I found a number that fit the pattern I happened to be testing. That is not the same as fixing the system, and the gap between the two is exactly where everything later goes wrong.&lt;/p&gt;

&lt;p&gt;Picture the most ordinary version of it. You put a limit on each worker — two jobs at a time, say — rather than on the shared thing they all pull from. Under your test, where the work lands mostly on a few workers, it passes: nothing starves, nothing is overrun. Then the work spreads across more workers than you tested with. Each one is still under its own limit, behaving perfectly — and together they overrun the shared resource that no one was counting. The per-worker number was a guess about how the work would be distributed. The one limit that actually governed safety — the total the shared resource could bear — was never written down anywhere.&lt;/p&gt;

&lt;p&gt;This is why tuning is worse than merely useless. The number you land on is fit to one distribution of load. Change the distribution and it doesn't just stop helping; it turns against you. Set it generously and a different pattern overruns the shared resource. Set it conservatively to be safe, and now the common case is throttled for a danger that only appears in a case you never see — so you pay in wasted capacity, everywhere, to paper over a limit you never actually wrote. And none of it survives the day the load looks different, which, with enough callers, is every day.&lt;/p&gt;

&lt;p&gt;The honest question is never "did that number help?" It is colder: &lt;em&gt;does this hold for any pattern, or only the one I tried?&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Closed, not tuned
&lt;/h2&gt;

&lt;p&gt;Strip it all down and there is one distinction underneath, and it is almost embarrassingly plain.&lt;/p&gt;

&lt;p&gt;There is a difference between a number that passes your test and a system that is closed. A system is closed when its behavior is bounded by logic that holds for &lt;em&gt;any&lt;/em&gt; input — an invariant you can state and defend — rather than by parameters fit to the cases you happened to try. The whole of "a server defending itself" is just one such invariant made concrete: never take on more than you can finish, and refuse the rest, fast and honestly, no matter who is calling. Bounding the shared resource instead of each worker is another. Closing the logic means finding the invariant that was missing and writing it down — not searching for a kinder number. The plainest way to find those missing invariants is to interrogate the parts you already have: of every buffer, every extra component, every knob, ask what invariant it is standing in for. If you cannot name the one it protects, it is protecting nothing — and it should not be there.&lt;/p&gt;

&lt;p&gt;And the closed version is almost always &lt;em&gt;simpler&lt;/em&gt; than the pile of tuned knobs it replaces. One limit on the thing that actually runs out beats a dozen per-component guesses that only agree with each other under the load you tested. People reach for the knobs because each one is small and immediate, and closing the logic means stopping to ask what is really true for every input. But a stack of numbers that happen to get along today is not a design. It is a postponement.&lt;/p&gt;

&lt;p&gt;Reaching that simplicity is its own kind of progress — one of the largest there is. A part you can take out, and find the system holds without it, is a failure mode gone, a knob gone, one less thing to keep alive at three in the morning.&lt;/p&gt;




&lt;h2&gt;
  
  
  What it comes down to
&lt;/h2&gt;

&lt;p&gt;From my chair — the one that has called more of other people's APIs than I can count — the single most useful habit I have is to distrust a green test. Mine or theirs. A passing run is a sample, not a proof. The number that made it work only tells me it worked once, for what I happened to send.&lt;/p&gt;

&lt;p&gt;A system is finished when there is nothing I can throw at it that reopens it. That is never a number you tuned. It is a piece of logic someone had to sit down and close — and the quiet, unglamorous truth is that almost nobody does, because the dashboard was already green, and green feels like done.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>architecture</category>
      <category>backend</category>
      <category>reliability</category>
    </item>
    <item>
      <title>How I climbed Java's concurrency staircase, one frustration at a time</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 07 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/how-i-climbed-javas-concurrency-staircase-one-frustration-at-a-time-aj8</link>
      <guid>https://dev.to/hidekimori/how-i-climbed-javas-concurrency-staircase-one-frustration-at-a-time-aj8</guid>
      <description>&lt;p&gt;I bought my first Java book when I was 23. I wasn't trying to "study concurrency," or "learn threads," or check any box. I wanted to make a nonogram — a paint-by-numbers logic puzzle, the kind where number clues along the rows and columns tell you which cells to fill in to reveal a hidden picture.&lt;/p&gt;

&lt;p&gt;What I'll add up front is the pace. I'd just quit my previous job, so I had nothing but time, and I spent almost all of it studying — every single day. It came to about six months, though I never set that as a target; it's simply when I started wondering whether I could find work in Java. Progress was embarrassingly slow. I'll say this plainly because it matters: nothing came quickly for me. What I had was time, daily repetition, and something I wanted to build.&lt;/p&gt;

&lt;p&gt;Looking back, that's the whole story. Everything I know about concurrency, I learned because something I was building stopped working, and the only way forward happened to be the next concept up. I never read the threading chapter because it was next in the book. I read it because I wanted a stop button.&lt;/p&gt;

&lt;p&gt;This is a description of that staircase — the one Java quietly built for me, where each step was pulled into existence by a problem on the step below it. I think the staircase matters more than any single step. So let me walk up it the way I actually climbed it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: The nonogram that started everything
&lt;/h2&gt;

&lt;p&gt;The first version was small. A &lt;code&gt;main&lt;/code&gt; method, a GUI, a grid. The user clicks a cell to fill it in or clear it, the state changes. When the grid is complete, a bit of logic checks whether the filled cells match the clues.&lt;/p&gt;

&lt;p&gt;Nothing here is hard. It's all single-threaded, top to bottom: an event comes in, I change some state, I redraw. There is exactly one thing happening at a time, and I never once had to think about &lt;em&gt;who else&lt;/em&gt; might be touching my data, because nobody else was. This was the safe ground floor, though I didn't know to call it that yet.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Teaching it to solve itself
&lt;/h2&gt;

&lt;p&gt;Then I got ambitious. I wanted the program to solve the puzzle on its own. So I wrote a solver — backtracking, the usual thing — and then I spent an embarrassing amount of time optimizing it. Pruning, ordering, little tricks to cut the search space. This was my first real taste of the fact that &lt;em&gt;my own logic is the thing I trust least&lt;/em&gt;. I'd be sure the solver was fast, run it, and watch it crawl.&lt;/p&gt;

&lt;p&gt;But it was still all on the ground floor. One thread, doing one long calculation. And that turned out to be the problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: I wanted a stop button
&lt;/h2&gt;

&lt;p&gt;The solver was slow on hard boards. And here's the thing that pushed me up the first step: while it was grinding away, the whole window froze. I couldn't click anything. I couldn't cancel. I just had to sit there and watch it think.&lt;/p&gt;

&lt;p&gt;I wanted a stop button. That's it. That was the entire motivation. Not "I should learn multithreading." Just: &lt;em&gt;I want to be able to click Cancel while it's running.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I want to underline this, because it's the pattern that repeats the whole way up. I never went looking for the next concept. The next concept came looking for me, wearing the costume of a small, concrete annoyance.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Entering threads, because I had no choice
&lt;/h2&gt;

&lt;p&gt;To have a stop button, I needed the solver to &lt;em&gt;not&lt;/em&gt; be the only thing running. The UI had to stay alive to receive my click while the solver was busy. There was no way around it: I had to learn threads.&lt;/p&gt;

&lt;p&gt;So I learned the smallest amount I needed. The solver moved into a &lt;code&gt;new Thread&lt;/code&gt;. Inside its loop, on each iteration, it checked a &lt;code&gt;volatile boolean&lt;/code&gt; stop flag. The UI thread — the one that was now free, because the heavy work had moved off it — could flip that flag when I clicked Cancel. The solver would see it on its next pass and bail out.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// roughly what I wrote, 23 years old and delighted&lt;/span&gt;
&lt;span class="kd"&gt;volatile&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;stopRequested&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Thread&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Runnable&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;solved&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;stopRequested&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;stepTheSolver&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}).&lt;/span&gt;&lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It worked. The window stayed responsive. I could cancel. I felt like a wizard.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: What that actually taught me
&lt;/h2&gt;

&lt;p&gt;I would not have said, then, that I "understood threads." I didn't. There were whole categories of things I had no idea about — that the UI events were themselves running on a thread, that there were rules about touching UI components from background threads, all of it invisible to me.&lt;/p&gt;

&lt;p&gt;But I learned one real thing, and it was the right first thing: &lt;strong&gt;if you make another thread, something can happen in parallel, and one can interrupt the other.&lt;/strong&gt; The heavy work and the responsiveness no longer had to take turns. That single idea — that "at the same time" is now possible — is the doorway into the entire rest of the staircase.&lt;/p&gt;

&lt;p&gt;And notice &lt;em&gt;how&lt;/em&gt; I walked through that doorway. I had to write &lt;code&gt;new Thread&lt;/code&gt;. It was an explicit, visible act. There was a clear moment where I crossed from "one thing at a time" into "more than one thing at a time," and because I had to write it down, I &lt;em&gt;knew&lt;/em&gt; I'd crossed it. Hold onto that. It's going to matter at the top of the stairs.&lt;/p&gt;




&lt;h2&gt;
  
  
  An aside: the day Java became a friend
&lt;/h2&gt;

&lt;p&gt;Somewhere in those six months, after a long stretch of being simply slow — copying examples, half-understanding them, forgetting, trying again — one thing finally clicked: &lt;em&gt;instances&lt;/em&gt;. The idea of an object as a thing that exists, that more than one name can point to, that gets shared. It just settled into place one day. I couldn't tell you what triggered it. But after that, Java felt like a friend instead of a stranger.&lt;/p&gt;

&lt;p&gt;I mention it here, right before the servlet story, because that click is what made the next step possible. When the member variable bit me, I recognized it almost instantly — &lt;em&gt;oh, this instance is being shared&lt;/em&gt; — precisely because instances had finally become real to me. The slow months bought me that recognition.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: The member variable that bit me
&lt;/h2&gt;

&lt;p&gt;Some time later I bought a book on servlets. I wanted to make things on the web. I did the incantation everyone does — &lt;code&gt;extends HttpServlet&lt;/code&gt;, implement &lt;code&gt;doGet&lt;/code&gt; — without really understanding what the container was doing underneath. I was building a little web chat — and I should say why, because it's close to the whole reason any of this happened. Back then, the chat rooms everyone hung out in were written in Perl. I'd spent countless hours in them. I wanted, badly, to build one of my own.&lt;/p&gt;

&lt;p&gt;I wanted to show the logged-in user's name on the page. So I did the obvious-looking thing: I stashed it in a field on the servlet. A member variable. It worked perfectly when I tested it alone.&lt;/p&gt;

&lt;p&gt;Then more than one person used it at the same time, and the names got crossed. My name showed up on someone else's page. The state was a mess.&lt;/p&gt;

&lt;p&gt;Here's what I want to be precise about, because it's a thing people still get wrong: &lt;strong&gt;a servlet is, by default, a single instance.&lt;/strong&gt; The container makes &lt;em&gt;one&lt;/em&gt; of your servlet objects and runs every request through it. So every request — every thread — is calling &lt;code&gt;doGet&lt;/code&gt; on the &lt;em&gt;same instance&lt;/em&gt;, sharing the &lt;em&gt;same&lt;/em&gt; fields. A member variable being &lt;code&gt;static&lt;/code&gt; was never the requirement. An ordinary instance field is already shared across every concurrent request, because the instance itself is shared. (This hasn't changed, by the way. Even today, a Spring &lt;code&gt;@Controller&lt;/code&gt; is a singleton by default. Same trap, newer paint.)&lt;/p&gt;

&lt;p&gt;I figured this out slowly — first a vague hunch that the member variable was the problem, that maybe these &lt;code&gt;doGet&lt;/code&gt; calls were stepping on each other. But because my grasp of the language itself was decent by then, the vague hunch hardened into a rule almost immediately. &lt;em&gt;Don't put per-request state on a shared instance.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And that's the step where the abstraction climbed. I stopped thinking about the specific bug and started thinking about the principle underneath it: &lt;strong&gt;who is this instance shared with? What is its scope?&lt;/strong&gt; That question — not the patch — is what I carried up to the next step.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 7: The database, and the things you only learn in production
&lt;/h2&gt;

&lt;p&gt;After that, the pace of new things to learn went vertical. A real database — MySQL. What a &lt;code&gt;PreparedStatement&lt;/code&gt; is and why string-concatenating SQL is a way to get yourself hurt. Resource management — closing what you open, every time, even when something throws.&lt;/p&gt;

&lt;p&gt;And one thing I want to call out specifically: connection pools. The importance of a connection pool is, in my experience, something you genuinely cannot learn from a personal project. You learn it in production, when real load arrives and you discover that opening a fresh connection per request falls over. Some lessons are only available on the job — this is one of them. The conditions that teach it, real load and real traffic, simply aren't there in a side project.&lt;/p&gt;

&lt;p&gt;Underneath all of it, though, the same shape from Step 6 kept showing up: &lt;em&gt;something is shared; who's allowed to touch it, and when?&lt;/em&gt; A connection. A pooled resource. The questions rhymed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 8: The batch that wouldn't finish — and the anticlimax
&lt;/h2&gt;

&lt;p&gt;Then I hit a wall that sent me to the last step I'll describe here. I had a batch job, and it would not finish in reasonable time. It was running on the &lt;code&gt;main&lt;/code&gt; thread — which meant it was using exactly one CPU. One core, on a machine that had several, all the rest sitting idle while my one core sweated.&lt;/p&gt;

&lt;p&gt;This was the Java 1.3 days, before &lt;code&gt;ExecutorService&lt;/code&gt; existed, so there was no thread pool to reach for. I built something that barely deserves the name: I'd start a fixed batch of threads — sixteen or so — and each time one finished, the main thread would slot a fresh one into its place. No shared queue, no synchronized blocks. Just the main thread, by hand, keeping a fixed number of threads busy.&lt;/p&gt;

&lt;p&gt;And here's what surprised me. After all the dread — multithreading had this reputation as the scary, advanced thing — what I actually wrote was &lt;em&gt;almost disappointingly simple&lt;/em&gt;. A fixed array of threads, refilled from the main thread as each one finished. That was it. The anticlimax was the lesson: the thing I'd been intimidated by, once I'd climbed the steps below it, was small.&lt;/p&gt;

&lt;p&gt;I watched the batch server's cores light up — all of them, finally working — and I was completely satisfied. I'd taken a single-core crawl and spread it across the machine, with code I understood line by line, because I'd written every line.&lt;/p&gt;




&lt;h2&gt;
  
  
  The staircase, from where I am now
&lt;/h2&gt;

&lt;p&gt;From where I stand now, looking back, it's one continuous staircase, and every step was carved by the step below it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The nonogram solver was &lt;strong&gt;slow&lt;/strong&gt;, so I wanted a &lt;strong&gt;stop button&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The stop button needed &lt;strong&gt;threads&lt;/strong&gt;, so I learned &lt;code&gt;new Thread&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Threads taught me &lt;strong&gt;"at the same time" is possible&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The servlet &lt;strong&gt;member variable&lt;/strong&gt; got crossed, so I learned &lt;strong&gt;scope&lt;/strong&gt; — who shares this instance.&lt;/li&gt;
&lt;li&gt;Scope and resources led into &lt;strong&gt;the database, pools, resource management&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;batch&lt;/strong&gt; wouldn't finish on one core, so I wrote a &lt;strong&gt;thread pool&lt;/strong&gt; by hand.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But the deeper thing — the thing I only saw much later — is that the whole climb was &lt;em&gt;one principle&lt;/em&gt;, asked over and over at higher and higher altitude:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who is pointing at this thing? Who shares it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It starts even earlier than threads, actually. The very first version of this question is the one every beginner hits: &lt;em&gt;"Wait — why did this value change? Who changed it?"&lt;/em&gt; — and the answer is the method you passed the object into. That's references. Two names pointing at one object. Once you understand &lt;em&gt;that&lt;/em&gt;, you can understand "two requests pointing at one servlet instance" (Step 6), and then "two threads pointing at one piece of shared memory" (the data race). References → scope → concurrency. It's the same question, climbing.&lt;/p&gt;

&lt;p&gt;Each stumble didn't teach me a patch. It taught me the next altitude of that one question. That's what I mean by a staircase: the frustrations were load-bearing.&lt;/p&gt;




&lt;h2&gt;
  
  
  If you're just starting out
&lt;/h2&gt;

&lt;p&gt;Looking back, there's something I wish someone had told me.&lt;/p&gt;

&lt;p&gt;I can still hold threads in my hands today. Not because I'm clever about concurrency — I'd never claim to have fully mastered it. It's because I started with a nonogram stop button at 23, and climbed one frustrated step at a time, and never skipped one.&lt;/p&gt;

&lt;p&gt;And here's the part I most want you to keep. I am not a gifted programmer. I wasn't fast, and most of what I learned came from repetition rather than flashes of insight. People with real talent for this exist, and I don't try to compete with them on raw ability. What I had was six months, the willingness to show up every day, and the refusal to skip steps.&lt;/p&gt;

&lt;p&gt;That's the whole secret, and it isn't one. Which means you can do it too — almost certainly faster than I did. You probably have more going for you than I did. Just don't skip the steps.&lt;/p&gt;

&lt;p&gt;There's one last thing underneath all of it, though — the fuel. I could show up every day for six months because there was something I genuinely wanted to make: my own version of those Perl chat rooms. It was a long, winding detour to get there. But I don't think the discipline works without it. Without something you actually want to build, you won't keep going. So that's the real prerequisite, the one beneath all the others: find a thing you want badly enough to climb a whole staircase for.&lt;/p&gt;

&lt;p&gt;The frustrations were the staircase. I just had to keep walking up.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>concurrency</category>
      <category>beginners</category>
      <category>career</category>
    </item>
    <item>
      <title>When infrastructure was physical</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 31 Aug 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/when-infrastructure-was-physical-g33</link>
      <guid>https://dev.to/hidekimori/when-infrastructure-was-physical-g33</guid>
      <description>&lt;p&gt;I was once happy — properly happy — about a load balancer.&lt;/p&gt;

&lt;p&gt;It was an NEC appliance, a proper pair in active and hot standby, and what made it a relief was something small and specific. Until then the web tier ran behind a software load balancer I'd wedged onto one of the servers. It did the job — but when I took a backend out of rotation, it cut the connections still open along with it, and somewhere a user watched their browser fill with an error. I hated that out of all proportion to what it was. What I wanted, badly, was a balancer that would let the requests already in flight finish before it let a server go. When one finally arrived — draining each backend quietly before it dropped it — I was happier than a piece of network hardware has any right to make me.&lt;/p&gt;

&lt;p&gt;The service it fronted ran on three rackmounted machines — Web1, Web2, and a MySQL box — the first rackmount servers I ever put my hands on. They were where my working life started.&lt;/p&gt;

&lt;p&gt;I don't think I've felt that exact happiness about infrastructure in a long time. I want to describe what it was, because most of it is gone now, and I'm not certain the trade was free.&lt;/p&gt;

&lt;p&gt;Back then, infrastructure was something you committed to before it existed. You chose a datacenter. You worked out the weight your racks were allowed to bear and the power you would draw, signed a contract for that power, and guessed — months ahead — how much you would actually need. Bandwidth was expensive, so you forecast your traffic and paid for a pipe sized to a number you had half made up. Every decision was a bet placed before the thing it was betting on arrived. You paid, in money and floor space and your own time, for load that hadn't shown up yet.&lt;/p&gt;

&lt;p&gt;Then the machines had to physically get there. I once had an Oracle Exadata that wouldn't fit through the datacenter door; we widened the opening to bring it in and reinforced the floor where it would stand. I kept its load average pinned as low as I could manage — the kind of attention you give something you feel responsible for. You don't forget carrying that much weight into a room.&lt;/p&gt;

&lt;p&gt;Some of the fleet was scavenged. A stack of blades left over from a project that hadn't worked out got a second life as something else entirely — hardware outliving the idea it was bought for. Other machines existed only because of limits: a dense, low-power blade chassis that made sense only because a fully loaded rack could pull thirty-eight kilowatts, and power and floor space were what you ran out of first.&lt;/p&gt;

&lt;p&gt;Nobody could tell me how much data we'd have in three years, and there was no S3 to wave the question away. So I read everything I could and landed on Isilon, because it could grow while it stayed online. There was no one to ask; you found these things yourself, or you didn't find them at all.&lt;/p&gt;

&lt;p&gt;It wasn't all the thrill of it, though there was thrill. It was also a long argument with failure. Most failures were almost friendly — a disk lamp on a RAID6 array you could practically ignore, or half a power feed dying while the redundancy quietly absorbed it. You learned not to fear those.&lt;/p&gt;

&lt;p&gt;The ones that got you were the unplanned ones. I remember a router that started failing with nothing behind it — no second unit, nothing to fail over to, nothing to do but the thing you didn't want to do. I closed my eyes, pulled the cable, swapped in the spare, and waited to learn whether I had just made it worse. Those were the ones that wore on you. There was no pager rotation. You were the redundancy.&lt;/p&gt;

&lt;p&gt;None of this was ever quite mine alone. I wrote the software, but the machines I ran it on were built and racked by someone else — we had one infrastructure engineer, and the iron was his before it became mine. And someone above the two of us decided to turn a pair of engineers loose on that much expensive equipment in the first place. I didn't think of any of it as a gift while it was happening. It was one.&lt;/p&gt;

&lt;p&gt;The cloud took all of this away, and I'm mostly grateful. I don't miss forecasting bandwidth, or widening doorways, or pulling cables with my eyes shut. Capacity is elastic now — it comes when you ask and goes when you stop, and no one bets the floor on a guess.&lt;/p&gt;

&lt;p&gt;But something left with it. When infrastructure was physical, it was also yours, in a way a call to someone else's datacenter never quite is — something that could thrill you and wear you down in the same week, because it was close enough to put your hands on. I wouldn't go back. I just remember being happy about a load balancer, and I notice I don't feel that about anything anymore.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>infrastructure</category>
      <category>devjournal</category>
    </item>
    <item>
      <title>The work of staying</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 24 Aug 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/the-work-of-staying-10c9</link>
      <guid>https://dev.to/hidekimori/the-work-of-staying-10c9</guid>
      <description>&lt;p&gt;There is a system I wrote years ago that is still running. I'd struggle to tell you what was hard about building it. That part took a few weeks, and weeks blur. What I remember is everything that came after: the data that arrived in shapes I hadn't planned for, the edge case that surfaced in year three, the small fixes made late at night because something real depended on it staying up.&lt;/p&gt;

&lt;p&gt;Building software is a blink. The life of the thing you built — the months and years it spends in contact with live data and real users — is where almost all of the engineering happens. It's also the only place I've ever learned whether my decisions held up.&lt;/p&gt;

&lt;p&gt;I want to be careful here, because this is easy to turn into a hierarchy and it isn't one. I was lucky. I happened to stay on the operating side — the side that keeps things running instead of building them and moving on. If my career had sent me from one project to the next, building and handing off, I don't know what kind of engineer I'd be today. That isn't a claim about ability. It's a claim about which side of a structure you land on.&lt;/p&gt;

&lt;p&gt;Here is the structure. When you build something and then leave, the verdict on your decisions still arrives — but it arrives at someone else's desk. The shortcut you took, the abstraction you chose, the thing you were certain would never need to change: you find out whether you were right long after you're gone, and the verdict never makes its way back to you. The feedback loop is severed. Not because you did anything wrong, but because the structure you worked inside never closes the loop to the person who'd learn from it.&lt;/p&gt;

&lt;p&gt;This happens anywhere software is built and handed off, which is everywhere. But I watched it at its most concentrated here in Japan, where many engineers sit on the vendor side of that handoff, building systems that other companies will run. The loop isn't cut more deeply here; it's cut for far more people. I don't think of it as a Japanese problem. It's a structural one, and Japan is simply where I saw it densest.&lt;/p&gt;

&lt;p&gt;Staying, though, isn't the whole of it. Being on the operating side hands you the loop; it doesn't make you use it. Almost every engineer who inherits an old system wants, at some point, to stand back and call it ugly — to narrate its flaws instead of touching them. It's a comfortable place to stand, and I've stood there myself more than once. The system is bad, and you're the one clear-eyed enough to see it.&lt;/p&gt;

&lt;p&gt;I once knew an engineer who had owned a system for a long time. For five years he'd been saying it was old, that it was behind, that it wasn't built the way things ought to be built. I remember asking him whether he hadn't had those five years himself.&lt;/p&gt;

&lt;p&gt;I didn't mean it as an accusation, and I don't repeat it as one. Complaining is not maintaining. For as long as something is in your hands, its condition is yours — not the condition you inherited, but the one it's in now, because the time to change it was the whole time you were holding it. That was true of him. It's true of me. And it's the sharpest form I know of a plainer idea: one person can be a complete unit of responsibility.&lt;/p&gt;

&lt;p&gt;None of this is an argument against trying things. I do it too — small integrations, things put together over a weekend to see if they'd work. I'm not standing outside that world; I live in it. The difference I care about is quieter. When I build one of those small things, I build it as if it will have to run: error handling, retries, concurrency, cost, all in view from the first line. "It worked, see how easy" is a different sentence from "it has been working." Operation trains a muscle that exploration can't, and neither is the lesser. You want both.&lt;/p&gt;

&lt;p&gt;For the past five years I've kept a single platform running, and I never set out to make it into what it became. It began as translation. Staying with it — answering what real documents and real volume kept throwing at it — turned translation into AI rewriting, then into structured output, then into something I'd now call document processing. I didn't design my way to any of that. I arrived at it by not leaving. The discovery came from the staying.&lt;/p&gt;

&lt;p&gt;The work of staying is mostly invisible. It doesn't demo well — there's no launch, no before-and-after, only a thing that is quietly still alive. I don't expect it to be widely felt. But anyone who has stayed with one thing long enough will know what I mean, and this is just me naming it out loud, so the few who do can know it was seen.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>career</category>
      <category>devjournal</category>
    </item>
    <item>
      <title>The doors were already there</title>
      <dc:creator>Hideki Mori</dc:creator>
      <pubDate>Mon, 17 Aug 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/hidekimori/the-doors-were-already-there-pji</link>
      <guid>https://dev.to/hidekimori/the-doors-were-already-there-pji</guid>
      <description>&lt;p&gt;I recently wrote that the translation system I work on had quietly become something larger — that turning a forty-year-old translation company into document-AI was less a pivot than one definition followed to its end.&lt;/p&gt;

&lt;p&gt;But being right about an idea is the cheap part. The expensive part is whether your system can act on it without being torn open. The reason that shift cost almost nothing — the reason "structure this contract" was a parameter and not a project — is that the places for it to attach were already there, drawn years earlier. This piece is about those places.&lt;/p&gt;




&lt;h2&gt;
  
  
  Naming the category, not the instance
&lt;/h2&gt;

&lt;p&gt;Five years ago, building the layer that talks to translation engines, I made a choice that bought me nothing at the time. I didn't write a connector for "the machine-translation vendors we use." I wrote one for "an engine that transforms text," and made the translation vendors one kind of it.&lt;/p&gt;

&lt;p&gt;The reason was duller than foresight. Translation was already not the only thing we did — we had summarization engines too. "Text transformation" was simply the accurate name for the category we were already in, and naming the instance — "translation" — would have been a lie I'd have to walk back later.&lt;/p&gt;

&lt;p&gt;That accuracy had a price. A "translation connector" would have been simpler that week. Choosing the true, wider name meant more structure for no immediate gain, and a payoff that was years away and, at the time, entirely hypothetical.&lt;/p&gt;




&lt;h2&gt;
  
  
  What slots in without a fight
&lt;/h2&gt;

&lt;p&gt;When generative AI arrived, it slotted in.&lt;/p&gt;

&lt;p&gt;A new provider is a new subtype. The genAI engines attached as a sibling to the machine-translation branch, under the same text-transformation base; nothing above them had to learn they had come.&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.amazonaws.com%2Fuploads%2Farticles%2Fuybs90chmuijebwrkbwg.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.amazonaws.com%2Fuploads%2Farticles%2Fuybs90chmuijebwrkbwg.png" alt="Connector class hierarchy: a Connector interface, a Text-Transformation base beneath it, with Machine-Translation and Generative-AI connectors as sibling subtypes, each over its own per-vendor implementations." width="800" height="557"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The opening was older than the thing that eventually occupied it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A new model is a row and a number. I work out its price from one rule and add a line to the billing config; the metering and the plans inherit it, and the engine never learns the model's name — it reads it from a table.&lt;/p&gt;

&lt;p&gt;A new service is a tenant. It brings no billing, no gateway rules, no "text or a file, never both" contract of its own — those are the building it moves into, not luggage it carries.&lt;/p&gt;

&lt;p&gt;None of this was designed for the thing that arrived. The point was never to guess the future. The point was to leave a correctly-shaped opening, so that whatever showed up could be received instead of integrated.&lt;/p&gt;




&lt;h2&gt;
  
  
  The part you can't buy late
&lt;/h2&gt;

&lt;p&gt;Here is the uncomfortable thing about extensibility: it is the one property you cannot add after you need it.&lt;/p&gt;

&lt;p&gt;You can add a feature late. You can claw back performance late. You can even add tests after the fact. But the seam that lets a new thing attach without disturbing the old ones has to exist before the new thing does — which means building it when there is no new thing, no payoff, and every reason to skip it. The whole cost is paid up front, on speculation, in the dark. By the time the future arrives and you wish you had the seam, it is too late to have had it cheaply. Now you are rebuilding the thing you could have shaped for almost nothing.&lt;/p&gt;

&lt;p&gt;So nobody who says "it just plugged in" was lucky. They paid early for an opening they could not yet justify, named the wider category instead of the easy one, and then waited — sometimes for years — to learn whether they had guessed the shape right.&lt;/p&gt;




&lt;h2&gt;
  
  
  What foresight comes down to
&lt;/h2&gt;

&lt;p&gt;I did not see generative AI coming. That matters, because it is the whole point. I saw something far smaller: that "translation" was not the most general thing I would ever be asked to do, and that betting the system on the narrowest version of what I did was a bad trade.&lt;/p&gt;

&lt;p&gt;That is most of what foresight comes down to. Not seeing what is coming — building so you don't have to. The generalization in the last piece looked like a discovery. Underneath, it was just a door I had left open five years earlier, finally being walked through.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Claude (Opus).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>architecture</category>
      <category>programming</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
