Skip to content
andrew.dunn.dev

Go Architecture for LLM-Maintained Codebases

This is my first real Go project. I’m building YotoShelf, a Go + Astro/Svelte app, and LLMs are writing most of the code. The harness work over the past few months taught me a lot about structuring instructions, but I wanted to think about the code layer too: what choices at the architecture level help me keep a project maintainable when I’m leaning heavily on models to produce it?

The working hypothesis: make wrong code fail to compile, not fail code review. I want to be able to step away from this project for weeks, come back, point an LLM at a feature, and trust that the toolchain catches the things I’d otherwise have to catch by reading every diff.

What I’m trying

The guiding question for each tool choice was: what prevents the next LLM session from quietly making this worse? I don’t know yet whether all of these choices are right. Some of them are informed by what I’ve read, some by what felt natural during a two-day sprint where LLMs wrote roughly 95% of the code. Documenting them here so I can revisit later.

API contract: huma. Go structs are the API spec. Request validation, response schemas, and OpenAPI docs are derived from types. If the struct is wrong, the compiler catches it. If the request doesn’t match, the runtime rejects it. I considered swaggo/swag, which uses comments to annotate handlers. The concern there is that comments can drift from the actual behavior, and models seem particularly prone to writing annotations that describe what they intend rather than what the code does. Huma ties the spec to the types directly, which felt like a better fit.

Database access: sqlc. SQL queries live in .sql files. sqlc generates type-safe Go functions. No rows.Scan(&a, &b, &c) with wrong column order. Schema drift becomes a compile error. I looked at GORM and ent, but ORMs hide the SQL. When a model generates an ORM call, I can’t see the query it produces without running it. sqlc is the opposite: the SQL is reviewable, the Go is generated. If a model writes a bad query, sqlc’s type checker catches it against the schema.

Migrations: goose. Versioned SQL migrations with rollback, embedded in the binary. Atlas is more powerful (declarative schema diffing) but heavier. Goose is simple: write SQL up/down migrations, embed them, done. For a project this early, I’d rather have fewer moving parts.

Config: koanf. Typed config struct with validation at startup. Composable providers (env vars, CLI flags). No os.Getenv scattered through handlers. I initially reached for Viper since it’s the one I’d seen referenced most, but it pulls in etcd, Consul, and remote config dependencies I’ll never use. Koanf lets you add only the providers you need. Smaller surface area felt right for a project where I’m trying to keep things legible.

Frontend types: openapi-typescript. Generated from huma’s OpenAPI spec. The frontend TypeScript types are derived from the Go types. If the backend changes shape, the frontend knows about it at compile time.

Frontend client: openapi-fetch. Typed fetch client generated from the spec. No raw fetch() calls. Every API call is type-checked against the same source of truth.

The path through a feature

When an LLM adds something new, the steps chain together:

  1. Write SQL query. sqlc generates type-safe Go function.
  2. Write huma operation. OpenAPI spec auto-generated from Go types.
  3. Run type generation. TypeScript types derived from OpenAPI.
  4. Use shared client. Frontend calls are type-checked.
  5. make check. Lint, test, build catches the rest.
BACKENDSOURCESQL queryyou write thisGENERATORsqlcGENERATEDGo typestype-safeGENERATORhumaGENERATEDOpenAPI specderived from typesFRONTENDGENERATORopenapi-typescriptGENERATEDTypeScript typesfrom the specGENERATORopenapi-fetchYOU CALLTyped clientyou call thismake check: lint, test, build runs the whole chain

The SQL query is the only artifact you write by hand and the typed client is the only one you call. Everything between them is generated, so changing the shape of a response means changing the query, and the change reaches the browser as a TypeScript compile error rather than a runtime surprise.

Each step depends on the output of the previous one. A model can’t easily skip ahead because the next step won’t compile without what the previous step produced. This connects to the composable tooling idea: let the tools enforce the constraints rather than relying on the model to remember them.

Conventions I’m trying

Beyond the tool choices, a few conventions that seem to help:

File size limits (CI-enforced). 400 LOC for Go, 250 LOC for Svelte. Generated code (sqlc output, etc.) is exempt. I want to be honest: these numbers are a judgment call, not a research finding. There’s real research on attention degradation in long contexts (Liu et al., 2023, “Lost in the Middle”), but that’s about retrieval, not file editing, and deriving LOC limits from it is a stretch. What I actually observed during the sprint was that a 620 LOC handler file caused real problems, but because it mixed three concerns (HTTP, SQL, filesystem), not because of its line count. A 488 LOC file that was all huma operations in a single concern was fine. The real rule is probably one concern per file, with the LOC limit as a backstop that catches genuinely bloated files without creating busywork splitting files that are already well-structured.

Named error constants. Every API error is a pre-defined variable in one file. Models pick from the catalog instead of inventing error strings. Consistent errors, reviewable in one place.

Package READMEs. Every internal package gets a five-line README: purpose, dependencies, dependents. A model reads this before modifying a package. Costs almost nothing to maintain, and I’m hoping it prevents wrong-package modifications.

Zero-warning linting. golangci-lint with errcheck, bodyclose, staticcheck. I’ve noticed models tend to forget to close resources or handle errors. The linter catches these before merge.

Golden file tests. Expected API responses stored as JSON files. The test compares actual vs expected. Response shape changes show up as file diffs in PRs rather than being buried in assertion logic.

Single verification command. make check runs everything. The model runs this after every change. Pass/fail, no ambiguity.

Compilerwrong types, missing error handling, unused importsLinterunclosed resources, bodyclose, staticcheckmake checkgolden file tests, integration, build

Each layer catches a narrower class of mistake than the one above it. The widest and cheapest net runs first, which is the whole argument for pushing work into the compiler.

Why Go

I’m new to Go, and the choice wasn’t driven by language familiarity. What drew me in was how well the type system and tooling ecosystem seem to work for LLM-generated code. Go’s explicit error handling, the absence of exceptions, the compiler’s strictness about unused variables and imports: these are all things that push back on a model that defaults to the optimistic path. A language that requires you to handle the error path requires the model to handle it too.

The merge request post explored how code review is straining under AI-generated volume. What I’m trying here is the complement: reduce what needs to be caught in review by catching it in the compiler. I don’t know yet whether this actually works at scale. Building the immutable base taught me that the gap between “we have a pipeline” and “the pipeline catches the thing that actually breaks us” can be surprisingly wide.

The specific tools will probably evolve as I learn more. The idea I’m testing is whether compiler-enforced contracts at every layer, a single source of truth per concern, and one verification command that catches everything else is enough to keep a project maintainable when I can’t hold all of it in my head and I’m relying on models to do most of the writing. Early results are encouraging. I’ll revisit this after more features are in.

I’m also getting heavily into Rust through Nomograph, which pushes the same idea further. Rust’s ownership model and borrow checker are an even stricter compiler contract, and I’m curious whether that strictness pays similar dividends when models are doing the writing. Different language, same question: how much can the type system do for you when you’re not the one holding the context?