All systems
Platform

Code Generation System Design

Turning one field list into a model, a service, a handler, routes, a schema, types, hooks and an admin page, and being able to take all of it back out.

internal/generateinternal/scaffold

1.Problem statement

A new resource in a full-stack application is about eleven files. A Go model, a service, a handler, a route group, a Zod schema, a TypeScript type, a React Query hook, an admin resource definition, an admin page, a sidebar entry, a CSV importer. None of them is hard, all of them are the same every time, and writing them by hand takes an afternoon and produces four inconsistencies.

The inconsistencies are the real cost rather than the afternoon. One resource paginates with a cap and the next does not. One scopes its list to the owner and the next forgets, which is a data leak rather than a style difference. One validates with Zod and the next trusts the form. Nobody notices, because each file is individually reasonable and the divergence is only visible across the set.

A generator removes both costs, and introduces its own problem. It does not only write files: it has to inject into files that already exist. The route file gains a group, the sidebar gains a link, the shared package gains a schema, the admin registry gains an entry. Injection is where a generator stops being a template engine and becomes something that edits a codebase, and edits can be wrong in ways that do not fail loudly.

Two specific ways, both of which happened here. An injection performed with a string replace against an anchor that has moved matches nothing, returns the input unchanged, and reports success. And every injection that has no matching removal leaves the project uncompilable the moment somebody removes the resource: a dangling import, a sidebar link to a deleted page.

The system has to be able to:

  • Take a resource name and a field list and produce the whole vertical slice.
  • Inject into existing files at named anchors, idempotently.
  • Remove a resource completely, undoing every injection.
  • Map a Go field type to a GORM tag, a Zod validator and a TypeScript type, consistently.
  • Regenerate TypeScript types and Zod schemas from the Go structs on demand.
  • Add a field to an existing resource without regenerating it.
  • Honour flags that change the shape: owned by user, append-only, public read, role-restricted.
  • Write the same code whether a project is new or being upgraded.

2.System requirements

Functional requirements

  • grit generate resource with an inline field list or a definition file.
  • Thirty field types, each with its Go type, GORM tag, Zod validator, TypeScript type and admin form control.
  • Anchor comments in the scaffolded templates, which the generator injects before.
  • grit remove, undoing every injection the generator makes.
  • grit sync, regenerating TypeScript and Zod from the Go structs.
  • grit add field, for a field on a resource that already exists.
  • Generators for jobs, mail, factories and column packs.
  • Flags: owned by user, append-only, public, role restriction, soft delete.
  • A CSV importer per resource, resolving relationships by natural key.

Non-functional requirements

  • Idempotent. generating the same resource twice must not produce two routes. Every injection checks for its own output first.
  • Reversible. generate and remove are a pair. An injection added without its removal is a bug, because it makes a cleanup operation break the build.
  • Fails loudly. an import block is built as a list, never with a string replace on an anchor line, because a replace whose anchor moved matches nothing and reports success.
  • Output is idiomatic. generated code is read, edited and reviewed by people. It is formatted, commented and ordinary, not marked as untouchable.
  • One source for shared text. anything written both at generate time and at upgrade time is written by one function called from both, or the two drift.
  • Types stay in step. the response shape, the Zod schema and the TypeScript type change in one commit. That is what sync is for.

3.Capacity estimation

Numbers for a mid-sized deployment. They are here to size the thing, not to predict your traffic: change an assumption and the sums below move with it.

Assumptions

ParameterValue
Files written per resource~11
Files injected into per resource~6
Field types supported30
Resources in a mature project25 to 60
Generation timeunder a second

What one command replaces

~11 files, ~900 lines of ordinary code
plus 6 injections across existing files
by hand: an afternoon, and four inconsistencies

Injection surface

6 anchors x 60 resources = 360 injections in a mature project
each of which must be removable
each of which must be idempotent

This is the number that makes removal a first-class requirement rather than a nicety. Three hundred and sixty edits that only go one way is a codebase that can only grow.

Type mapping combinations

30 field types x 5 outputs (GORM, Zod, TS, form control, importer)
= 150 mappings

Which is why the rule is to generate one resource with all thirty types and compile it. A mapping that is wrong for money or json is invisible in a resource that uses neither.

