Featured image of post Config is a border, not a chain

Config is a border, not a chain

I built a boundary so I wouldn't have to move my config package. Then I moved it anyway, and the boundary is the only reason that didn't hurt.

go-tool-base was meant to be an all-in-one framework, and for a good while that was exactly right. Then I started wanting bits of it on their own. Signing went first, off into its own module, and that went well enough that I lined up the chat package to follow.

So I costed it. To let a package go off and find an API token by itself, I’d have to take Viper along. And afero. And fsnotify, pflag binding, and every last scrap of environment-prefix behaviour we’d accumulated over the years.

All that, so a package could learn a string.

The helpful thing had turned into the fence

I’d done this dance once already with logging. A package that reaches for the framework’s logger is a package that can’t leave, and the answer was to hand it an interface instead of a dependency. That one’s written up in a logging interface that doesn’t leak its backend.

Config was the same trap in a much better disguise. Worse, actually, because config is genuinely useful. Everything wants some. Our container was the one thing in the building that knew how a value had really been resolved: which file it came from, whether an env var had beaten it, what the default was if nobody said anything at all. That’s a real service. The packages were right to want it.

The trouble was what wanting it looked like once you opened the file. pkg/chat reached into that container in three separate places. Picking a provider. Working out a fallback. Resolving a credential. Three small, sensible, entirely defensible lines. And between them, a chain bolting the package to the framework’s floor.

The obvious answer, which was wrong

Extract config first. Do it before anything else, because it’s the blocker.

I said that out loud, quite confidently, and it has a lovely logic to it. If the thing in the way is config, move config.

It’s wrong, and it took me an embarrassingly long time to see why. Our config isn’t a data structure. It’s a set of promises about resolution order. File, then environment, then flags, with a prefix convention and hot reload sitting on top of the lot. Pull that out wholesale and you either drag every one of those promises into every consumer, or you break them somewhere nobody notices until it’s in production and somebody’s PHPBS_ prefix has quietly stopped winning.

And I had one line I wasn’t crossing. Whatever I did here, a user’s config file had to carry on working exactly the way it did yesterday. Not mostly. Exactly.

Paperwork, not a tow rope

So it went the other way round.

Instead of packages reaching into the framework, each package writes down what it needs as a plain typed struct of its own, and the framework fills it in at the boundary. In Go that’s just unmarshalling: take a lump of resolved config, pour it into a struct the package defined for itself.

Think customs paperwork rather than a tow rope. The package says “endpoint, timeout, token, please”. The framework, being the only thing that actually knows where any of those came from, fills in the form and hands it over. Nothing crosses the border except values. Constructors ended up in two flavours. A plain one that takes typed settings and knows nothing whatsoever about the framework, and an adapter next to it that takes the config container, does the paperwork, and calls the plain one.

Reusable as the default, framework compatibility as the special case. Which is the right way round, and I got there via one thoroughly wrong turn… I had it backwards at first, with the typed version wearing the awkward name, and had to go back and revert the lot.

The thing that actually made it stick wasn’t a code rule at all. It was a testing rule. Package tests use typed settings. Only the adapter’s own tests are allowed to build a config container. Sounds like bookkeeping, and it’s the most useful line in the whole design, because the moment an ordinary test reaches for that container you’ve found a leak.

Then hot reload nearly did for it

Here’s the wall I walked into.

A package is holding a struct of values that got copied at construction. Somebody edits their config file while the thing is running. That package is now sat there holding a polite lie. The framework knows the value moved; the struct hasn’t the faintest idea. So I built observers for it. The adapter notices a reload, rehydrates its typed settings, and the package behind the border ends up holding the new values instead of the old ones.

The bit that mattered wasn’t the mechanism though, it was making it compulsory. I’d written it up first as guidance, which is a polite way of saying optional, and optional is exactly the sort of thing that gets skipped on a Friday afternoon and bites you six months later when nobody remembers the rule existed. So it got tightened into an actual rule: long-lived adapters use the common helper, and the helper does the lot for you.

Which is ObserveSection. Initial unmarshal, register an observer, then publish a fresh snapshot whenever the typed section genuinely changes. Not when any old key moves, which would have every package in the building waking up because somebody fixed a typo in a log level. When that struct differs from the last one it published.

There’s a SectionChange carrying the before and after too, so a package can see what actually moved rather than just being told something did. The reload machinery itself is reloading config without a restart; this is the bit where the packages behind the border finally get told about it.

And then I moved it anyway

Now. Everything above argues that config stays put. Crown jewels, holds the promises, packages get paperwork and the container never leaves home. I believed that. I wrote it down. I based a fortnight of refactoring on it.

Within a fortnight I’d extracted it.

go/config went off and became its own module, and then I did something considerably ruder than merely moving it… I tore Viper out and rebuilt the thing around a Store with real provenance on every value. spf13/viper doesn’t appear in that go.mod at all now. pkg/config doesn’t exist in go-tool-base. The commit that finished the job is called, with no ceremony whatsoever, refactor(config): consume the extracted go/config module, and these days the framework depends on go/config v0.13.3 like any other library off the shelf.

So by the letter of it I was wrong. The premise of the whole exercise was that config wouldn’t move, and it moved.

Except nothing noticed! Not one package. chat, tls, http, grpc, the gateway, the telemetry core, every last release provider… none of them changed, because none of them was reaching through the border to begin with. They were sat there holding their own structs, filled in by an adapter, and swapping out the entire machine behind that adapter turned out to be somebody else’s problem entirely. Mine, obviously. But only mine, and only on one side of the line.

What the border actually bought

I built it so I wouldn’t have to move config. Then I moved config, and the border is the only reason that didn’t hurt.

Which isn’t the lesson I set out to learn, and it’s a better one. I thought I was buying extractability for the packages. What I actually bought was the freedom to rip up my own side of the line without asking anyone’s permission, because the only thing I’d promised them was a shape. Where the values came from, how they layered, what beat what… all of that stayed mine. And in the end I changed the lot of it. Viper’s gone, and chat still doesn’t know.

Built with Hugo · Theme Stack designed by Jimmy