Cates Works
All notes
Product notes/ 6 min read

The generated code isn’t the asset. The two files it imports are.

August 6, 2026

Point a generator like Kubb at an OpenAPI spec and it emits TypeScript types, validation schemas, a fetch client, and TanStack Query hooks. On paper that is a solved problem. Do it in fourteen codebases, though, and they do not age the same way. Some stay pleasant to work in for years. Some rot into a folder nobody wants to touch, because regenerating it might break things in ways nobody can predict from the diff. I went looking for what separated the two groups, expecting to find a clever generator config. The config was almost identical everywhere. The difference was in decisions made about everything around the generated code.

Decision one: the spec is a file you commit, not a URL you hit

The rot-prone setups pointed the generator at a live server — an input URL like localhost:3001/openapi.json. It works beautifully on the machine of the person who set it up, and it quietly creates three problems. Codegen now needs a running server, so it fails in CI. It fails offline. And, worst of the three, an API change becomes invisible in code review: the contract your frontend depends on can shift underneath you and leave no trace in any diff a human ever reads.

The durable setups vendor the spec instead. The server dumps an openapi.json, that file gets committed, and the generator reads it off disk. Codegen becomes hermetic — same input, same output, no network, no server. More importantly, the API contract becomes a reviewable diff. When an endpoint changes shape, somebody sees it before a customer does.

One small CI job keeps that honest: regenerate, typecheck, and fail the build if git diff comes back non-empty. That check is a few lines of YAML and it is the difference between a client that tracks the API and a client that silently drifts away from it.

Decision two: the generated code imports your seams, never the libraries

This is the one that actually matters. Generated code is regenerated constantly — that is the entire point of it — which means nothing durable can live inside it. If every generated client imports fetch and TanStack Query directly, then your base URL, your auth header, your error shape, your retry policy, and even your Query library major version are smeared across hundreds of files that get rewritten on every spec change. Changing how you attach a token becomes a migration.

So I stopped letting the generated code touch the outside world. Instead there are two small hand-written packages, and the generator is configured to import from those:

  • A fetch-client seam — roughly 250 lines, no dependencies, implementing the contract the generator expects: base URL, bearer token, per-request headers, timeout, and a typed error that carries the HTTP status.
  • A react-query seam — smaller still. It re-exports all of TanStack Query, plus one createQueryClient with a shared retry and 401 policy, so every app in the family is on one version with one behavior.

The generator config is three lines pointing the client plugin, the query plugin, and the mutation plugin at those two packages instead of the raw libraries. That is the whole trick. Swapping the transport, changing how a token gets attached, or bumping TanStack Query to a new major is now a one-file edit, and every generated client in every repo moves with it.

Both seams together are less code than a single day of the churn they prevent. The generated src directory is disposable — I would happily delete and regenerate it on a Friday afternoon. The seams and the vendored spec are the surface I actually maintain, and they are the only part I write by hand.

The same lesson showed up again that afternoon, wearing a different hat

I keep a portable library of small reusable prompt-docs for coding agents, originally exported out of a large internal monorepo. The copies still living in that monorepo had drifted newer, so my first instinct was obvious: sync the newer versions back into the portable library.

That instinct was exactly backwards. The drift was not improvement — it was specialization. Each in-repo copy had grown a "Repository Context" section naming internal packages and a private design system. Syncing them back would have poisoned a library whose entire value is that it works anywhere, with details that work in exactly one place.

The right move ran the other direction: distill. Three of the monorepo-only docs had a genuinely generic core buried under repo-specific detail — one on keyboard navigation for data tables, two on coordinating a React Query cache. I cut each from around 800 lines to around 200, swapped the internal domain for a neutral example, and moved those into the portable set. Everything else stayed where it was.

The rule I took from it: before syncing a shared library from a fork, check the direction of the drift. Portable value flows by distilling generic essence out. It never flows by copying repo-specific context in.

The thread connecting both

Both of these are the same question asked in two places: which part of this is durable, and which part is churn? The failure mode is not laziness — it is care applied to the wrong layer. Hand-tuning generated files, or lovingly syncing a fork’s local customizations into a shared library, is real effort spent on the part that was always going to be rewritten.

When I look at a system now, the first thing I try to identify is the small surface that everything else will be regenerated against. That surface gets the attention, the tests, and the review. Everything downstream of it gets to be cheap and replaceable, which is exactly what makes it safe to change.

The takeaway

Find the part of the system that gets rewritten and the part that does not, then put all of your care into the second one — the hand-written seam is the asset, and the generated code is just its shadow.

Capabilities this touches

Let’s talk

Have a project, or a product that could work harder?

Most projects begin with a short, no-pressure discovery call.