Test surface

124 unit and integration tests across generate and scaffold
covering pluralisation, type mapping, injection, removal and round trips

4.High level design

Parse a definition, write files from templates, inject at anchors, and keep a removal for every injection.

Core components

  • Definition parser. turns a field list, inline or from a file, into a typed description: names in every case the templates need, types, tags, relationships and flags.
  • Type mapper. one field type to its Go type, GORM tag, Zod validator, TypeScript type, admin control and importer handling. One table, because five tables drift.
  • Template writers. the files produced whole: the model, the service, the handler, the schema, the types, the hooks, the admin definition and page.
  • Injectors. inject before an anchor, or inline. The anchors are comments the scaffold emits, which makes them a contract between the two packages.
  • Removers. one per injection. Remove a line containing, remove inline text, remove a line block, remove a schema export block.
  • Sync. parses the Go structs and regenerates the TypeScript types and Zod schemas, so the three never need to be edited in step by hand.
  • Shared hunk functions. text needed at both generate time and upgrade time, written by one function called from both so a new project and an upgraded one cannot differ.

Request flow

One command, eleven files and six injections

12345678grit generateParse DefinitionType MapperWrite FilesFind AnchorsInjectgrit removeMatching RemovalsProject Compiles
  1. 1A field list arrives: a resource name and its fields, with types and modifiers, inline or from a definition file.
  2. 2It is parsed into every form the templates need. A name is wanted as singular, plural, PascalCase, camelCase, snake_case and kebab-case, and getting pluralisation wrong is how a route ends up at /api/v1/persons.
  3. 3Each field type is mapped to its five outputs through one table: the Go type, the GORM tag, the Zod validator, the TypeScript type and the admin form control. One table rather than five, because five would disagree within a month.
  4. 4The files that are written whole are written: model, service, handler, schema, types, hooks, admin definition, admin page, importer.
  5. 5The files that already exist are located by anchor. The anchors are comments the scaffold emits, which makes them a contract: renaming one means the scaffold stops emitting a hook the generator needs, and the generator then silently injects nothing.
  6. 6Injection happens before the anchor, or inline. An import block is assembled as a list rather than by replacing an anchor line, because a replace whose anchor has moved matches nothing, returns the input unchanged and reports success.
  7. 7Later, the resource is removed. Every injection has a matching removal, written in the same change that added the injection, because an injection with no removal leaves a dangling import or a link to a deleted page and breaks the build on an operation meant to clean up.
  8. 8The project compiles either way. That is the test: generate, compile, remove, compile. A round trip that leaves no trace is the only evidence the pair is complete.

Data flow

  • The definition is the single input. Everything about a resource that any layer needs is derived from it, so the layers cannot disagree.
  • Anchors are load-bearing and shared between two packages. A new anchor goes into the scaffold template in the same commit as the generator code that looks for it.
  • Flags change the shape rather than adding a wrapper. Owned by user means the list is scoped and the handlers check ownership, not that a middleware was added.
  • Generated code is ordinary code. It is formatted, commented and expected to be edited, because a resource always needs something the generator did not predict.

5.Technology stack

ComponentWhat it is
Inputan inline field list or a definition file
Field types30, each mapped to five outputs
Injectionbefore a named anchor, or inline
Removalone remover per injection, added together
SyncGo structs to TypeScript and Zod
Formattinggofmt on Go output, Prettier conventions on TypeScript
Tests124 across generate and scaffold, including generate-remove round trips

6.API design

The commands

MethodEndpointWhat it does
POSTgrit generate resource Post title:string body:textThe whole vertical slice
POSTgrit generate resource Order --owned-by userScoped to its owner at every layer
DELETEgrit remove PostEvery file and every injection
PATCHgrit add field Post published:boolOne field across all layers
GETgrit syncRegenerate TypeScript and Zod from Go

What one command produces

$ grit generate resource Invoice number:string total:money due_on:date \
customer:belongs_to status:enum:draft,sent,paid --owned-by user
apps/api/internal/models/invoice.go
apps/api/internal/services/invoice_service.go
apps/api/internal/handlers/invoice_handler.go
apps/api/internal/handlers/invoice_import.go
packages/shared/src/schemas/invoice.ts
packages/shared/src/types/invoice.ts
apps/web/src/hooks/use-invoices.ts
apps/admin/src/resources/invoice.ts
apps/admin/src/app/invoices/page.tsx
injected: routes.go, models.go, admin sidebar, resource registry,
shared index, admin navigation

