The generator is written once, in code - everything but the business logic comes out of it.
👋 I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This part is about one tool I have specified: a generator that produces the skeleton of a new domain. Maybe the idea is useful to you, maybe you look at it differently - both are interesting to me. Notes: github.com/brilliant-almazov.
The generator does not exist yet. What exists is a specification split into 12 iterations and a standing rule that none of them starts without an explicit go-ahead. So this is a piece of designed work and the shape I chose for it, not a report on a working tool. Everything below is in the future tense on purpose: there is no "we generate" here, only "it will generate".
The thesis
Basic things should be generated by code, not by a model. A model writes the generator once; from
then on it is an ordinary binary - no tokens, no session, and no chance that the layout comes out
slightly different on the fifth domain than it did on the first.
Why this is possible here at all
The layout and the style are already fixed, hard. Packages go by component type with the domain as
a nested package; reads and writes live in two separate layers; a concern is expressed by a folder,
never by a prefix in a file name; a file stays under 100 lines. As of the last count that is
2733 Go files across 252 packages, average file 39 lines.
That rigidity is the whole precondition. A formalised layout is the only thing that makes
generation possible: the generator does not guess the style, it lays files out by rule. Where the
rule is a matter of taste, nothing can be generated - only suggested.
A second effect comes from the same order: the more behaviour has been pulled into generic cores,
the less there is left to generate. The generator writes wiring; the behaviour is already in the
core it wires to.
The shape of the tool
A library with two functions, plus a thin CLI on top:
Generate(cfg) (Result, error)
CheckDrift(cfg) ([]string, error)
Not a script, not a folder of templates you copy and rename. A library, because the second
function - "tell me whether what is on disk still matches" - has to run in CI.
Two inputs, and neither covers the skeleton alone
The first input is a YAML description of the domain: domain name and plural, Go types, the
account-scope flag, whether there is a slug, whether there is a batch, the header table, the
revision table, the link column, the label column, table aliases, the payload columns (Go type, SQL
type, NULL, comment), the domain event types, the set of error sentinels, and the number of the
first migration. The second is the .proto descriptor: the full gRPC service name, the list of
RPCs with their request and response types, the entity message, the batch messages.
Neither is enough on its own. The descriptor knows the wire contract and nothing about storage; the
YAML knows the tables and nothing about which RPCs exist.
┌────────────────────────┐ ┌────────────────────────┐
│ domain.yaml │ │ proto descriptor │
│ │ │ │
│ name + plural │ │ service name │
│ Go types │ │ RPC list │
│ account scope │ │ request types │
│ slug / batch │ │ response types │
│ header table │ │ entity message │
│ revision table │ │ batch messages │
│ link column │ └────────────────────────┘
│ label column │ │
│ table aliases │ │
│ payload columns │ │
│ event types │ │
│ error sentinels │ │
│ first migration │ │
└────────────────────────┘ │
│ │
└───────────────┬───────────────┘
▼
┌───────────────────┐
│ generator │
└───────────────────┘
│
▼
┌───────────────────────────────────────┐
│ service skeleton │
└───────────────────────────────────────┘
Rendering is string concatenation
Constant fragments, a string builder, token replacement - $domain$, $Domain$, $plural$.
Template engines are explicitly ruled out. Every .go output is then run through the formatter, so
alignment and import grouping are not the renderer's problem at all.
The reason is narrow: a template hides the structure of its output behind its own syntax, and here
the output is the thing under review. When a generated file looks wrong, I want to read the code
that produced it as code.
Fully generated versus a manual body
Not everything can be generated, and pretending otherwise is how generators get abandoned. Each
layer is split in advance into what comes out complete and what comes out as a signature:
| Layer | Fully generated | Manual body |
|---|---|---|
| domain handlers | all 5 files | none |
| domain codecs |
codec.go, mapping.go, request.go, request_patch.go, suite.go, base.go
|
payload.go, filter.go, errors.go
|
| domain repository |
calls.go, repository.go, revision.go, deps.go, methods/search.go, methods/find_all_by_ids.go
|
spec.go, methods/header.go, methods/code_taken.go, methods/slug_taken.go
|
| domain manager |
calls.go, deps.go, manager.go, methods/{create,update,correct,archive}.go
|
spec.go |
| domain model | 9 files | 6 files |
| domain wiring in the daemon | all 4 files | none |
| domain test rig | all 3 files | none |
| migrations | all | none |
A "manual body" file is still generated once: correct package, correct imports, correct
signatures, bodies marked with a panic stub. On a second run without -force it is not
overwritten. The generator owns the shape, a person owns the decisions, and it never eats the
second one.
Drift
Every generated file will carry a Code generated by … DO NOT EDIT. header. That header is a
contract, not a courtesy. The comparison mode regenerates into memory, compares against what is in
the repository, and exits non-zero on any difference. Editing a generated file by hand is
forbidden; if the generated shape is wrong, the generator changes.
┌──────────────────────────────────────┐
│ Code generated by … DO NOT EDIT. │
└──────────────────────────────────────┘
┌────────────────────────┐ =? ┌────────────────────────┐
│ generated in memory │ │ file in the repository │
└────────────────────────┘ └────────────────────────┘
│
┌──────────────┴──────────────┐
▼ ▼
┌────────────────────┐ ┌─────────────────────────┐
│ equal → exit 0 │ │ differs → non-zero exit │
└────────────────────┘ └─────────────────────────┘
What it will not touch
The generic cores, and anything that exists once per service rather than once per domain -
publishers, the cache-invalidation consumer. They are not a unit of generation, and a generator
that tries to own singletons ends up owning the whole service.
The order of work
Twelve iterations: the YAML format first, then reading the proto descriptor, then the generator
frame, then one iteration per layer, then the comparison mode last - there is nothing to compare
until something is produced.
What it costs
A generator is another product: its own bugs, its own tests, its own review, and it has to keep up
with every change to the layout it encodes - which means the layout stops being free to change
casually.
The DO NOT EDIT header means a quick fix by hand is off the table. Every exception costs a new
iteration of the generator, and the first time that happens under time pressure is when the
decision gets tested for real.
And the double input has to be kept in agreement by someone. Two sources of truth for one skeleton
is a real cost; I took it because collapsing them would mean either inventing storage facts from
the wire contract or duplicating the contract in YAML.
The one conclusion
Generate the things that are already decided; write by hand the things that are still decisions.
The dividing line is not "how complex is this file" - it is "does this file contain a choice".
Platform and generation - Part 6.
Next: controlled versus ideal - why I keep choosing the version I can predict over the version that
is better on paper.
That's my case and my price for it. If you do this better, if you've already been through it, or if
you look at it differently - I'd like to hear how it's solved on your side, and what broke when you
tried.



Top comments (0)