<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Architecture on PHP Boy Scout</title><link>https://blog-570662.gitlab.io/tags/architecture/</link><description>Recent content in Architecture on PHP Boy Scout</description><generator>Hugo -- gohugo.io</generator><language>en-gb</language><copyright>Matt Cockayne</copyright><lastBuildDate>Sun, 17 May 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://blog-570662.gitlab.io/tags/architecture/index.xml" rel="self" type="application/rss+xml"/><item><title>One graph, not micro-stacks</title><link>https://blog-570662.gitlab.io/one-graph-not-micro-stacks/</link><pubDate>Sun, 17 May 2026 00:00:00 +0000</pubDate><guid>https://blog-570662.gitlab.io/one-graph-not-micro-stacks/</guid><description>&lt;img src="https://blog-570662.gitlab.io/one-graph-not-micro-stacks/cover-one-graph-not-micro-stacks.png" alt="Featured image of post One graph, not micro-stacks" /&gt;&lt;p&gt;Once an infrastructure repo has a few concerns in it (account hardening, the security baseline, the signing stack still to come) there&amp;rsquo;s a steady pressure to split them into separate stacks with separate state, and Terragrunt is right there to help you do it. The &lt;code&gt;infra&lt;/code&gt; repo keeps everything in one OpenTofu graph instead. The reason comes down to who enforces your dependency ordering: the engine, or you.&lt;/p&gt;
&lt;h2 id="the-pressure-to-split"&gt;The pressure to split
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;infra&lt;/code&gt; repo&amp;rsquo;s &lt;code&gt;src/&lt;/code&gt; has several concerns in it, and more coming, the signing stack among them. Once a repo reaches that point, there&amp;rsquo;s a steady pressure to split: one stack per concern, each with its own state file.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s an appealing pressure. Separate stacks feel modular. Each &lt;code&gt;apply&lt;/code&gt; touches less, so the blast radius of any one run is smaller. And Terragrunt exists, popular and well-regarded, precisely to orchestrate a fleet of separate stacks. The path is well trodden.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;infra&lt;/code&gt; didn&amp;rsquo;t take it. &lt;code&gt;src/&lt;/code&gt; is a single OpenTofu root stack: each concern is a &lt;code&gt;module&lt;/code&gt; block, in its own &lt;code&gt;main.&amp;lt;concern&amp;gt;.tf&lt;/code&gt; file, all sharing one state and one graph.&lt;/p&gt;
&lt;h2 id="what-one-graph-gives-you"&gt;What one graph gives you
&lt;/h2&gt;&lt;p&gt;The thing a single graph gives you is engine-enforced truth about ordering and data.&lt;/p&gt;
&lt;p&gt;Inside one OpenTofu graph, the tool builds the full dependency DAG itself. When the signing stack needs a value the security baseline produced, you reference it directly, &lt;code&gt;module.baseline.something&lt;/code&gt;, and OpenTofu &lt;em&gt;guarantees&lt;/em&gt; two things: the baseline is created before the thing that depends on it, and the value handed across is the current one from this same apply. Ordering and data-passing aren&amp;rsquo;t things you arranged. They&amp;rsquo;re facts the engine checks and enforces, every plan, every apply.&lt;/p&gt;
&lt;h2 id="what-splitting-costs"&gt;What splitting costs
&lt;/h2&gt;&lt;p&gt;Split &lt;code&gt;src/&lt;/code&gt; into per-concern stacks with separate state, and that guarantee is the thing you spend.&lt;/p&gt;
&lt;p&gt;Now one stack reads another&amp;rsquo;s outputs through &lt;code&gt;terraform_remote_state&lt;/code&gt;. That&amp;rsquo;s a lookup of a &lt;em&gt;snapshot&lt;/em&gt;: the other stack&amp;rsquo;s last applied state, whatever it was, whenever that was. It&amp;rsquo;s not a live edge in a graph. Ordering is no longer enforced by the engine either; it becomes something you arrange yourself, in CI stage sequencing or in Terragrunt&amp;rsquo;s own dependency blocks.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the trade, stated plainly. You give up a strong, engine-checked guarantee, and you buy back a weaker, hand-arranged imitation of it. Terragrunt is a good tool for managing that weaker world tidily. But the question worth asking first is whether you should be in the weaker world at all.&lt;/p&gt;
&lt;h2 id="when-splitting-is-genuinely-right"&gt;When splitting is genuinely right
&lt;/h2&gt;&lt;p&gt;This isn&amp;rsquo;t an argument that splitting is always wrong. Separate states genuinely earn their place when concerns have different change cadences, different access boundaries, or different teams owning them: when you actively &lt;em&gt;want&lt;/em&gt; an apply of one to be unable to touch another, and you want different people holding different state.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;infra&lt;/code&gt; has none of those. It&amp;rsquo;s a single account, a single operator, one cohesive set of concerns. The only thing splitting would buy here is a smaller per-apply blast radius, and that&amp;rsquo;s better handled by reviewing the plan before it applies, which &lt;a class="link" href="https://blog-570662.gitlab.io/reviewed-then-applied/" &gt;the next post&lt;/a&gt; is about, than by fragmenting the dependency graph. So &lt;code&gt;src/&lt;/code&gt; stays one graph, and Terragrunt was considered and deliberately not adopted.&lt;/p&gt;
&lt;h2 id="if-ordering-between-graphs-is-ever-needed"&gt;If ordering between graphs is ever needed
&lt;/h2&gt;&lt;p&gt;If &lt;code&gt;infra&lt;/code&gt; ever does genuinely need more than one stack, the plan isn&amp;rsquo;t Terragrunt. It&amp;rsquo;s to keep each stack a single strong graph internally, and to sequence the stacks with CI stages. Keep the engine-enforced guarantee where it&amp;rsquo;s strongest, inside each graph, and reach for hand-arranged ordering only at the one seam where it&amp;rsquo;s unavoidable.&lt;/p&gt;
&lt;h2 id="boiling-it-down"&gt;Boiling it down
&lt;/h2&gt;&lt;p&gt;A multi-concern infrastructure repo feels like it should be split into per-concern stacks, and Terragrunt is right there to manage the result. &lt;code&gt;infra&lt;/code&gt; keeps &lt;code&gt;src/&lt;/code&gt; as one OpenTofu graph instead.&lt;/p&gt;
&lt;p&gt;Inside one graph, OpenTofu enforces dependency ordering and passes current values across module boundaries as checked facts. Split into separate states and that becomes a &lt;code&gt;terraform_remote_state&lt;/code&gt; snapshot lookup plus ordering you arrange by hand: a weaker version of what you gave up. Splitting is right when concerns have different cadences, boundaries or owners; for a single-account, single-operator repo none of that applies, so the strong guarantee is worth keeping, and Terragrunt is the tool for a problem &lt;code&gt;infra&lt;/code&gt; chose not to have.&lt;/p&gt;</description></item><item><title>Why I hand-rolled every module</title><link>https://blog-570662.gitlab.io/why-i-hand-rolled-every-module/</link><pubDate>Sun, 10 May 2026 00:00:00 +0000</pubDate><guid>https://blog-570662.gitlab.io/why-i-hand-rolled-every-module/</guid><description>&lt;img src="https://blog-570662.gitlab.io/why-i-hand-rolled-every-module/cover-why-i-hand-rolled-every-module.png" alt="Featured image of post Why I hand-rolled every module" /&gt;&lt;p&gt;There are well-known community module libraries for AWS: Cloud Posse, the &lt;code&gt;terraform-aws-modules&lt;/code&gt; collection, plenty more. Both &lt;code&gt;terraform-aws-bootstrap&lt;/code&gt; and &lt;code&gt;terraform-aws-security-baseline&lt;/code&gt; use almost none of them. Every sub-module is hand-rolled from raw AWS resources, and before you accuse me of not-invented-here syndrome (a perfectly fair first guess), hear me out, because the same evaluation kept landing the same way for a real reason.&lt;/p&gt;
&lt;h2 id="the-promise-of-a-wrapper-module"&gt;The promise of a wrapper module
&lt;/h2&gt;&lt;p&gt;The community module ecosystem makes an appealing offer. Don&amp;rsquo;t write raw &lt;code&gt;aws_s3_bucket&lt;/code&gt; and &lt;code&gt;aws_s3_bucket_policy&lt;/code&gt; and &lt;code&gt;aws_s3_bucket_public_access_block&lt;/code&gt; and the rest. Call a tested, popular module, pass it a handful of inputs, and get a correct, well-configured bucket. Less code in your repo, and the code you don&amp;rsquo;t write has been exercised by thousands of other users.&lt;/p&gt;
&lt;p&gt;For a lot of infrastructure that&amp;rsquo;s a genuinely good deal, and I take it often. For the two infrastructure modules in this series, I took it almost never. Every sub-module is built from raw AWS resources. That wasn&amp;rsquo;t a reflex. It was the same evaluation, made over and over, landing the same way.&lt;/p&gt;
&lt;h2 id="what-kept-going-wrong"&gt;What kept going wrong
&lt;/h2&gt;&lt;p&gt;For each place a wrapper module could have fitted, I looked at the wrapper. And the recurring finding was one of two things. Either using the wrapper &lt;em&gt;correctly&lt;/em&gt;, with all the overrides my posture needed, came to more configuration than the raw resources would have. Or the wrapper&amp;rsquo;s abstraction leaked the instant I needed something it hadn&amp;rsquo;t anticipated, and I was now writing code to fight it.&lt;/p&gt;
&lt;h2 id="the-cloudtrail-bucket-concretely"&gt;The CloudTrail bucket, concretely
&lt;/h2&gt;&lt;p&gt;The clearest example is the bucket that holds CloudTrail logs.&lt;/p&gt;
&lt;p&gt;There are popular modules that set up CloudTrail and bundle an S3 bucket for the logs. Convenient. But that bundled bucket isn&amp;rsquo;t the bucket I want. It doesn&amp;rsquo;t carry &lt;code&gt;lifecycle { prevent_destroy = true }&lt;/code&gt;, and its bucket policy is weaker than the one &lt;a class="link" href="https://blog-570662.gitlab.io/a-state-bucket-that-defends-itself/" &gt;the state bucket&lt;/a&gt; taught me to want: TLS-only, SSE-KMS-only, wrong-key-denied.&lt;/p&gt;
&lt;p&gt;So to use the wrapper I had two options. Accept a weaker audit-log bucket than the rest of the account, which rather defeats the point of an audit log. Or fight the wrapper: disable its bucket, create my own, wire it back in. Fighting the wrapper is &lt;em&gt;more&lt;/em&gt; work than simply writing the fifty-odd lines of raw &lt;code&gt;aws_s3_bucket&lt;/code&gt; plus policy that give me exactly the posture I&amp;rsquo;d already designed once. The wrapper didn&amp;rsquo;t save code. It added a negotiation.&lt;/p&gt;
&lt;h2 id="a-wrapper-is-a-deal-and-deals-have-terms"&gt;A wrapper is a deal, and deals have terms
&lt;/h2&gt;&lt;p&gt;This isn&amp;rsquo;t an argument that community modules are bad. It&amp;rsquo;s an argument about when the deal is good.&lt;/p&gt;
&lt;p&gt;A wrapper module is a good deal while its abstraction holds: while what it assumes you want matches what you want. The moment you need something it didn&amp;rsquo;t anticipate, the deal inverts. Now you&amp;rsquo;re working &lt;em&gt;against&lt;/em&gt; the abstraction, and an abstraction you&amp;rsquo;re fighting costs more than no abstraction at all. (Regular readers will recognise that line from &lt;a class="link" href="https://blog-570662.gitlab.io/an-ai-interface-that-fits-on-one-screen/" &gt;the LangChain argument&lt;/a&gt;; it&amp;rsquo;s the same principle in a very different language.)&lt;/p&gt;
&lt;p&gt;Infrastructure that holds signing keys is precisely the case where you need to control the specifics: every encryption setting, every lifecycle rule, every line of every bucket policy. That&amp;rsquo;s a domain where wrapper abstractions leak fast, because the whole job &lt;em&gt;is&lt;/em&gt; the details the wrapper smoothed over.&lt;/p&gt;
&lt;h2 id="the-cost-paid-on-purpose"&gt;The cost, paid on purpose
&lt;/h2&gt;&lt;p&gt;Hand-rolling isn&amp;rsquo;t free. It&amp;rsquo;s more lines of HCL in the repo, up front, than a one-line module call.&lt;/p&gt;
&lt;p&gt;What those lines buy is worth the price &lt;em&gt;for this kind of infrastructure&lt;/em&gt;. There&amp;rsquo;s no transitive module-version churn to track. There&amp;rsquo;s no abstraction between me and the resource when something behaves oddly. And every line is one I can read, and defend, in a security review, because I wrote it and it says exactly what it does. For a foundation that will hold the most sensitive key in the system, &amp;ldquo;readable and mine&amp;rdquo; beats &amp;ldquo;short and someone else&amp;rsquo;s&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s a deliberate trade, not a universal rule. For an internal tool on a deadline, reach for the wrapper. For the security-critical base of everything else, the raw resources won every time I checked.&lt;/p&gt;
&lt;h2 id="to-sum-up"&gt;To sum up
&lt;/h2&gt;&lt;p&gt;The community module ecosystem offers less code that more people have tested, and for plenty of infrastructure that&amp;rsquo;s the right call. For &lt;code&gt;terraform-aws-bootstrap&lt;/code&gt; and &lt;code&gt;terraform-aws-security-baseline&lt;/code&gt; it almost never was, because each wrapper turned out to be more configuration than the raw resources once my posture was accounted for, or it leaked the moment I needed a specific.&lt;/p&gt;
&lt;p&gt;The CloudTrail log bucket is the pattern in miniature: the bundled bucket lacked &lt;code&gt;prevent_destroy&lt;/code&gt; and a strong policy, so using the wrapper meant either a weaker bucket or fighting the module. A wrapper is a good deal while its abstraction holds and a bad one the moment you fight it, and security-critical foundation infrastructure is all specifics. Hand-rolling cost more lines and bought code I can read and defend. For this, that was the trade worth making.&lt;/p&gt;</description></item><item><title>Supporting a provider, or actually using it</title><link>https://blog-570662.gitlab.io/supporting-a-provider-or-actually-using-it/</link><pubDate>Sat, 02 May 2026 00:00:00 +0000</pubDate><guid>https://blog-570662.gitlab.io/supporting-a-provider-or-actually-using-it/</guid><description>&lt;img src="https://blog-570662.gitlab.io/supporting-a-provider-or-actually-using-it/cover-supporting-a-provider-or-actually-using-it.png" alt="Featured image of post Supporting a provider, or actually using it" /&gt;&lt;p&gt;If your CLI tool talks to an AI model, you don&amp;rsquo;t want to hard-wire one vendor. So you reach for a single client interface over several providers, which is the right call. The trap is the next step: build that interface on only what every provider has in common, and you quietly throw away the very features that made you want a particular provider in the first place. rust-tool-base&amp;rsquo;s &lt;code&gt;rtb-ai&lt;/code&gt; refuses to make that trade.&lt;/p&gt;
&lt;h2 id="the-pull-toward-one-interface"&gt;The pull toward one interface
&lt;/h2&gt;&lt;p&gt;If your CLI tool talks to an AI model, hard-wiring one vendor is a poor bet. One user has an Anthropic key, another an OpenAI key. Someone&amp;rsquo;s on Gemini. Someone runs Ollama locally because their data can&amp;rsquo;t leave the building. Someone points at an OpenAI-compatible endpoint from a provider you&amp;rsquo;ve never heard of. You don&amp;rsquo;t want a separate code path for each, so you want one &lt;code&gt;AiClient&lt;/code&gt; that all of them slot behind.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;rtb-ai&lt;/code&gt; gets that unification from the &lt;code&gt;genai&lt;/code&gt; crate, which already speaks to Anthropic, OpenAI, Gemini, Ollama and OpenAI-compatible endpoints. One interface, five providers, the tool author picks one in config. The Go sibling makes the same bet: go-tool-base&amp;rsquo;s &lt;code&gt;chat&lt;/code&gt; package also unifies several providers, behind &lt;a class="link" href="https://blog-570662.gitlab.io/an-ai-interface-that-fits-on-one-screen/" &gt;an interface deliberately kept to four methods&lt;/a&gt;. So far this is the obvious design, and if it were the whole design there&amp;rsquo;d be nothing to write about.&lt;/p&gt;
&lt;h2 id="what-unified-quietly-costs-you"&gt;What &amp;ldquo;unified&amp;rdquo; quietly costs you
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the catch in any unified interface. It can only expose what every provider behind it has in common.&lt;/p&gt;
&lt;p&gt;The common subset is plain chat. Messages go in, text comes out, optionally streamed token by token. That&amp;rsquo;s real and it&amp;rsquo;s useful and every provider does it. But the common subset is also the &lt;em&gt;floor&lt;/em&gt;, and the features that make a particular provider worth choosing are almost never on the floor. They&amp;rsquo;re the things only that provider does.&lt;/p&gt;
&lt;p&gt;Anthropic is the sharp example, because it has three features that matter and not one of them is common-subset.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Prompt caching.&lt;/strong&gt; You can mark the stable parts of a request, the system prompt and the tool list, as cacheable. The provider keeps them warm, and on the next turn you aren&amp;rsquo;t billed to re-send and re-process text that didn&amp;rsquo;t change. On a long agent loop, where the same large system prompt rides along on every single turn, that&amp;rsquo;s a substantial saving in both cost and latency.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Extended thinking.&lt;/strong&gt; The model works through a hard problem in a visible, budgeted reasoning pass before it commits to an answer, and you can see that reasoning.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Citations.&lt;/strong&gt; Structured references back to source material in the response.&lt;/p&gt;
&lt;p&gt;A client built strictly on the common subset can&amp;rsquo;t express any of those. It has no field for them, because four of the five providers wouldn&amp;rsquo;t know what to do with the field. So a purely lowest-common-denominator client would &amp;ldquo;support&amp;rdquo; Anthropic and then use it badly, leaving its best features unreachable. Support as a checkbox, not as the point.&lt;/p&gt;
&lt;h2 id="the-escape-hatch"&gt;The escape hatch
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;rtb-ai&lt;/code&gt;&amp;rsquo;s answer is to not choose. It runs two implementations under one interface.&lt;/p&gt;
&lt;p&gt;For OpenAI, Gemini, Ollama and OpenAI-compatible endpoints, calls route through &lt;code&gt;genai&lt;/code&gt;, the unified path. For Anthropic, every method drops to a direct &lt;code&gt;reqwest&lt;/code&gt; implementation straight against the Messages API. Same &lt;code&gt;AiClient&lt;/code&gt; on the surface, a different implementation underneath, selected by which provider the config names.&lt;/p&gt;
&lt;p&gt;And the request type has deliberate room for the difference:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;ChatRequest&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;system&lt;/span&gt;: &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;: &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;temperature&lt;/span&gt;: &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;f32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;max_tokens&lt;/span&gt;: &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;u32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sd"&gt;/// Anthropic-only: enables prompt caching at every stable point.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sd"&gt;/// Ignored on non-Anthropic providers.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;cache_control&lt;/span&gt;: &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sd"&gt;/// Anthropic-only: extended-thinking budget. `None` disables.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sd"&gt;/// Ignored on non-Anthropic providers.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;thinking&lt;/span&gt;: &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ThinkingMode&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Set &lt;code&gt;cache_control&lt;/code&gt; and the Anthropic-direct path inserts cache breakpoints at the three stable points: the system prompt, the tool list, and the first message. Set &lt;code&gt;thinking&lt;/code&gt; and it adds the thinking block, and streaming surfaces a separate &lt;code&gt;ThinkingToken&lt;/code&gt; event so you can show the reasoning apart from the answer. On a non-Anthropic provider, both fields are simply ignored. The interface carries them; only the implementation that understands them acts on them.&lt;/p&gt;
&lt;h2 id="a-hatch-not-a-leak"&gt;A hatch, not a leak
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s worth being precise about why this isn&amp;rsquo;t the thing it superficially resembles, which is a leaky abstraction.&lt;/p&gt;
&lt;p&gt;A leaky abstraction is one where implementation details bleed through that you didn&amp;rsquo;t intend and can&amp;rsquo;t reason about. The abstraction quietly fails to abstract, and you&amp;rsquo;re left guessing which provider you&amp;rsquo;re really talking to.&lt;/p&gt;
&lt;p&gt;This is the opposite of that. The two Anthropic-only fields aren&amp;rsquo;t a leak. They&amp;rsquo;re named, documented as Anthropic-only, inert everywhere else, and right there in the public type for anyone to see. The interface is uniform for the common case and &lt;em&gt;deliberately, visibly&lt;/em&gt; non-uniform at exactly the points where uniformity would have cost you the good features. You opt into provider-specifics by setting a field. You stay fully portable by leaving it at its default. Nothing bleeds; you decide.&lt;/p&gt;
&lt;p&gt;The same design line explains what &lt;em&gt;does&lt;/em&gt; stay in the unified path. Structured output, &lt;code&gt;chat_structured::&amp;lt;T&amp;gt;&lt;/code&gt;, sends a JSON Schema derived from your Rust type with the request and validates the reply against it before handing you a typed &lt;code&gt;T&lt;/code&gt;. That&amp;rsquo;s a portability win that costs nothing across providers, so it belongs in the common interface. The split isn&amp;rsquo;t &amp;ldquo;Anthropic versus the rest&amp;rdquo;. It&amp;rsquo;s &amp;ldquo;features that are free to unify go in the unified path; features that aren&amp;rsquo;t get a designed door&amp;rdquo;. Prompt caching and extended thinking get the door, because flattening them away would be the expensive kind of convenient.&lt;/p&gt;
&lt;h2 id="to-sum-up"&gt;To sum up
&lt;/h2&gt;&lt;p&gt;A CLI tool that integrates AI wants one client over several providers, and a unified interface can only expose what those providers share. The shared floor is plain chat, and the features worth choosing a provider &lt;em&gt;for&lt;/em&gt;, like Anthropic&amp;rsquo;s prompt caching, extended thinking and citations, are never on the floor.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;rtb-ai&lt;/code&gt; keeps both. &lt;code&gt;genai&lt;/code&gt; provides the unified path across five providers; an Anthropic-direct &lt;code&gt;reqwest&lt;/code&gt; path drops below the abstraction for the features &lt;code&gt;genai&lt;/code&gt; can&amp;rsquo;t reach, and &lt;code&gt;ChatRequest&lt;/code&gt; carries the Anthropic-only fields openly, ignored elsewhere. Uniform where uniformity is free, with a designed escape hatch where it isn&amp;rsquo;t. That&amp;rsquo;s the difference between supporting a provider and actually using it.&lt;/p&gt;</description></item><item><title>Two kinds of feature flag</title><link>https://blog-570662.gitlab.io/two-kinds-of-feature-flag/</link><pubDate>Thu, 30 Apr 2026 00:00:00 +0000</pubDate><guid>https://blog-570662.gitlab.io/two-kinds-of-feature-flag/</guid><description>&lt;img src="https://blog-570662.gitlab.io/two-kinds-of-feature-flag/cover-two-kinds-of-feature-flag.png" alt="Featured image of post Two kinds of feature flag" /&gt;&lt;p&gt;go-tool-base has feature flags: switches that decide which built-in commands are live in a given run. rust-tool-base has those too. But it also has a second, completely separate kind of flag, and the difference between them is one of those distinctions that&amp;rsquo;s obvious the moment you see it and dangerously easy to conflate before you do. One decides what a command &lt;em&gt;does&lt;/em&gt;. The other decides whether a chunk of code is &lt;em&gt;in the binary at all&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id="a-workspace-of-crates"&gt;A workspace of crates
&lt;/h2&gt;&lt;p&gt;Before the flags, the shape that makes them possible. go-tool-base is one Go module with packages under &lt;code&gt;pkg/&lt;/code&gt;. rust-tool-base is a Cargo &lt;em&gt;workspace&lt;/em&gt; of seventeen crates: &lt;code&gt;rtb-app&lt;/code&gt;, &lt;code&gt;rtb-config&lt;/code&gt;, &lt;code&gt;rtb-cli&lt;/code&gt;, &lt;code&gt;rtb-vcs&lt;/code&gt;, &lt;code&gt;rtb-ai&lt;/code&gt;, &lt;code&gt;rtb-mcp&lt;/code&gt;, &lt;code&gt;rtb-docs&lt;/code&gt;, &lt;code&gt;rtb-telemetry&lt;/code&gt;, and so on, with an umbrella crate called &lt;code&gt;rtb&lt;/code&gt; that re-exports the public surface.&lt;/p&gt;
&lt;p&gt;That isn&amp;rsquo;t tidiness for its own sake. Each subsystem being a separately compilable crate is what gives you a unit you can include or exclude &lt;em&gt;wholesale&lt;/em&gt;. Hold onto that, because it&amp;rsquo;s the hinge for everything below.&lt;/p&gt;
&lt;h2 id="the-flag-go-tool-base-already-has"&gt;The flag go-tool-base already has
&lt;/h2&gt;&lt;p&gt;go-tool-base has feature flags, and I&amp;rsquo;d describe them as runtime flags. A tool built on it can enable or disable built-in commands:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetFeatures&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Disable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;InitCmd&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Enable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AiCmd&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;At startup the framework resolves that set and decides which commands are reachable for this run. The &lt;code&gt;init&lt;/code&gt; command might be present in the binary but switched off; the &lt;code&gt;ai&lt;/code&gt; command might be switched on. It&amp;rsquo;s about the &lt;em&gt;user-facing surface&lt;/em&gt;: which commands exist for someone typing &lt;code&gt;--help&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;rust-tool-base keeps this idea. A command carries a &lt;code&gt;CommandSpec&lt;/code&gt; with an optional &lt;code&gt;feature&lt;/code&gt; field, and the runtime decides whether a feature-gated command is reachable. Same purpose: shape the surface per invocation.&lt;/p&gt;
&lt;p&gt;If that were the whole story, there&amp;rsquo;d be nothing to write. The reason there&amp;rsquo;s a post is the &lt;em&gt;other&lt;/em&gt; kind of flag, which Rust makes available and Go really doesn&amp;rsquo;t.&lt;/p&gt;
&lt;h2 id="the-flag-rust-adds"&gt;The flag Rust adds
&lt;/h2&gt;&lt;p&gt;Cargo features are a compile-time mechanism. The &lt;code&gt;rtb&lt;/code&gt; umbrella crate declares them like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;features&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;default&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;cli&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;update&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;docs&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;mcp&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;credentials&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;tui&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;cli&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;dep:rtb-cli&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;update&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;dep:rtb-update&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;ai&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;dep:rtb-ai&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;rtb-docs?/ai&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;vcs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;dep:rtb-vcs&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;telemetry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;dep:rtb-telemetry&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;full&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;cli&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;update&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;docs&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;mcp&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;ai&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;credentials&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;tui&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;telemetry&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;vcs&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Each subsystem is an &lt;em&gt;optional&lt;/em&gt; crate dependency, and a feature switches it on. This is a different kind of switch entirely, and the difference is the whole point.&lt;/p&gt;
&lt;p&gt;A runtime flag decides what a command does &lt;em&gt;while the program runs&lt;/em&gt;. The code is in the binary either way; the flag just gates it.&lt;/p&gt;
&lt;p&gt;A Cargo feature decides what&amp;rsquo;s &lt;em&gt;in the binary in the first place&lt;/em&gt;. Build a tool without the &lt;code&gt;vcs&lt;/code&gt; feature and &lt;code&gt;rtb-vcs&lt;/code&gt; is not compiled. Its dependencies are not compiled. &lt;code&gt;gix&lt;/code&gt;, the pure-Rust Git implementation &lt;code&gt;rtb-vcs&lt;/code&gt; pulls in, roughly two and a half megabytes of it, is not compiled and not linked. It isn&amp;rsquo;t switched off in the binary. It was never in the binary. The compiler never even saw it.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s something a runtime flag cannot do, because by the time anything runs, the binary already exists with everything in it.&lt;/p&gt;
&lt;h2 id="two-axes-kept-separate"&gt;Two axes, kept separate
&lt;/h2&gt;&lt;p&gt;So rust-tool-base has two flag systems answering two genuinely different questions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cargo features&lt;/strong&gt; answer: &lt;em&gt;what is this binary made of?&lt;/em&gt; They&amp;rsquo;re decided when you build the tool, in &lt;code&gt;Cargo.toml&lt;/code&gt;. They control compilation, binary size, dependency surface, and compile time. A tool that never touches Git builds without &lt;code&gt;vcs&lt;/code&gt; and is smaller, faster to compile, and has a smaller dependency tree to audit. A tool that wants everything turns on &lt;code&gt;full&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Runtime feature flags&lt;/strong&gt; answer: &lt;em&gt;what can the user do in this run?&lt;/em&gt; They&amp;rsquo;re decided as the program starts. They control which commands appear, which paths are reachable.&lt;/p&gt;
&lt;p&gt;These could have been mashed into one mechanism, and it would have been a mistake. The app-context design notes are blunt about it: feature gating doesn&amp;rsquo;t belong on the per-command context object, because a feature-gated command &amp;ldquo;either exists or doesn&amp;rsquo;t&amp;rdquo; rather than changing its behaviour mid-run. Compile-time composition is one decision, made by the person building the tool. Runtime gating is another, made per invocation. Conflating them would mean you couldn&amp;rsquo;t reason cleanly about either.&lt;/p&gt;
&lt;h2 id="the-go-version-of-this-had-to-be-hand-built"&gt;The Go version of this had to be hand-built
&lt;/h2&gt;&lt;p&gt;This isn&amp;rsquo;t a thing Go simply lacks. I &lt;a class="link" href="https://blog-570662.gitlab.io/the-blank-import-that-keeps-a-dependency-out-of-your-binary/" &gt;wrote a whole post&lt;/a&gt; about how go-tool-base keeps its optional keychain dependency out of binaries that don&amp;rsquo;t want it, using a blank import and the linker&amp;rsquo;s dead-code elimination. It works. But it was a piece of deliberate engineering for &lt;em&gt;one&lt;/em&gt; dependency, and getting it right took care.&lt;/p&gt;
&lt;p&gt;Cargo features make that same outcome a first-class, declarative thing, and not for one dependency but for every subsystem the framework has. You don&amp;rsquo;t engineer the exclusion. You name a feature and leave it off. The crate, and its whole subtree, stays out. Rust&amp;rsquo;s build system was designed for exactly this, and rust-tool-base leans on it across the entire workspace rather than hand-rolling it once.&lt;/p&gt;
&lt;h2 id="where-this-leaves-us"&gt;Where this leaves us
&lt;/h2&gt;&lt;p&gt;go-tool-base has runtime feature flags: they decide, per invocation, which built-in commands are reachable. rust-tool-base keeps that, and adds a second kind that Rust makes available.&lt;/p&gt;
&lt;p&gt;Cargo features decide what the binary is &lt;em&gt;compiled from&lt;/em&gt;. Each of the framework&amp;rsquo;s seventeen crates is an optional dependency, and a feature switched off means that crate and its entire dependency subtree are never compiled or linked. A runtime flag gates what code &lt;em&gt;does&lt;/em&gt;; a Cargo feature gates whether code &lt;em&gt;is there at all&lt;/em&gt;. Two axes, two questions, deliberately kept as separate systems.&lt;/p&gt;</description></item><item><title>Reloading config without a restart</title><link>https://blog-570662.gitlab.io/reloading-config-without-a-restart/</link><pubDate>Mon, 27 Apr 2026 00:00:00 +0000</pubDate><guid>https://blog-570662.gitlab.io/reloading-config-without-a-restart/</guid><description>&lt;img src="https://blog-570662.gitlab.io/reloading-config-without-a-restart/cover-reloading-config-without-a-restart.png" alt="Featured image of post Reloading config without a restart" /&gt;&lt;p&gt;A config file changes. Someone edits a setting, rotates a credential, flips a feature flag. How does the running process find out? For most processes the answer is blunt: it doesn&amp;rsquo;t, until you restart it. For a short-lived CLI that&amp;rsquo;s completely fine. For a long-running service, &amp;ldquo;just restart it&amp;rdquo; is a much bigger ask than it sounds.&lt;/p&gt;
&lt;h2 id="the-default-answer-is-a-restart"&gt;The default answer is a restart
&lt;/h2&gt;&lt;p&gt;Configuration lives in a file. The file changes: someone edits a setting, rotates a credential, flips a feature flag. How does the running process find out?&lt;/p&gt;
&lt;p&gt;Overwhelmingly, the honest answer is that it doesn&amp;rsquo;t. A process reads its config once, at startup, and that snapshot is frozen for the life of the process. Change the file and nothing happens until you restart, at which point a fresh process reads the fresh file.&lt;/p&gt;
&lt;p&gt;For a short-lived CLI invocation that&amp;rsquo;s completely fine. It reads config, does its job, exits, and the next invocation reads whatever the file says then. But the same frameworks are also used to build long-running services, and for a service &amp;ldquo;just restart it&amp;rdquo; is not the small thing it sounds like.&lt;/p&gt;
&lt;h2 id="what-a-restart-actually-costs"&gt;What a restart actually costs
&lt;/h2&gt;&lt;p&gt;Restarting a long-running service means every open connection drops. Any in-flight request is lost, or has to be retried by whoever sent it. Caches that took real time to warm are cold again. There&amp;rsquo;s a window, short but real, where the service simply isn&amp;rsquo;t serving.&lt;/p&gt;
&lt;p&gt;If the thing you changed was a log level, or a feature flag, or a timeout, you&amp;rsquo;ve paid a disruption wildly out of proportion to the change. And the calculation only gets worse as the service gets more important, because the services you least want to bounce on a whim are exactly the ones that matter most.&lt;/p&gt;
&lt;h2 id="hot-reload-re-read-in-place"&gt;Hot-reload: re-read in place
&lt;/h2&gt;&lt;p&gt;Hot-reload is the alternative, and both go-tool-base and rust-tool-base support it.&lt;/p&gt;
&lt;p&gt;The process doesn&amp;rsquo;t read config once and freeze it. It &lt;em&gt;watches&lt;/em&gt; the config file. When the file changes, it re-reads it, re-applies it, and carries on running. No new process, no dropped connections, no cold start. The change lands in the live process.&lt;/p&gt;
&lt;p&gt;The shape is the same in both frameworks:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A file watcher notices the config file changed. Underneath, this is the operating system&amp;rsquo;s own file-notification facility, &lt;code&gt;inotify&lt;/code&gt; on Linux and its equivalents elsewhere. rust-tool-base reaches it through the &lt;code&gt;notify&lt;/code&gt; crate; go-tool-base, through the watcher built into Viper.&lt;/li&gt;
&lt;li&gt;A debounce step waits for the writes to settle. Saving a file is often several separate operations, and you don&amp;rsquo;t want to reload three times for one edit.&lt;/li&gt;
&lt;li&gt;The config is re-parsed from disk.&lt;/li&gt;
&lt;li&gt;The new config is swapped in atomically.&lt;/li&gt;
&lt;li&gt;Observers are notified, so the subsystems that care can react.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Steps four and five are the ones worth slowing down on, because they&amp;rsquo;re where a naive hot-reload quietly goes wrong.&lt;/p&gt;
&lt;h2 id="the-two-details-that-make-it-safe"&gt;The two details that make it safe
&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;The atomic swap.&lt;/strong&gt; You do not mutate the live config object in place. A reader on another thread, partway through reading it, would see a torn mix of old and new values, and that&amp;rsquo;s a genuinely nasty class of bug. Instead the process builds a &lt;em&gt;new&lt;/em&gt;, complete config value and swaps the pointer to it in a single atomic operation. Any reader sees either the entire old config or the entire new one, never a blend. rust-tool-base does this with &lt;code&gt;arc-swap&lt;/code&gt;; go-tool-base does the equivalent. Reads stay cheap and lock-free, and an update is one pointer swap.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The observer notification.&lt;/strong&gt; Re-reading the file isn&amp;rsquo;t the end of the job. Some subsystems have to &lt;em&gt;do something&lt;/em&gt; when config changes: a connection pool resizes, a logger changes level, a rate limiter takes a new ceiling. So a hot-reload system has to let those subsystems subscribe. rust-tool-base hands observers a &lt;code&gt;watch::Receiver&lt;/code&gt;, a channel that always holds the latest value; go-tool-base exposes an &lt;code&gt;Observable&lt;/code&gt; interface. A subsystem subscribes once and reacts every time config changes, for the life of the process.&lt;/p&gt;
&lt;h2 id="where-this-earns-its-keep-a-kubernetes-pod"&gt;Where this earns its keep: a Kubernetes pod
&lt;/h2&gt;&lt;p&gt;Hot-reload is a nicety on a developer&amp;rsquo;s laptop. Inside a Kubernetes pod it becomes genuinely valuable, and the reason is a neat fit between how Kubernetes delivers config and how a file watcher works.&lt;/p&gt;
&lt;p&gt;In Kubernetes you don&amp;rsquo;t usually bake configuration into the container image. It lives in ConfigMap and Secret objects, and the clean way to consume them is to &lt;em&gt;mount them as volumes&lt;/em&gt;. Mount a ConfigMap as a volume and each key becomes a file in the pod&amp;rsquo;s filesystem.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s the part that connects to everything above. When you update that ConfigMap or Secret, Kubernetes does not restart your pod. The kubelet notices the object changed and rewrites the projected files inside the still-running pod. The files on disk change underneath a process that never stopped.&lt;/p&gt;
&lt;p&gt;That file rewrite is exactly the event a hot-reload watcher exists to catch. So the whole chain becomes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You &lt;code&gt;kubectl apply&lt;/code&gt; an updated ConfigMap, or rotate a Secret.&lt;/li&gt;
&lt;li&gt;The kubelet updates the projected files inside the pod.&lt;/li&gt;
&lt;li&gt;The framework&amp;rsquo;s file watcher sees the write.&lt;/li&gt;
&lt;li&gt;The config is re-parsed, swapped in atomically, and observers are notified.&lt;/li&gt;
&lt;li&gt;The new configuration is live, and the pod never cycled.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You&amp;rsquo;ve changed a running service, in a running pod, with no rollout, nothing terminated and recreated, no dropped traffic. Rotate a database credential, raise a log level to debug an incident in progress, flip a feature flag: all of it live. For a service where a restart is the very thing you&amp;rsquo;re trying hard to avoid, the kind of &lt;a class="link" href="https://blog-570662.gitlab.io/lifecycle-management-for-long-running-go-services/" &gt;long-running service&lt;/a&gt; these frameworks are built for, that&amp;rsquo;s the difference between a config change being routine and being an event.&lt;/p&gt;
&lt;h2 id="the-honest-caveats"&gt;The honest caveats
&lt;/h2&gt;&lt;p&gt;Two things, so this doesn&amp;rsquo;t read as magic.&lt;/p&gt;
&lt;p&gt;First, not everything can be hot-reloaded. Some configuration genuinely needs a restart: the port a server binds to, the size of a thread pool, anything wired up exactly once at process start. Hot-reload covers the large category of settings a subsystem can re-read and re-apply; it doesn&amp;rsquo;t abolish restarts. A config system worth its salt is clear about which settings are live and which are not.&lt;/p&gt;
&lt;p&gt;Second, a Kubernetes gotcha that catches people out. The in-place file update happens for ConfigMaps and Secrets mounted as &lt;em&gt;volumes&lt;/em&gt;. Consume the same ConfigMap as &lt;em&gt;environment variables&lt;/em&gt; instead, and those are fixed when the container starts and never update, short of a restart. If you want hot-reload in a pod, mount config and secrets as files, not env vars. And even with volumes the update isn&amp;rsquo;t instant: the kubelet syncs on a period, around a minute by default, so a reload is &amp;ldquo;within a minute or so&amp;rdquo;, not &amp;ldquo;the moment you hit apply&amp;rdquo;.&lt;/p&gt;
&lt;h2 id="what-it-comes-down-to"&gt;What it comes down to
&lt;/h2&gt;&lt;p&gt;A config file changes, and the default way to pick it up is to restart the process. For a long-running service that restart costs dropped connections, lost work and a cold start, often for a change as small as a log level.&lt;/p&gt;
&lt;p&gt;go-tool-base and rust-tool-base both support hot-reload instead: a file watcher catches the change, the config is re-parsed and swapped in atomically so no reader sees torn state, and observers are notified so subsystems can react, all in a live process. The setting where it pays off most is a Kubernetes pod, where ConfigMaps and Secrets mounted as volumes are rewritten in place by the kubelet and the watcher catches that write directly. Mount them as volumes rather than env vars, allow for the kubelet&amp;rsquo;s sync delay, accept that some settings still need a restart, and within those limits &amp;ldquo;the config changed&amp;rdquo; stops meaning &amp;ldquo;cycle the pod&amp;rdquo;.&lt;/p&gt;</description></item><item><title>Registering commands without life before main</title><link>https://blog-570662.gitlab.io/registering-commands-without-life-before-main/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://blog-570662.gitlab.io/registering-commands-without-life-before-main/</guid><description>&lt;img src="https://blog-570662.gitlab.io/registering-commands-without-life-before-main/cover-registering-commands-without-life-before-main.png" alt="Featured image of post Registering commands without life before main" /&gt;&lt;p&gt;I ended the &lt;a class="link" href="https://blog-570662.gitlab.io/what-survives-a-port/" &gt;last post&lt;/a&gt; promising to show how a Rust command registers itself when the language flatly refuses to run any of your code before &lt;code&gt;main()&lt;/code&gt;. This is that post, and it&amp;rsquo;s a lovely example of reaching the same outcome by a completely different road.&lt;/p&gt;
&lt;p&gt;The outcome I wanted to keep is self-registration.&lt;/p&gt;
&lt;h2 id="what-self-registration-buys"&gt;What self-registration buys
&lt;/h2&gt;&lt;p&gt;A command in go-tool-base lives in its own file, and that file puts the command into the framework itself. There&amp;rsquo;s no central list of commands to keep in sync. You add a file, the command appears. You delete the file, it&amp;rsquo;s gone. Nothing else changes.&lt;/p&gt;
&lt;p&gt;That property is worth protecting. The alternative, a hand-maintained registry that every new command has to be threaded into, is exactly the sort of central file that turns into a merge-conflict magnet and quietly falls out of date. So when go-tool-base moved to Rust, self-registration was firmly in the column of things that had to survive.&lt;/p&gt;
&lt;p&gt;The way Go &lt;em&gt;did&lt;/em&gt; it was not.&lt;/p&gt;
&lt;h2 id="how-go-does-it"&gt;How Go does it
&lt;/h2&gt;&lt;p&gt;A Go package can declare an &lt;code&gt;init()&lt;/code&gt; function, and the runtime guarantees every &lt;code&gt;init()&lt;/code&gt; runs before &lt;code&gt;main()&lt;/code&gt; starts. A go-tool-base command file uses this to append itself to a package-level slice:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;init&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;DeployCommand&lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;By the time &lt;code&gt;main()&lt;/code&gt; runs, every command file&amp;rsquo;s &lt;code&gt;init()&lt;/code&gt; has already fired and the registry slice is populated. It&amp;rsquo;s a tidy trick, and it leans entirely on a Go feature: code that executes before &lt;code&gt;main()&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="rust-doesnt-have-that"&gt;Rust doesn&amp;rsquo;t have that
&lt;/h2&gt;&lt;p&gt;Rust has no &lt;code&gt;init()&lt;/code&gt;. There&amp;rsquo;s no language-blessed phase that runs your code before &lt;code&gt;main()&lt;/code&gt;. This is a deliberate decision, not an oversight. Code running before &lt;code&gt;main()&lt;/code&gt; across many files has no well-defined order, and a startup phase whose ordering you can&amp;rsquo;t see is a classic source of subtle, miserable bugs. Rust closed that door on purpose.&lt;/p&gt;
&lt;p&gt;Which leaves a real question. If nothing runs before &lt;code&gt;main()&lt;/code&gt;, how does a command file insert itself into a registry without a central list editing it in?&lt;/p&gt;
&lt;h2 id="distributed-slices"&gt;Distributed slices
&lt;/h2&gt;&lt;p&gt;The answer is a crate called &lt;code&gt;linkme&lt;/code&gt;, and the mechanism is the &lt;em&gt;linker&lt;/em&gt; rather than a runtime phase.&lt;/p&gt;
&lt;p&gt;You declare a slice the framework will collect into:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#[distributed_slice]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;BUILTIN_COMMANDS&lt;/span&gt;: &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-&amp;gt; &lt;span class="nb"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Command&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A command file then contributes one entry to it:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;Greet&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;impl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Command&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Greet&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="cm"&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#[distributed_slice(BUILTIN_COMMANDS)]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;register_greet&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-&amp;gt; &lt;span class="nb"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Command&lt;/span&gt;&lt;span class="o"&gt;&amp;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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Box&lt;/span&gt;::&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Greet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Here&amp;rsquo;s the part that makes it work. The &lt;code&gt;#[distributed_slice]&lt;/code&gt; attribute doesn&amp;rsquo;t generate any code that runs at startup. It places each entry into a dedicated section of the compiled object file. When the linker builds the final binary, it gathers everything in that section and lays it out as one contiguous array. &lt;code&gt;BUILTIN_COMMANDS&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; that array.&lt;/p&gt;
&lt;p&gt;So by the time the program exists as a binary on disk, the registry is already assembled. &lt;code&gt;main()&lt;/code&gt; doesn&amp;rsquo;t build it. No &lt;code&gt;init()&lt;/code&gt; builds it. The linker built it, statically, as part of producing the executable. At runtime the framework iterates a slice that was complete before the process ever started.&lt;/p&gt;
&lt;h2 id="what-you-get-from-it"&gt;What you get from it
&lt;/h2&gt;&lt;p&gt;The outcome is the one Go&amp;rsquo;s &lt;code&gt;init()&lt;/code&gt; gave, and then a bit more.&lt;/p&gt;
&lt;p&gt;A command still lives in one file and still self-registers. Adding a command is still adding a file. There&amp;rsquo;s still no central list.&lt;/p&gt;
&lt;p&gt;But there&amp;rsquo;s no startup phase to reason about, because there isn&amp;rsquo;t one. There&amp;rsquo;s no global mutable slice being appended to as &lt;code&gt;init()&lt;/code&gt;s fire, because nothing is appended at runtime; the slice is immutable and finished. There&amp;rsquo;s no ordering question, because the linker isn&amp;rsquo;t running your code, it&amp;rsquo;s collecting data. And it costs nothing at runtime: assembling the registry happened at link time, so program start just reads it.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s the same idea go-tool-base had, expressed by the tool Rust actually gives you. Go reaches the registry through a controlled phase before &lt;code&gt;main()&lt;/code&gt;. Rust reaches it without any phase at all, because the linker did the assembly while the binary was still being built. Two roads, one destination&amp;hellip; which, if you&amp;rsquo;ve been following along, is becoming the whole theme of the Rust side of this project.&lt;/p&gt;
&lt;h2 id="in-short"&gt;In short
&lt;/h2&gt;&lt;p&gt;Self-registration, where a command file inserts itself into the framework with no central list, is a property worth keeping. go-tool-base achieves it with a package-level &lt;code&gt;init()&lt;/code&gt;, leaning on Go&amp;rsquo;s guarantee that such functions run before &lt;code&gt;main()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Rust has no equivalent and wants none, because code running before &lt;code&gt;main()&lt;/code&gt; has no clear ordering. rust-tool-base uses &lt;code&gt;linkme&lt;/code&gt; distributed slices instead: each command is placed into a dedicated linker section, and the linker assembles them into one contiguous, immutable slice as it builds the binary. The registry is complete before the program runs. Same outcome as Go&amp;rsquo;s &lt;code&gt;init()&lt;/code&gt;, with no life before &lt;code&gt;main&lt;/code&gt; required.&lt;/p&gt;</description></item><item><title>What survives a port, and what doesn't</title><link>https://blog-570662.gitlab.io/what-survives-a-port/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://blog-570662.gitlab.io/what-survives-a-port/</guid><description>&lt;img src="https://blog-570662.gitlab.io/what-survives-a-port/cover-what-survives-a-port.png" alt="Featured image of post What survives a port, and what doesn't" /&gt;&lt;p&gt;Rebuilding go-tool-base in Rust turned out to be the most honest design review I&amp;rsquo;ve ever sat through, and I didn&amp;rsquo;t have to do anything except keep going. Porting a framework into a language with completely different idioms forces a separation you can&amp;rsquo;t fake: the parts that survive the move are &lt;em&gt;design&lt;/em&gt;, and the parts that don&amp;rsquo;t are just &lt;em&gt;habit&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id="two-columns"&gt;Two columns
&lt;/h2&gt;&lt;p&gt;When you port a system between languages that don&amp;rsquo;t share idioms, every piece of it sorts itself into one of two columns, without you having to make the call.&lt;/p&gt;
&lt;p&gt;In the first column is the &lt;em&gt;outcome&lt;/em&gt; a piece of the design produces: every command receives the framework&amp;rsquo;s services, configuration is layered with a fixed precedence, commands register themselves, errors carry guidance to the user. In the second column is the &lt;em&gt;mechanism&lt;/em&gt; that produced that outcome in the original language.&lt;/p&gt;
&lt;p&gt;Things in the first column survive the port. You rebuild them, differently, because the tool genuinely needs them. Things in the second column do not survive. You find their replacement, and the Go version turns out to have been one valid implementation of an idea, not the idea itself. Doing this for go-tool-base, mechanism by mechanism, was more honest about my own design than any amount of sitting and staring at it would have been.&lt;/p&gt;
&lt;h2 id="the-container"&gt;The container
&lt;/h2&gt;&lt;p&gt;go-tool-base hands every command a &lt;code&gt;Props&lt;/code&gt; struct. It carries the logger, the config, the assets, the filesystem handle. Some of it is reached through loosely-typed accessors. It works well, and I &lt;a class="link" href="https://blog-570662.gitlab.io/props-the-container-that-does-the-heavy-lifting/" &gt;wrote a whole post about it&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The &lt;em&gt;outcome&lt;/em&gt; is column one: a command should receive one object, and that object should carry the framework&amp;rsquo;s services so the command doesn&amp;rsquo;t go assembling them itself. That survived. RTB hands every command an &lt;code&gt;App&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The loosely-typed accessors were column two. In Rust an &lt;code&gt;App&lt;/code&gt; is a plain struct with concrete fields, each one an &lt;code&gt;Arc&amp;lt;T&amp;gt;&lt;/code&gt; so a clone is a few atomic increments rather than a deep copy. Nothing is keyed by string. Nothing is fetched by name and asserted to a type. The thing the container &lt;em&gt;is for&lt;/em&gt; survived; the way Go expressed it did not.&lt;/p&gt;
&lt;h2 id="registration"&gt;Registration
&lt;/h2&gt;&lt;p&gt;A go-tool-base command self-registers using a package-level &lt;code&gt;init()&lt;/code&gt; function, which Go runs before &lt;code&gt;main()&lt;/code&gt; and which appends the command to a global slice.&lt;/p&gt;
&lt;p&gt;The outcome, column one, is that a command lives in its own file and inserts itself into the framework with no central list to edit. That&amp;rsquo;s genuinely worth keeping.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;init()&lt;/code&gt; mechanism is column two, and Rust doesn&amp;rsquo;t even offer it: Rust deliberately has no code that runs before &lt;code&gt;main()&lt;/code&gt;. The replacement is link-time registration through distributed slices, which gets its &lt;a class="link" href="https://blog-570662.gitlab.io/registering-commands-without-life-before-main/" &gt;own post next&lt;/a&gt;. Same outcome, no global mutable state, assembled by the linker rather than by a startup phase.&lt;/p&gt;
&lt;h2 id="configuration"&gt;Configuration
&lt;/h2&gt;&lt;p&gt;go-tool-base layers configuration with a precedence: flags over environment over file over defaults. Some of it is read back through key lookups.&lt;/p&gt;
&lt;p&gt;The layering and the precedence are column one. They survived exactly. RTB layers config with the same ordering.&lt;/p&gt;
&lt;p&gt;The key lookups were column two. In Rust the merged configuration is deserialised into &lt;em&gt;your own&lt;/em&gt; &lt;code&gt;serde&lt;/code&gt; struct, so a config value is a typed field you access like any other field, and a typo is a compile error instead of a missing key at runtime. The precedence survived; reading values back out of a string-keyed bag did not.&lt;/p&gt;
&lt;h2 id="the-error-path"&gt;The error path
&lt;/h2&gt;&lt;p&gt;go-tool-base routes every error through one handler so presentation is consistent, which I &lt;a class="link" href="https://blog-570662.gitlab.io/errors-that-tell-the-user-what-to-do-next/" &gt;also wrote up&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;One consistent exit for errors is column one. It survived. What didn&amp;rsquo;t survive was the &lt;em&gt;handler&lt;/em&gt;: RTB has no error-handler object at all, because Rust&amp;rsquo;s own return-from-&lt;code&gt;main&lt;/code&gt; convention plus a report hook does the job the handler was built to do. That one has &lt;a class="link" href="https://blog-570662.gitlab.io/errors-without-an-error-handler/" &gt;its own post too&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="what-the-exercise-was-actually-worth"&gt;What the exercise was actually worth
&lt;/h2&gt;&lt;p&gt;Every mechanism told the same story. The container, the registration, the config access, the error path, the cancellation signal that go-tool-base carries on a &lt;code&gt;context.Context&lt;/code&gt; and RTB carries on a &lt;code&gt;CancellationToken&lt;/code&gt;. In every case the &lt;em&gt;thing it achieved&lt;/em&gt; walked across to Rust untouched, and the &lt;em&gt;Go code that achieved it&lt;/em&gt; was left behind.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the useful result. Before this port I couldn&amp;rsquo;t have told you, for any given pattern in go-tool-base, whether it was load-bearing design or just the idiomatic Go way to write it that day. Now I can, because each one was forced to prove itself by being rebuilt from nothing in a language that flatly wouldn&amp;rsquo;t accept the original. Whatever survived was real. Whatever I had to replace was always replaceable, which means it was never really the point.&lt;/p&gt;
&lt;h2 id="the-upshot"&gt;The upshot
&lt;/h2&gt;&lt;p&gt;Porting a framework into a language with different idioms separates design from habit for free. The outcome a pattern produces is design, and it survives the move. The mechanism that produced it is idiom, and it gets left behind for the new language&amp;rsquo;s equivalent.&lt;/p&gt;
&lt;p&gt;go-tool-base&amp;rsquo;s &lt;code&gt;Props&lt;/code&gt; bag, its &lt;code&gt;init()&lt;/code&gt; registration, its key-based config access and its error handler were all idiom. The single context object, self-registration, layered precedence and a consistent error exit were all design, and all four came through to RTB intact. The next three posts take the most interesting replacements one at a time, starting with how a Rust command registers itself when the language won&amp;rsquo;t run anything before &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;</description></item><item><title>An AI interface that fits on one screen</title><link>https://blog-570662.gitlab.io/an-ai-interface-that-fits-on-one-screen/</link><pubDate>Fri, 27 Mar 2026 00:00:00 +0000</pubDate><guid>https://blog-570662.gitlab.io/an-ai-interface-that-fits-on-one-screen/</guid><description>&lt;img src="https://blog-570662.gitlab.io/an-ai-interface-that-fits-on-one-screen/cover-an-ai-interface-that-fits-on-one-screen.png" alt="Featured image of post An AI interface that fits on one screen" /&gt;&lt;p&gt;The moment you decide a CLI tool should talk to an LLM, there&amp;rsquo;s a strong gravitational pull towards reaching for LangChain, or one of its many relatives. It&amp;rsquo;s the obvious move. It&amp;rsquo;s also, for most CLI work, a bit like hiring a removals firm to carry a single box up the stairs.&lt;/p&gt;
&lt;p&gt;Let me explain why go-tool-base went the other way, and what &amp;ldquo;the other way&amp;rdquo; actually looks like.&lt;/p&gt;
&lt;h2 id="the-instinct-and-why-it-overshoots"&gt;The instinct, and why it overshoots
&lt;/h2&gt;&lt;p&gt;When you add AI to a tool, the instinct is to reach for the big general-purpose framework. LangChain and its relatives are capable, and they exist for a real need: orchestrating complex multi-step AI applications, with retrieval pipelines, memory stores, chains of calls, whole fleets of agents.&lt;/p&gt;
&lt;p&gt;Now look at what a CLI tool actually needs from an LLM. It needs to send a prompt and get text back. Sometimes it wants structured data back instead of prose. Sometimes it wants to let the model call a few of the tool&amp;rsquo;s own functions. That&amp;rsquo;s pretty much the whole list.&lt;/p&gt;
&lt;p&gt;Pulling in a framework built to orchestrate retrieval and agent swarms in order to do &lt;em&gt;that&lt;/em&gt; is a poor trade. You take on a large new vocabulary of concepts, a wide dependency surface, and a great deal of abstraction you&amp;rsquo;ll never touch, all to perform three or four operations. The framework isn&amp;rsquo;t wrong. It&amp;rsquo;s just answering a far bigger question than the one a CLI tool is asking.&lt;/p&gt;
&lt;h2 id="what-go-tool-base-chose-instead"&gt;What go-tool-base chose instead
&lt;/h2&gt;&lt;p&gt;go-tool-base didn&amp;rsquo;t reach for a framework. The decision is on the record in its own design notes: before a single line was written, LangChain Go, go-openai, Vercel&amp;rsquo;s AI SDK and around ten other options were evaluated, and not one of them matched what a CLI framework actually needs. So the &lt;code&gt;chat&lt;/code&gt; package was built deliberately small.&lt;/p&gt;
&lt;p&gt;How small? The entire core &lt;code&gt;ChatClient&lt;/code&gt; interface is four methods:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ChatClient&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;interface&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&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="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;SetTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="nx"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;Add&lt;/code&gt; appends a message to the conversation. &lt;code&gt;Chat&lt;/code&gt; sends a prompt and returns text. &lt;code&gt;Ask&lt;/code&gt; sends a prompt and returns a &lt;em&gt;typed Go struct&lt;/em&gt;, the model&amp;rsquo;s answer unmarshalled straight into a value you defined. &lt;code&gt;SetTools&lt;/code&gt; hands the model a set of your own functions it&amp;rsquo;s allowed to call. That&amp;rsquo;s the whole surface. Downstream code that uses AI never holds anything larger than this, and never has to know which provider is behind it.&lt;/p&gt;
&lt;p&gt;The package&amp;rsquo;s own documentation has a word for this: right-sized. Large enough to solve genuine provider-abstraction complexity, small enough that the full interface fits on a single screen.&lt;/p&gt;
&lt;h2 id="thin-is-not-the-same-as-does-little"&gt;&amp;ldquo;Thin&amp;rdquo; is not the same as &amp;ldquo;does little&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;This is the part worth being precise about, because &amp;ldquo;four methods&amp;rdquo; can sound like &amp;ldquo;barely does anything&amp;rdquo;, and that&amp;rsquo;s the wrong read entirely.&lt;/p&gt;
&lt;p&gt;Behind those four methods sits genuinely awkward work. Five providers (OpenAI, Claude, Gemini, a locally installed &lt;code&gt;claude&lt;/code&gt; binary, and any OpenAI-compatible endpoint) each with a different wire API, all normalised behind the one interface. A &lt;a class="link" href="https://blog-570662.gitlab.io/letting-the-ai-call-your-go-functions/" &gt;tool-calling loop&lt;/a&gt;. Structured output via JSON Schema, made to behave consistently across providers that each express it differently. Error normalisation. Token chunking.&lt;/p&gt;
&lt;p&gt;The point of a thin abstraction is not that there&amp;rsquo;s little underneath it. It&amp;rsquo;s that the &lt;em&gt;interface&lt;/em&gt; stays small while the &lt;em&gt;implementation&lt;/em&gt; quietly absorbs the complexity. Four methods on the surface; five provider integrations and a tool-calling loop below the waterline. The thinness is a property of what the caller sees, not of what the package does. A reach-for-LangChain decision gets that backwards: it exposes the caller to all the machinery, whether or not the caller will ever need it.&lt;/p&gt;
&lt;h2 id="the-core-stays-small-even-as-features-grow"&gt;The core stays small even as features grow
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s a neat detail in how &lt;code&gt;chat&lt;/code&gt; keeps the interface from creeping. The package also supports streaming responses and conversation persistence, both of which are real features with real surface area. Neither of them is in the four-method core.&lt;/p&gt;
&lt;p&gt;Instead they&amp;rsquo;re &lt;em&gt;separate, optional&lt;/em&gt; interfaces. A streaming-capable client also satisfies &lt;code&gt;StreamingChatClient&lt;/code&gt;; a persistable one also satisfies &lt;code&gt;PersistentChatClient&lt;/code&gt;. Code that wants those capabilities does a type assertion to ask for them, and code that doesn&amp;rsquo;t simply never sees them. So the common path stays four methods forever. New capabilities arrive as opt-in interfaces alongside the core, not as new methods bolted onto it. The thing that fits on one screen keeps fitting on one screen.&lt;/p&gt;
&lt;h2 id="extensible-without-forking-testable-without-a-network"&gt;Extensible without forking, testable without a network
&lt;/h2&gt;&lt;p&gt;Two more properties keep the package small without making it limiting.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s extensible. The provider list isn&amp;rsquo;t closed. A &lt;code&gt;RegisterProvider&lt;/code&gt; call lets any package contribute a new provider, and &lt;code&gt;chat.New&lt;/code&gt; will route to it. You add a backend without forking &lt;code&gt;pkg/chat&lt;/code&gt; or sending a patch upstream.&lt;/p&gt;
&lt;p&gt;And it&amp;rsquo;s testable. The package ships generated mocks. A downstream tool&amp;rsquo;s AI features can be tested against a mock &lt;code&gt;ChatClient&lt;/code&gt; returning canned responses, with no network, no API key, and no flakiness. Because the interface is four methods, that mock is trivial to set up and complete by construction. A sprawling framework interface is a sprawling thing to fake; a four-method one is not. (I&amp;rsquo;ll come back to testing AI code properly &lt;a class="link" href="https://blog-570662.gitlab.io/testing-code-that-calls-an-llm/" &gt;in a later post&lt;/a&gt;, because it deserves a whole article of its own.)&lt;/p&gt;
&lt;h2 id="the-right-size"&gt;The right size
&lt;/h2&gt;&lt;p&gt;When a CLI tool needs AI, the instinct is a large framework like LangChain. For orchestrating retrieval pipelines and agent swarms, that&amp;rsquo;s exactly the right tool. For sending a prompt, getting a struct back, and letting the model call a few functions, it&amp;rsquo;s enormous overkill.&lt;/p&gt;
&lt;p&gt;go-tool-base&amp;rsquo;s &lt;code&gt;chat&lt;/code&gt; package is the deliberate alternative, chosen only after LangChain Go and a dozen others were weighed up and rejected. Its core &lt;code&gt;ChatClient&lt;/code&gt; interface is four methods. Underneath sit five normalised providers, a tool-calling loop, structured output and error handling, but the caller sees four methods and never learns which provider is active. Streaming and persistence are opt-in interfaces beside the core, not additions to it. It extends without forking and tests without a network. Right-sized: the complexity is real, but it lives under the interface rather than in it.&lt;/p&gt;</description></item><item><title>Middleware for CLI commands, not just web servers</title><link>https://blog-570662.gitlab.io/middleware-for-cli-commands-not-just-web-servers/</link><pubDate>Tue, 24 Mar 2026 00:00:00 +0000</pubDate><guid>https://blog-570662.gitlab.io/middleware-for-cli-commands-not-just-web-servers/</guid><description>&lt;img src="https://blog-570662.gitlab.io/middleware-for-cli-commands-not-just-web-servers/cover-middleware-for-cli-commands-not-just-web-servers.png" alt="Featured image of post Middleware for CLI commands, not just web servers" /&gt;&lt;p&gt;Every CLI tool past a certain size grows a category of logic that doesn&amp;rsquo;t really belong to any one command, and yet has to happen for loads of them. Timing. An auth check. Panic recovery, so a crash becomes a clean error instead of a stack-trace all over someone&amp;rsquo;s terminal. A log line saying the command started and how it finished.&lt;/p&gt;
&lt;p&gt;Web frameworks sorted this out years ago. CLIs, for some reason, mostly still copy-paste it around.&lt;/p&gt;
&lt;h2 id="the-logic-that-belongs-to-no-single-command"&gt;The logic that belongs to no single command
&lt;/h2&gt;&lt;p&gt;That category of logic doesn&amp;rsquo;t belong to any one command, yet needs to happen for many of them. Time how long the command took. Check the user is authenticated before a command that needs it. Recover from a panic so a crash becomes a clean error rather than a stack-trace vomited across the screen. Log that the command started and how it ended.&lt;/p&gt;
&lt;p&gt;None of that is the command&amp;rsquo;s &lt;em&gt;job&lt;/em&gt;. The &lt;code&gt;deploy&lt;/code&gt; command&amp;rsquo;s job is to deploy. But timing and recovery and auth still have to happen around it, and around &lt;code&gt;build&lt;/code&gt;, and around &lt;code&gt;sync&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Put that logic &lt;em&gt;inside&lt;/em&gt; each command&amp;rsquo;s &lt;code&gt;RunE&lt;/code&gt; and you&amp;rsquo;ve copied the same six lines into thirty functions, which means thirty places to fix when the logging format changes and thirty chances to forget one of them. Cross-cutting concerns copied by hand don&amp;rsquo;t stay consistent. They drift, every time.&lt;/p&gt;
&lt;h2 id="web-frameworks-already-solved-this"&gt;Web frameworks already solved this
&lt;/h2&gt;&lt;p&gt;This is not a new problem. It&amp;rsquo;s about the oldest problem in web frameworks, and they settled on an answer a long time ago: middleware. Gin has it, Echo has it, every HTTP stack you&amp;rsquo;ve ever touched has it. A middleware is a wrapper that sits around a handler, runs its cross-cutting logic, and calls through to the handler in the middle.&lt;/p&gt;
&lt;p&gt;A CLI command is, structurally, just a handler too. So go-tool-base brings the same pattern to the Cobra command tree, with the same functional Chain shape:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Middleware&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;cobra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;cobra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A middleware receives the &lt;em&gt;next&lt;/em&gt; handler in the chain and returns a new handler that wraps it. You compose a stack of them, and each command&amp;rsquo;s real &lt;code&gt;RunE&lt;/code&gt; runs in the middle of the onion. Write the timing logic once, as one middleware, and every command in the chain is timed. Change the log format once and all thirty commands change with it, because there was only ever one copy. (The &amp;ldquo;write it once, in a place where everyone inherits it&amp;rdquo; drum again, which I will keep banging until the series runs out.)&lt;/p&gt;
&lt;h2 id="but-cobra-already-has-prerun"&gt;&amp;ldquo;But Cobra already has PreRun&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;It does, and this is the objection worth answering properly, because Cobra ships &lt;code&gt;PersistentPreRun&lt;/code&gt; and &lt;code&gt;PreRun&lt;/code&gt; hooks and they look, at a glance, like they cover this.&lt;/p&gt;
&lt;p&gt;They don&amp;rsquo;t, and the reason is structural. A &lt;code&gt;PreRun&lt;/code&gt; hook is a thing that happens &lt;em&gt;before&lt;/em&gt; the command. That&amp;rsquo;s all it is. It can&amp;rsquo;t run anything &lt;em&gt;after&lt;/em&gt;. It can&amp;rsquo;t wrap the command in a &lt;code&gt;defer&lt;/code&gt;. It can&amp;rsquo;t catch a panic the command throws. It can&amp;rsquo;t measure how long the command took, because measuring a duration needs a start point &lt;em&gt;and&lt;/em&gt; an end point, and the hook only owns the start.&lt;/p&gt;
&lt;p&gt;A middleware wraps the &lt;em&gt;entire&lt;/em&gt; execution. Because it&amp;rsquo;s a function that calls &lt;code&gt;next()&lt;/code&gt; in its own body, it straddles the command:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;TimingMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;HandlerFunc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;HandlerFunc&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;cobra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// the command runs here&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Debug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;command finished&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;took&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Since&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Before, after, and around. A recovery middleware can put a &lt;code&gt;defer recover()&lt;/code&gt; in place that a &lt;code&gt;PreRun&lt;/code&gt; hook structurally cannot. An auth middleware can check a condition and return an error &lt;em&gt;instead of calling &lt;code&gt;next()&lt;/code&gt; at all&lt;/em&gt;, refusing to let the command run in the first place. &lt;code&gt;PreRun&lt;/code&gt; can&amp;rsquo;t veto the command; it runs, and then the command runs regardless.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;PreRun&lt;/code&gt; is a notification that the command is about to happen. Middleware is control over whether and how it happens. For genuine cross-cutting concerns you need the second thing, not the first.&lt;/p&gt;
&lt;h2 id="to-sum-up"&gt;To sum up
&lt;/h2&gt;&lt;p&gt;Timing, auth, recovery and logging are cross-cutting concerns: necessary for many commands, owned by none. Hand-copied into every &lt;code&gt;RunE&lt;/code&gt;, they drift out of sync. Web frameworks fixed this with middleware years ago, and a CLI command is structurally just another handler.&lt;/p&gt;
&lt;p&gt;go-tool-base brings the functional Chain middleware pattern to the Cobra command tree. A middleware wraps a command&amp;rsquo;s whole execution, so it acts before and after and can decide whether the command runs at all&amp;hellip; strictly more than Cobra&amp;rsquo;s &lt;code&gt;PreRun&lt;/code&gt; hooks, which only fire beforehand and can&amp;rsquo;t wrap, recover, time, or veto. Write the concern once, wrap the chain, and every command inherits it consistently.&lt;/p&gt;</description></item><item><title>A logging interface that doesn't leak its backend</title><link>https://blog-570662.gitlab.io/a-logging-interface-that-doesnt-leak-its-backend/</link><pubDate>Mon, 23 Mar 2026 00:00:00 +0000</pubDate><guid>https://blog-570662.gitlab.io/a-logging-interface-that-doesnt-leak-its-backend/</guid><description>&lt;img src="https://blog-570662.gitlab.io/a-logging-interface-that-doesnt-leak-its-backend/cover-a-logging-interface-that-doesnt-leak-its-backend.png" alt="Featured image of post A logging interface that doesn't leak its backend" /&gt;&lt;p&gt;The same tool, in two different lives, wants two completely different kinds of log.&lt;/p&gt;
&lt;p&gt;On my laptop I want logs I can actually read: colour, alignment, friendly timestamps. The very same tool running as a daemon in a container wants none of that. It wants structured JSON, one object a line, ready for a log aggregator to swallow. And in a test I want the logger to shut up entirely. The interesting question is what it costs you to move between the three.&lt;/p&gt;
&lt;h2 id="the-same-tool-wants-different-logs"&gt;The same tool wants different logs
&lt;/h2&gt;&lt;p&gt;On a developer&amp;rsquo;s machine the tool is a CLI. You want logs that are pleasant to read in a terminal: colour, alignment, human-friendly timestamps. The charmbracelet logger does that beautifully.&lt;/p&gt;
&lt;p&gt;Then the very same tool grows a &lt;code&gt;serve&lt;/code&gt; command and gets deployed as a daemon in a container. Now coloured terminal output is worse than useless. The log aggregator wants structured JSON, one object per line, machine-parseable. &lt;code&gt;slog&lt;/code&gt; does that.&lt;/p&gt;
&lt;p&gt;And in tests you want neither. You want the logger to exist, satisfy the interface, and stay completely silent.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s three different logging backends, wanted by one tool across three different lives. So what does switching between them actually cost?&lt;/p&gt;
&lt;h2 id="what-it-costs-depends-on-what-your-packages-imported"&gt;What it costs depends on what your packages imported
&lt;/h2&gt;&lt;p&gt;If your packages import a concrete logger, if &lt;code&gt;pkg/config&lt;/code&gt; and &lt;code&gt;pkg/setup&lt;/code&gt; and twenty others each have &lt;code&gt;import &amp;quot;github.com/charmbracelet/log&amp;quot;&lt;/code&gt; and take a &lt;code&gt;*log.Logger&lt;/code&gt;, then the backend is welded into the entire codebase. Switching to JSON for the container build means editing the import and the parameter type in every single one of those packages. The backend has &lt;em&gt;leaked&lt;/em&gt;. A detail that should have been one decision has become a property of a hundred files.&lt;/p&gt;
&lt;p&gt;go-tool-base doesn&amp;rsquo;t let it leak. Every package in the framework accepts a &lt;code&gt;logger.Logger&lt;/code&gt;, an interface, and nothing else. No package anywhere imports a concrete logging library. A package states, in its types, &amp;ldquo;I need something I can log through&amp;rdquo;, and stops right there. It has no idea, and no way to find out, what&amp;rsquo;s actually on the other end.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// what every package depends on&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Logger&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;interface&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Debug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="kt"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="kt"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="kt"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="kt"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// ...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The backend gets chosen once, at the top, when the tool builds its &lt;a class="link" href="https://blog-570662.gitlab.io/props-the-container-that-does-the-heavy-lifting/" &gt;Props&lt;/a&gt;. It travels down to every package as the interface, through the &lt;code&gt;Props&lt;/code&gt; container. The packages underneath never see the concrete type, so the concrete type can change without a single one of them noticing. (There&amp;rsquo;s that &amp;ldquo;decide it once, in one place&amp;rdquo; theme again. I did warn you it runs through everything.)&lt;/p&gt;
&lt;h2 id="three-backends-and-the-swap-is-one-line"&gt;Three backends, and the swap is one line
&lt;/h2&gt;&lt;p&gt;go-tool-base ships three implementations of that interface:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;charmbracelet&lt;/strong&gt; (&lt;code&gt;logger.NewCharm(w, opts...)&lt;/code&gt;). Coloured, styled, for humans at a terminal. The CLI default.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;slog JSON&lt;/strong&gt;, a &lt;code&gt;slog&lt;/code&gt;-backed backend emitting structured JSON, for daemons and containers feeding a log aggregator.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;noop&lt;/strong&gt;, which does precisely nothing, for tests that want a real &lt;code&gt;Logger&lt;/code&gt; and total silence.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Switching the tool from a friendly CLI logger to container-ready JSON is a change to the one line in &lt;code&gt;main()&lt;/code&gt; that constructs the logger. That&amp;rsquo;s the lot. &lt;code&gt;pkg/config&lt;/code&gt; doesn&amp;rsquo;t change. &lt;code&gt;pkg/setup&lt;/code&gt; doesn&amp;rsquo;t change. None of the twenty packages change, because none of them ever knew which backend they had. The decision was always one line; the interface is what &lt;em&gt;kept&lt;/em&gt; it one line.&lt;/p&gt;
&lt;p&gt;The noop backend deserves its own mention, because it&amp;rsquo;s the one people underrate. A test for a command shouldn&amp;rsquo;t be spraying log output all over the test run, but the command still needs a non-nil &lt;code&gt;Logger&lt;/code&gt; to function. &lt;code&gt;logger.NewNoop()&lt;/code&gt; gives you exactly that: interface satisfied, output binned, test quiet. And because it&amp;rsquo;s just another implementation of the same interface, no test needs any special logging machinery. It passes a different backend, exactly the way the container build does.&lt;/p&gt;
&lt;h2 id="the-general-shape"&gt;The general shape
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s nothing exotic going on here. It&amp;rsquo;s &amp;ldquo;depend on interfaces, not implementations&amp;rdquo;, which every Go developer has had drilled into them at some point. The bit worth holding onto is &lt;em&gt;where&lt;/em&gt; the rule actually pays out, and it&amp;rsquo;s at the seams between a stable core and a detail you know full well you&amp;rsquo;ll want to vary.&lt;/p&gt;
&lt;p&gt;A logging backend is exactly such a detail. You will want it different in a terminal, in a container, and in a test. So the thing your code depends on has to be the interface, and the concrete backend has to be chosen at one well-known point and nowhere else. Get that boundary right and &amp;ldquo;we need JSON logs in production&amp;rdquo; is a one-line change. Get it wrong and it&amp;rsquo;s a refactor and a bad afternoon.&lt;/p&gt;
&lt;h2 id="what-it-comes-down-to"&gt;What it comes down to
&lt;/h2&gt;&lt;p&gt;One tool legitimately wants three different logging backends across its life: coloured output in a terminal, structured JSON in a container, silence in a test. The cost of moving between them is decided entirely by whether your packages imported a concrete logger or an interface.&lt;/p&gt;
&lt;p&gt;go-tool-base&amp;rsquo;s packages depend only on &lt;code&gt;logger.Logger&lt;/code&gt;, never a backend. Three implementations ship (charmbracelet, &lt;code&gt;slog&lt;/code&gt; JSON, noop) and the backend is chosen once, in &lt;code&gt;main()&lt;/code&gt;, then carried everywhere as the interface through &lt;code&gt;Props&lt;/code&gt;. Switching is one line at the top, because the detail was never allowed to leak into the hundred files below it.&lt;/p&gt;</description></item><item><title>Many embedded filesystems, one merged view</title><link>https://blog-570662.gitlab.io/many-embedded-filesystems-one-merged-view/</link><pubDate>Sat, 21 Mar 2026 00:00:00 +0000</pubDate><guid>https://blog-570662.gitlab.io/many-embedded-filesystems-one-merged-view/</guid><description>&lt;img src="https://blog-570662.gitlab.io/many-embedded-filesystems-one-merged-view/cover-many-embedded-filesystems-one-merged-view.png" alt="Featured image of post Many embedded filesystems, one merged view" /&gt;&lt;p&gt;Go&amp;rsquo;s &lt;code&gt;embed&lt;/code&gt; package is one of those features that makes you slightly giddy the first time you use it. One &lt;code&gt;//go:embed&lt;/code&gt; directive and your default config, your templates, your docs are all baked into the binary. The tool just works the moment it&amp;rsquo;s installed, with nothing external to lose or forget to ship.&lt;/p&gt;
&lt;p&gt;And then you go and build something modular on top of it, and you discover the catch nobody warned you about.&lt;/p&gt;
&lt;h2 id="embedfs-is-an-island"&gt;&lt;code&gt;embed.FS&lt;/code&gt; is an island
&lt;/h2&gt;&lt;p&gt;An &lt;code&gt;embed.FS&lt;/code&gt; has a property that&amp;rsquo;s easy to miss until it bites: it&amp;rsquo;s local to the package that declared it. The &lt;code&gt;//go:embed&lt;/code&gt; directive can only see files at or below its own source file. So in any project bigger than a toy, you don&amp;rsquo;t have &lt;em&gt;an&lt;/em&gt; embedded filesystem. You have many. The root package embeds one. Each feature, each subcommand that ships its own templates or defaults, embeds another. They&amp;rsquo;re islands, one per package, and Go gives you no native way to make them behave as a whole.&lt;/p&gt;
&lt;p&gt;For most files that&amp;rsquo;s perfectly fine. A feature&amp;rsquo;s templates can stay on the feature&amp;rsquo;s island; nothing else needs them.&lt;/p&gt;
&lt;p&gt;It stops being fine the moment features need to contribute to something shared.&lt;/p&gt;
&lt;h2 id="the-shared-config-problem"&gt;The shared-config problem
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the case that forces the issue. A go-tool-base tool has a global &lt;code&gt;config.yaml&lt;/code&gt; of defaults, embedded at the root. Now you add a feature, and that feature has its own configuration keys, with their own sensible defaults.&lt;/p&gt;
&lt;p&gt;Where do those defaults go?&lt;/p&gt;
&lt;p&gt;The naive answer is: edit the root &lt;code&gt;config.yaml&lt;/code&gt; and add the feature&amp;rsquo;s section. And that&amp;rsquo;s a genuinely bad answer, because it inverts the dependency. The root config now has to know about every feature. Add a feature, edit the centre. Remove one, edit the centre again. The central file becomes a pinch point that every feature has to reach into, and a modular architecture where you can&amp;rsquo;t add a module without editing the core isn&amp;rsquo;t really modular at all&amp;hellip; it just has more files.&lt;/p&gt;
&lt;p&gt;What you actually want is for the feature to ship its own slice of default config, on its own island, and for the global config the tool reads to somehow already contain it. The feature contributes; the centre doesn&amp;rsquo;t budge.&lt;/p&gt;
&lt;h2 id="propsassets-merge-the-islands"&gt;&lt;code&gt;props.Assets&lt;/code&gt;: merge the islands
&lt;/h2&gt;&lt;p&gt;That&amp;rsquo;s the job of &lt;code&gt;props.Assets&lt;/code&gt;. (Yes, it lives on &lt;a class="link" href="https://blog-570662.gitlab.io/props-the-container-that-does-the-heavy-lifting/" &gt;Props&lt;/a&gt;, the load-bearing container I keep going on about. Most of the good stuff does.) It&amp;rsquo;s a layer that implements the standard &lt;code&gt;fs.FS&lt;/code&gt; interface, and into it you &lt;code&gt;Register&lt;/code&gt; each &lt;code&gt;embed.FS&lt;/code&gt; under a name:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// root main.go&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;Assets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewAssets&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AssetMap&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;root&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;assets&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// a feature&amp;#39;s command constructor&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;//go:embed assets/*&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;assets&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FS&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;NewCmdFeature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;cobra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Command&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Assets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;feature&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;assets&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// ...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Now &lt;code&gt;Props&lt;/code&gt; carries one &lt;code&gt;Assets&lt;/code&gt; value that represents all the islands as a single filesystem. The root&amp;rsquo;s files and every registered feature&amp;rsquo;s files, addressable through one &lt;code&gt;fs.FS&lt;/code&gt;. Each registration is named, so the islands stay individually identifiable, but they read as one.&lt;/p&gt;
&lt;p&gt;That alone solves the addressing problem. The genuinely clever part is what happens for structured files.&lt;/p&gt;
&lt;h2 id="opening-a-file-that-exists-in-several-places"&gt;Opening a file that exists in several places
&lt;/h2&gt;&lt;p&gt;When you &lt;code&gt;Open&lt;/code&gt; a path through &lt;code&gt;props.Assets&lt;/code&gt; and that path has a structured extension (&lt;code&gt;.yaml&lt;/code&gt;, &lt;code&gt;.yml&lt;/code&gt;, &lt;code&gt;.json&lt;/code&gt;, &lt;code&gt;.csv&lt;/code&gt;) it doesn&amp;rsquo;t simply return the first match it stumbles across. It does this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Discovery.&lt;/strong&gt; It finds every instance of that path, across every registered filesystem.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Parsing.&lt;/strong&gt; It unmarshals each one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Merging.&lt;/strong&gt; It deep-merges the parsed data, using &lt;code&gt;mergo&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Re-serialisation.&lt;/strong&gt; It hands you back a single &lt;code&gt;fs.File&lt;/code&gt; whose contents are the combined, merged result.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;So picture the shared-config problem again, only solved this time. The root ships a &lt;code&gt;config.yaml&lt;/code&gt; with the base defaults. Each feature ships a &lt;code&gt;config.yaml&lt;/code&gt; on its own island carrying only its own keys. Nobody edits anybody else&amp;rsquo;s file. When the &lt;code&gt;init&lt;/code&gt; command opens &lt;code&gt;config.yaml&lt;/code&gt; through &lt;code&gt;props.Assets&lt;/code&gt;, it doesn&amp;rsquo;t get the root&amp;rsquo;s copy. It gets the deep-merge of the root&amp;rsquo;s copy and every registered feature&amp;rsquo;s copy: one &lt;code&gt;config.yaml&lt;/code&gt; that contains every default in the tool, assembled at runtime from contributions that never knew about each other.&lt;/p&gt;
&lt;p&gt;A feature contributes its defaults simply by existing and registering. The centre never changes. That&amp;rsquo;s the modular property the naive approach couldn&amp;rsquo;t give you, and it generalises well beyond config&amp;hellip; the same merge applies to a shared &lt;code&gt;commands.csv&lt;/code&gt;, or any structured file features want to add rows or keys to.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s also a &lt;code&gt;Mount&lt;/code&gt; method for attaching an arbitrary &lt;code&gt;fs.FS&lt;/code&gt; at a virtual path, which is handy for surfacing something external (a temp directory, say) as part of the same tree. But the structured merge is the feature that really earns &lt;code&gt;Assets&lt;/code&gt; its place.&lt;/p&gt;
&lt;h2 id="boiling-it-down"&gt;Boiling it down
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;embed.FS&lt;/code&gt; is per-package by design, so a modular CLI ends up with many embedded filesystems, one island per feature. Most of the time that&amp;rsquo;s fine. It fails specifically when features need to contribute to a shared resource like the global &lt;code&gt;config.yaml&lt;/code&gt;, because the naive fix forces every feature to reach in and edit a central file.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;props.Assets&lt;/code&gt; merges all the registered islands into a single &lt;code&gt;fs.FS&lt;/code&gt;, and for structured files it goes further: opening a &lt;code&gt;.yaml&lt;/code&gt;, &lt;code&gt;.json&lt;/code&gt; or &lt;code&gt;.csv&lt;/code&gt; discovers every copy across every island, deep-merges them, and returns the combined whole. A feature drops its own defaults onto its own island, registers, and the merged config the tool reads already includes them. Contribution without coupling, which is rather the whole point of being modular in the first place.&lt;/p&gt;</description></item><item><title>Props: the container that does the heavy lifting</title><link>https://blog-570662.gitlab.io/props-the-container-that-does-the-heavy-lifting/</link><pubDate>Sat, 21 Mar 2026 00:00:00 +0000</pubDate><guid>https://blog-570662.gitlab.io/props-the-container-that-does-the-heavy-lifting/</guid><description>&lt;img src="https://blog-570662.gitlab.io/props-the-container-that-does-the-heavy-lifting/cover-props.png" alt="Featured image of post Props: the container that does the heavy lifting" /&gt;&lt;p&gt;I name-dropped &lt;code&gt;Props&lt;/code&gt; back in the &lt;a class="link" href="https://blog-570662.gitlab.io/introducing-go-tool-base/" &gt;introduction&lt;/a&gt; and then rather glossed over it, which was a bit unfair of me, because it&amp;rsquo;s the single most important design decision in the whole framework. So let&amp;rsquo;s give it the attention it actually deserves.&lt;/p&gt;
&lt;p&gt;And the best place to start, oddly enough, is the name.&lt;/p&gt;
&lt;h2 id="start-with-the-name"&gt;Start with the name
&lt;/h2&gt;&lt;p&gt;The container at the centre of go-tool-base is called &lt;code&gt;Props&lt;/code&gt;, and the name is doing real work, so we&amp;rsquo;ll start there.&lt;/p&gt;
&lt;p&gt;It is not short for &amp;ldquo;properties&amp;rdquo;, though it does hold a few. A &lt;em&gt;prop&lt;/em&gt; is the heavy timber or steel beam that stops a structure quietly collapsing in on itself. And for anyone who follows the rugby: a prop is the position in the scrum, the broad-shouldered forward whose entire job is to provide structural support so everyone else can get on with the game.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the design brief, in a single word. &lt;code&gt;Props&lt;/code&gt; is not where the clever, flashy work happens. It scores no tries. It&amp;rsquo;s the unglamorous, load-bearing thing that holds the framework up so that your actual command logic gets to be the interesting part. Understand the name and you understand what the struct is &lt;em&gt;for&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id="what-it-carries"&gt;What it carries
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;Props&lt;/code&gt; is the single object passed to every command constructor in a go-tool-base tool. It holds the dependencies a command might need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Tool&lt;/code&gt;, metadata about the CLI (name, summary, release source).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Logger&lt;/code&gt;, the logging abstraction.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Config&lt;/code&gt;, the loaded configuration container.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;FS&lt;/code&gt;, a filesystem abstraction (&lt;code&gt;afero&lt;/code&gt;), so a command never touches the real disk directly.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Assets&lt;/code&gt;, the embedded-resource manager.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Version&lt;/code&gt;, build information.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ErrorHandler&lt;/code&gt;, the centralised error reporter.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A command constructor&amp;rsquo;s signature is, accordingly, boring on purpose:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;NewCmdExample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;cobra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Command&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="o"&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&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;One parameter. Everything the command could possibly need is reachable through it. No globals, no &lt;code&gt;init()&lt;/code&gt;-time wiring, no twelve-argument constructor that quietly grows a thirteenth argument next month.&lt;/p&gt;
&lt;h2 id="why-a-struct-and-not-contextcontext"&gt;Why a struct, and not &lt;code&gt;context.Context&lt;/code&gt;
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the design decision I actually want to defend, because it&amp;rsquo;s the one Go developers tend to raise an eyebrow at. Go already has a well-known way to carry things through a call tree: &lt;code&gt;context.Context&lt;/code&gt;. So why not just put the logger and the config in the context and pass that around?&lt;/p&gt;
&lt;p&gt;Because &lt;code&gt;context.Context&lt;/code&gt; carries its values as &lt;code&gt;interface{}&lt;/code&gt;, and that&amp;rsquo;s the wrong trade for &lt;em&gt;dependencies&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Pull a dependency out of a context and you get this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;logger&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;).(&lt;/span&gt;&lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Logger&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// a runtime type assertion&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That one line has two separate ways to hurt you. The key is a bare string, so a typo compiles perfectly happily and then fails at runtime. The type assertion is unchecked, so if the wrong thing is sitting under that key, your tool panics in front of a user. Neither failure is visible to the compiler. Neither is visible to your IDE. You find out when it breaks, which is to say at the worst possible time.&lt;/p&gt;
&lt;p&gt;Pull the same dependency out of &lt;code&gt;Props&lt;/code&gt; and you get this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;starting&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// a field access&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;p.Logger&lt;/code&gt; is a typed field. If it doesn&amp;rsquo;t exist, or you&amp;rsquo;ve used it wrong, the code simply doesn&amp;rsquo;t compile. Your IDE autocompletes it. Refactor the &lt;code&gt;Logger&lt;/code&gt; interface and every misuse lights up at build time. There&amp;rsquo;s no runtime type assertion, because there&amp;rsquo;s no &lt;code&gt;interface{}&lt;/code&gt; to assert from in the first place.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;context.Context&lt;/code&gt; is the right tool for what it was designed for: cancellation, deadlines, request-scoped signals that genuinely cross API boundaries. It&amp;rsquo;s the wrong tool for &amp;ldquo;here are my program&amp;rsquo;s services&amp;rdquo;, because it trades away the compiler&amp;rsquo;s help for a flexibility you really don&amp;rsquo;t want here. Dependencies should be &lt;em&gt;declared&lt;/em&gt;, somewhere the compiler checks them. &lt;code&gt;Props&lt;/code&gt; is that somewhere.&lt;/p&gt;
&lt;h2 id="what-you-get-back-for-it"&gt;What you get back for it
&lt;/h2&gt;&lt;p&gt;That one decision pays out in three currencies.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Testability.&lt;/strong&gt; A command is now a pure function of its &lt;code&gt;Props&lt;/code&gt;. To test it, you build a &lt;code&gt;Props&lt;/code&gt; with the doubles you want (an in-memory &lt;code&gt;FS&lt;/code&gt; instead of the real disk, a no-op &lt;code&gt;Logger&lt;/code&gt;, a config you&amp;rsquo;ve populated by hand) and call the constructor. No global state to reset between tests, no monkey-patching, no &lt;code&gt;init()&lt;/code&gt; order to puzzle over. The dependency is an argument, so the test just passes a different one.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Consistency.&lt;/strong&gt; Cross-cutting changes have exactly one place to happen. When the global &lt;code&gt;--debug&lt;/code&gt; flag flips the log level, it does so on the &lt;code&gt;Logger&lt;/code&gt; inside &lt;code&gt;Props&lt;/code&gt;, and because every command reads its logger from the same &lt;code&gt;Props&lt;/code&gt;, every command gets the new level. No command can drift, because none of them owns its own copy.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Extensibility.&lt;/strong&gt; Adding a new framework-wide service is just adding a field to one struct. Every command can immediately reach it; none of them needed touching to make it reachable.&lt;/p&gt;
&lt;h2 id="to-sum-up"&gt;To sum up
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;Props&lt;/code&gt; is the dependency-injection container at the heart of go-tool-base: one struct, passed to every command, holding the logger, config, filesystem, assets, error handler and tool metadata. It&amp;rsquo;s a concrete struct rather than a &lt;code&gt;context.Context&lt;/code&gt; payload entirely on purpose, because dependencies belong somewhere the compiler can check them, not behind a string key and a hopeful runtime type assertion. That single choice buys you testability, consistency and easy extension.&lt;/p&gt;
&lt;p&gt;The name says it best, really. &lt;code&gt;Props&lt;/code&gt; doesn&amp;rsquo;t score the tries. It&amp;rsquo;s the broad-shouldered thing in the scrum that stops the whole framework folding, so the rest of your code is free to go and play.&lt;/p&gt;</description></item></channel></rss>