7.Low level design

Core types

Generator.Runinternal/generate/generator.go

The end-to-end shape: parse, write, inject. The integration tests drive this rather than the pieces, because the pieces passing individually is not evidence the whole works.

injectBefore / injectInlineinternal/generate/inject.go

The two injection primitives. Both check for their own output first, because generating the same resource twice must not produce two routes.

go_import.go

Builds an emitted import block as a list. The shape to copy, specifically because a strings.Replace on an anchor line fails silently when the anchor has moved.

remove.gointernal/generate/remove.go

removeLinesContaining, removeInlineText, removeLineBlock, removeSchemaExportBlock. One per injection kind, tested by a generate-then-remove round trip.

sync.go

Parses the Go structs and emits TypeScript types and Zod schemas. The reason the response shape, the schema and the type can change in one commit.

Design principles applied

  • Every injection needs a removal, in the same change. they are a pair. Adding one half leaves a project that cannot clean up after itself, and the breakage appears during an operation that was supposed to help.
  • Never replace on an anchor line. a no-op replace reports success. Build the block as a list so a missing anchor is an error rather than a silent nothing.
  • Idempotent or a bug. check for your own output before injecting. Somebody will run the command twice.
  • One writer for shared text. anything emitted at both generate time and upgrade time comes from one function. Two copies of the same template diverge, and the divergence is between a new project and an upgraded one, which is the hardest pair to compare.
  • Generate all thirty types and compile. a type mapping that is wrong for money or json is invisible in a resource that uses neither, and the resource that uses them is somebody else’s project.
  • Never call a service from a model. models to services is an import cycle. Auto-numbering in a create hook calls the sequence directly, and the generator has to know that.

Patterns

PatternWhere it is used
Template methodone definition driving every layer’s output
Anchor injectionnamed comments as a contract between two packages
Inverse operationa remover paired with every injector
Single mapping tableone field type to five outputs
Round-trip testgenerate, compile, remove, compile

8.Scalability and performance

  • Generation is a development-time operation. The only latency that matters is that it feels instant, and it does.
  • The injection surface is what grows: six anchors times sixty resources is three hundred and sixty edits a mature project is carrying.
  • Adding a layer means adding an injector and a remover and a test for the round trip. The cost of a new layer is paid once and then applies to every resource.
  • Adding a field type means one row in the mapping table and a compile of the all-types resource. That is the cheap axis, deliberately.
  • Upgrade is where this gets hard: existing projects need the new output applied to code they have edited, which is why shared hunk functions and a manifest of what has been modified exist.
  • The real scaling limit is not the generator, it is that generated code is read by people. Output nobody wants to read is output they fork, and then the generator is irrelevant.

9.Bottlenecks and improvements

What breaks first

  • Anchors that move. rename one and the generator silently injects nothing. Nothing errors, and the resource is half-generated.
  • Edited generated files. a project that changed a generated file cannot have the new version written over it, and the upgrade either loses the edit or skips the fix.
  • Injections without removals. the project stops compiling during a removal, which is the operation least likely to be tested and most likely to be run in a hurry.
  • Type mapping gaps. thirty types times five outputs is a hundred and fifty mappings, and the wrong one is invisible until a project uses that type.
  • Divergence between generate and upgrade. the same code emitted by two writers drifts, and the difference is between a fresh project and an upgraded one.

What to do about it

  • Test the anchors exist. a test asserting every anchor the generator looks for is present in the scaffold output. A rename then fails a test rather than producing half a resource.
  • A manifest of what was modified. a hash per generated file, so an upgrade can report an edited file rather than silently replacing or skipping it.
  • Round-trip every injection in a test. generate, compile, remove, compile. It is the only check that a new injector has its remover.
  • One resource with every field type, compiled in CI. thirty types in one resource that has to build. A bad mapping fails the build rather than somebody’s project.
  • One function, called from both paths. generate and upgrade call the same writer. It is the only arrangement where the two cannot differ.

Read next