<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Modules on PHP Boy Scout</title>
        <link>https://phpboyscout.uk/tags/modules/</link>
        <description>Recent content in Modules on PHP Boy Scout</description>
        <generator>Hugo -- gohugo.io</generator>
        <language>en-gb</language>
        <copyright>Matt Cockayne</copyright>
        <lastBuildDate>Fri, 23 Oct 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://phpboyscout.uk/tags/modules/index.xml" rel="self" type="application/rss+xml" /><item>
            <title>Eighty-one megabytes of hello world</title>
            <link>https://phpboyscout.uk/eighty-one-megabytes-of-hello-world/</link>
            <pubDate>Fri, 23 Oct 2026 00:00:00 +0000</pubDate>
            <guid>https://phpboyscout.uk/eighty-one-megabytes-of-hello-world/</guid>
            <description>&lt;img src=&#34;https://phpboyscout.uk/eighty-one-megabytes-of-hello-world/cover-eighty-one-megabytes-of-hello-world_hu_44d35787de444587.jpg&#34; alt=&#34;Featured image of post Eighty-one megabytes of hello world&#34; /&gt;&lt;p&gt;There&amp;rsquo;s a question I should have asked &lt;a class=&#34;link&#34; href=&#34;https://gtb.phpboyscout.uk/&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;go-tool-base&lt;/a&gt; about a year before I did (give or take), and it&amp;rsquo;s a slightly rude one to ask any framework: how big is a tool that does nothing?&lt;/p&gt;&#xA;&lt;p&gt;I finally got round to asking it one morning in September, and it came back with&amp;hellip; 81 MB.&lt;/p&gt;&#xA;&lt;h2 id=&#34;everything-and-the-kitchen-sink&#34;&gt;Everything, and the kitchen sink&#xA;&lt;/h2&gt;&lt;p&gt;I&amp;rsquo;d been wiring more chat providers into the framework (the local ones, &lt;code&gt;claude-local&lt;/code&gt; and &lt;code&gt;codex-local&lt;/code&gt;, because I&amp;rsquo;m a cheapskate and the metered API bills were getting silly), and each new provider I added was another module for any tool built on the framework to carry. That nagged at me, a tad. A project generated with &lt;code&gt;gtb&lt;/code&gt; imports go-tool-base, and go-tool-base at the time imported the lot, each chat adapter and each forge adapter it knew about, so a tool got OpenAI&amp;rsquo;s SDK, Anthropic&amp;rsquo;s, Google&amp;rsquo;s auth stack, go-github and the GitLab API client whether it talked to any of them or not. &lt;a class=&#34;link&#34; href=&#34;https://colophon.phpboyscout.uk/&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;colophon&lt;/a&gt; uses forges and no chat provider, &lt;a class=&#34;link&#34; href=&#34;https://keryx.phpboyscout.uk/&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;keryx&lt;/a&gt; uses chat and one forge, and each was carrying the other&amp;rsquo;s SDKs.&lt;/p&gt;&#xA;&lt;p&gt;I knew this, more or less. I&amp;rsquo;d told myself it was fine because there was a way out. Two files in &lt;code&gt;pkg/&lt;/code&gt; did the registering, and both carried a comment saying so: &lt;a class=&#34;link&#34; href=&#34;https://gitlab.com/phpboyscout/go-tool-base/-/blob/b2fc134f/pkg/chat/providers.go&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;one for chat&lt;/a&gt; said &lt;em&gt;&amp;ldquo;a regulated downstream that wants a lighter binary can build its own adapter and import only the providers it needs&amp;rdquo;&lt;/em&gt;, and &lt;a class=&#34;link&#34; href=&#34;https://gitlab.com/phpboyscout/go-tool-base/-/blob/b2fc134f/pkg/setup/providers.go&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;the other for forges&lt;/a&gt; said &lt;em&gt;&amp;ldquo;a tool that needs only one forge can drop the others: replace this file&amp;rsquo;s effect by importing just the providers you want&amp;rdquo;&lt;/em&gt;. Very reassuring, and written by me some months earlier with what I&amp;rsquo;m sure felt like care at the time, and not a word of it was true.&lt;/p&gt;&#xA;&lt;h2 id=&#34;go-has-no-way-to-un-import&#34;&gt;Go has no way to un-import&#xA;&lt;/h2&gt;&lt;p&gt;A blank import in Go (&lt;code&gt;import _ &amp;quot;some/module&amp;quot;&lt;/code&gt;) exists to run that module&amp;rsquo;s &lt;code&gt;init()&lt;/code&gt; for its side effects, and the side effect here is an adapter registering itself with the framework&amp;rsquo;s registry. &lt;a class=&#34;link&#34; href=&#34;https://phpboyscout.uk/the-blank-import-that-keeps-a-dependency-out-of-your-binary/&#34; &gt;I wrote about that pattern approvingly in April&lt;/a&gt;: put the blank import in the tool&amp;rsquo;s own &lt;code&gt;main&lt;/code&gt;, and deleting one line drops that adapter and its SDK from the binary. The keychain backend worked like that, and so did the signing backends.&lt;/p&gt;&#xA;&lt;p&gt;The chat providers and the forges didn&amp;rsquo;t. Their blank imports sat in &lt;code&gt;pkg/chat&lt;/code&gt; and &lt;code&gt;pkg/setup&lt;/code&gt;, and &lt;code&gt;pkg/setup&lt;/code&gt; is imported by &lt;code&gt;pkg/cmd/root&lt;/code&gt;, which is the root command any tool built on the framework uses. So the imports arrived through the front door, with the framework, and a downstream tool had about as much say in the matter as it has over which packages the standard library links. You can add imports in Go, but you can&amp;rsquo;t subtract one that something you depend on has already made, and the comment was describing something you simply couldn&amp;rsquo;t do.&lt;/p&gt;&#xA;&lt;p&gt;I don&amp;rsquo;t think I&amp;rsquo;d have found it by reading, so I ran a spike, an hour timeboxed: a &lt;code&gt;main&lt;/code&gt; that calls &lt;code&gt;root.NewCmdRoot&lt;/code&gt; and not much else, built static and stripped, then the two provider files emptied and the same build again.&lt;/p&gt;&#xA;&lt;table&gt;&#xA;&#x9;&lt;thead&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;th&gt;&lt;/th&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;th&gt;with the adapters in &lt;code&gt;pkg/&lt;/code&gt;&lt;/th&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;th&gt;without&lt;/th&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&lt;/thead&gt;&#xA;&#x9;&lt;tbody&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;stripped binary&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;81,326,240 bytes&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;34,312,352 bytes&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;packages linked&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;948&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;812&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&#x9;&#x9;&lt;tr&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;modules in the closure&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;171&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&#x9;&#x9;&lt;td&gt;136&lt;/td&gt;&#xA;&#x9;&#x9;&#x9;&lt;/tr&gt;&#xA;&#x9;&lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;p&gt;Fifty-eight percent of hello world was&amp;hellip; SDKs for services it had no plans to call! Twenty-three packages of openai-go, twenty-three of the Anthropic SDK, sixteen of Google&amp;rsquo;s auth library, the GitLab API client in full. The 34 MB that remained is a separate conversation (grpc, go-git, protobuf, OpenTelemetry and the charm TUI stack, all framework-side, all things a tool might plausibly use) and I&amp;rsquo;ve not had it yet.&lt;/p&gt;&#xA;&lt;h2 id=&#34;one-binary-needs-the-sink&#34;&gt;One binary needs the sink&#xA;&lt;/h2&gt;&lt;p&gt;The fix was fairly obvious once the number was in front of me. The keychain had already shown me the shape of it: the registering belongs in a tool&amp;rsquo;s &lt;code&gt;main&lt;/code&gt;, and the framework had no business doing it on the tool&amp;rsquo;s behalf.&lt;/p&gt;&#xA;&lt;p&gt;Only one thing in the repository needs all the adapters, and that&amp;rsquo;s &lt;code&gt;gtb&lt;/code&gt; itself, the CLI that generates projects. Its wizard asks about each forge and each chat provider, so it has to link them all; it is, in the comment I gave it, the kitchen-sink binary. The framework doesn&amp;rsquo;t need the sink and neither does anything built on it. So the CLI moved into its own module at &lt;code&gt;cli/&lt;/code&gt;, nested in the same repository, with &lt;a class=&#34;link&#34; href=&#34;https://gitlab.com/phpboyscout/go-tool-base/-/blob/6860fe7d/cli/cmd/gtb/providers.go#L6-16&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;its nine blank imports in its own &lt;code&gt;main&lt;/code&gt;&lt;/a&gt; where they belong, and the two provider files in &lt;code&gt;pkg/&lt;/code&gt; were deleted. The framework&amp;rsquo;s &lt;code&gt;go.mod&lt;/code&gt; went from 68 direct requires to 51.&lt;/p&gt;&#xA;&lt;p&gt;Then the generator learned to emit only what a tool asks for. It didn&amp;rsquo;t need a new question for forges, it already asks one: enable the &lt;code&gt;gitlab&lt;/code&gt; feature and you get &lt;code&gt;go/forge-gitlab&lt;/code&gt; linked, and a tool that enabled a forge it hadn&amp;rsquo;t linked would fail its update check at runtime anyway. Chat providers became a list on the manifest. Each family gets one file in the generated &lt;code&gt;cmd/&amp;lt;name&amp;gt;/&lt;/code&gt;, one blank import per line, regenerated from the manifest, which is the keychain&amp;rsquo;s shape from April applied across the board.&lt;/p&gt;&#xA;&lt;p&gt;The closing check in &lt;a class=&#34;link&#34; href=&#34;https://gitlab.com/phpboyscout/go-tool-base/-/wikis/specs/0194-the-gtb-cli-as-a-nested-module-and-adapters-chosen-per-tool&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;the spec&lt;/a&gt;: a project generated with &lt;code&gt;claude&lt;/code&gt; and &lt;code&gt;gitlab&lt;/code&gt; selected tidies clean, emits a one-line &lt;code&gt;chat.go&lt;/code&gt; and a one-line &lt;code&gt;forge.go&lt;/code&gt;, and &lt;code&gt;go version -m&lt;/code&gt; on the built binary shows &lt;code&gt;chat-anthropic&lt;/code&gt; and &lt;code&gt;forge-gitlab&lt;/code&gt; and no other adapter. Seventy-two megabytes, and that one has a chat provider and a forge in it and does some work.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-bill-came-two-days-later&#34;&gt;The bill came two days later&#xA;&lt;/h2&gt;&lt;p&gt;Splitting a repository into two modules does have a price, though, and I paid it inside the week.&lt;/p&gt;&#xA;&lt;p&gt;A nested module that depends on its parent has no precedent in this estate (the two we had, &lt;code&gt;go/controls/lint&lt;/code&gt; and a capability generator, are standalone), and Go shapes it for you: &lt;code&gt;go install path@version&lt;/code&gt; refuses a module whose &lt;code&gt;go.mod&lt;/code&gt; carries a &lt;code&gt;replace&lt;/code&gt;, so the CLI couldn&amp;rsquo;t just point at &lt;code&gt;../&lt;/code&gt;. The spec went with a committed &lt;code&gt;go.work&lt;/code&gt; for in-repo builds and a &lt;code&gt;cli/go.mod&lt;/code&gt; requiring the framework&amp;rsquo;s last &lt;em&gt;release&lt;/em&gt;, which sounds fine and means a CLI change can&amp;rsquo;t use framework API landed in the same merge request, or in any merge request since the last tag, until a release is cut. Two days in, a change touching both hit that (of course it did), and I found myself doing a two-step deployment through the very workspace I&amp;rsquo;d set up to avoid two-step deployments. That felt massively counter-intuitive and I said so, at some length.&lt;/p&gt;&#xA;&lt;p&gt;The way out was to stop pretending &lt;code&gt;go install&lt;/code&gt; was an install path. It hadn&amp;rsquo;t really been one for a while: the install scripts, the Homebrew cask, self-update and &lt;code&gt;just install&lt;/code&gt; all build framework and CLI from one commit through the workspace, so the pairing already held anywhere a person gets the binary. The docs now say don&amp;rsquo;t use &lt;code&gt;go install&lt;/code&gt; for &lt;code&gt;gtb&lt;/code&gt;, the guard job that enforced the lag is gone, and colophon tags &lt;code&gt;cli/vX.Y.Z&lt;/code&gt; beside each root tag so the two version series move in lockstep. The &lt;code&gt;require&lt;/code&gt; line in &lt;code&gt;cli/go.mod&lt;/code&gt; is kept current by Renovate at each release, for tooling&amp;rsquo;s sake, and not much depends on it being fresh.&lt;/p&gt;&#xA;&lt;p&gt;I wouldn&amp;rsquo;t call that solved. Settled, for now, until the day something needs the CLI on its own version series, and I&amp;rsquo;d pay it again for the 47 MB.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-comment-that-said-i-didnt-need-to-look&#34;&gt;The comment that said I didn&amp;rsquo;t need to look&#xA;&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s the comment that bothers me more than the size, if I&amp;rsquo;m honest.&lt;/p&gt;&#xA;&lt;p&gt;For the best part of a year two files said, in good faith, that a downstream could opt out, and with them saying so it went unchecked, by me most of all. I think an opt-out that&amp;rsquo;s never been tried is worse than not having one, because a missing one is a constraint you know about and design around, and an untried one is a reassurance that lets the problem sit (81 MB of it, in this case).&lt;/p&gt;</description>
        </item></channel>
</rss>
