The durable design ideas behind MetaObjects codegen. Generators are disposable; the metamodel is the spine. This guide is the why — the tradeoffs and techniques you reach for when "it works" needs to become "it works at scale, and the next person (or LLM) can change it safely."
See also: ADR-0034 (scaffold-and-own), ADR-0020 (codegen tiering), ADR-0001 (build-time type binding).
If the metadata fully describes it (FK references, CRUD, validator chains, type-safe
finders, relations), generate it. The only things you hand-write are what metadata
genuinely cannot express (regex from outside the model, business logic, and
irreducible SQL views — recursive CTEs, window functions, set ops). This is the
raison d'être; when in doubt, generate. Most SQL views are not irreducible: a
derived/aggregate read model is an object.projection whose
CREATE VIEW DDL is generated from its origin.* children — hand-writing that view
is drift, and because an unmodeled view is unmanaged, meta verify --db can't
even see it.
The library ships the engine + a documented template library; you copy templates into your repo and own them. A single standard template set has never fit across projects — framework conventions, naming, structure, and business patterns are per-project. Don't fight a black-box generator; own a starting point and edit it. Choosing and adapting a starting template is a human/Claude judgment call, not a CLI flag. (See ADR-0034.)
For a running start, meta init scaffolds a sensible default set —
codegen/generators/{entity,queries,routes,barrel}.ts, copied from the reference
templates — and wires metaobjects.config.ts to import them locally, so meta gen
runs from generators you own from the first run. Each file is written only if absent,
so re-running meta init --force never clobbers a hand-edited generator. Importing
those factories from @metaobjectsdev/codegen-ts/generators instead is deprecated
and slated for removal in a future major — own the local copy.
There is no single right way to author a generator. The menu, and when to reach for each:
| Mechanism | Speed | Readability of output shape | Travels across ports? | Reach for it when… |
|---|---|---|---|---|
| Direct code (TS/Java/C#/Python/Kotlin generator) | Fastest | Lower — the output is assembled in code; you don't see the shape at a glance | No (port-bound) | You want speed + full programmatic power, and complex logic |
| Mustache template | Slower | Highest — the output shape is right there in the template; easy to read + review | Yes — byte-identical across every port's render engine | The output shape matters more than raw speed; you want one template to serve several languages |
| Groovy (JVM) | Slower | Medium | JVM | Scriptable JVM authoring without a compile step |
| Cross-language (TS/Python emitting Java/C#) | — | — | Yes | One team authors all generators in their preferred language for any target |
The core tradeoff: direct code is far faster than a template engine, but a template is cleaner to look at — the generated shape is visible in the template instead of buried in string assembly. Pick speed (direct) when the logic is gnarly or the run is hot; pick a template (Mustache) when the shape is what you're iterating on, or when you want the same output across languages. Mustache is also the natural cross-language bridge: the render engine is byte-identical in every port, so one template emits identical output anywhere.
A common optimization: skip regeneration when the output is already up-to-date — compare the mtime of the generator code and/or template against the mtime of the existing generated file, and don't regenerate if the output is newer.
The catch: it's only worth the effort for the slow paths. Mustache and Groovy templates are slow enough that skipping unchanged output is a real win. Direct-code codegen is so fast that the skip logic costs more than it saves — just regenerate every time. Match the optimization to the mechanism: timestamp-skip for templates, always-regenerate for direct code.
Generated code is disposable, but people do edit it. Three strategies, each with per-language variations — choose by what you're protecting:
- Three-way merge. Edit the generated file in place; a committed baseline +
git merge-file --diff3re-applies your edits on every regen. Best when edits are small and scattered through otherwise-generated files. (MetaObjects' default for generated files; the baseline lives in.metaobjects/.gen-state/.) - Generated base class + your extension. Generate a base, and you write a subclass that overrides what you need. The base regenerates freely; your subclass is yours. Best when the customization is behavioral and naturally subclassable.
- Partials. Where the language supports it (C#
partialclasses are the clean case), generate one partial and own another. Best when the language gives you a first-class "split one type across files" seam.
Each port has its own idiom — Java/Kotlin lean on subclass+override seams, C# on partials, TS on three-way merge or composition. The strategy is a per-project, often per-artifact choice.
When you go with the base-class/extension strategy, the git layout is what makes it pleasant:
- The extension (the subclass / your-side partial you edit): generate it once, commit it, and from then on it's yours — you and the LLM edit it freely; codegen never touches it again.
- The base class (the regenerated half): emit it into a separate directory and gitignore it. It's regenerated on every build, never reviewed, never committed.
This cleanly separates owned + edited (committed, stable, in review) from regenerated + disposable (gitignored, invisible). You get full regeneration of the mechanical half with zero merge noise, and a small hand-owned half that's the only thing in git and the only thing anyone reads.
Every generated file carries a @generated header. Codegen overwrites only files that
carry it; a file you've taken ownership of (header removed, or a never-generated path)
is never clobbered. This is the backstop under every strategy above — the generator
will not silently eat your work.
Resolving an object's native class/module from its FQN happens in generated code
(static imports for data-oriented ports; an FQN-keyed registry for OO ports), never via
Class.forName / Type.GetType / importlib. Runtime reflection is impossible in TS
and breaks under GraalVM native-image / .NET AOT. (ADR-0001.)
Keep native code generators per-port and idiomatic (Drizzle/Zod for TS, EF Core for C#, Spring for Java/Kotlin, Pydantic for Python) — collapsing them to one generic emitter is the OpenAPI-Generator trap. Keep language-neutral artifacts (migrations, docs, future OpenAPI/Mermaid) as one shared engine. The test: does the output depend on the implementation language? If yes, it's per-port; if no, it's shared. (ADR-0020.)
MetaObjects codegen has been object-centric (one artifact per MetaObject), but generation has three natural scopes, and all three are legitimate first-class patterns. The scope is the walk — what the generator iterates over:
| Scope | Walks | Emits | Examples |
|---|---|---|---|
| Object | each MetaObject | one artifact per object | entity, queries, routes, DTO, repository |
| Package | each package (objects grouped by package) | one artifact per package | a service-layer module, a package barrel/index, a package-scoped registry or config |
| App | the whole model once | one artifact for the app | a shared component, an app-wide config (e.g. a Spring @Configuration), a global object/binding registry, the overall barrel, a whole-model diagram |
Lineage (the Java predecessor). App-level generation was first-class there —
SingleFileDirectGeneratorBase (one whole-model PlantUML diagram), or overriding
execute(loader) to emit a single app config (KotlinSpringConfigGenerator), or the
getFinalWriter() hook (per-object files plus one final app file). Package-level
was not first-class even there — it was done ad-hoc, grouping by getPackage()
inside a generator's execute() (the FR-019 shared-enum aggregation). The newest,
cleanest shape is the cross-port TemplateGenerator's caller-supplied walk
function: the walk returns the units to render, so per-object / per-package /
whole-model are just different walks.
The model to expose. Three scope helpers, so the scope is declared, not buried in custom iteration:
perEntity(fn)— object scope. Runsfnonce per matched object.perPackage(fn)— package scope: group matching objects by package (packages ascending, objects keepingctx.entitiesorder), runfnonce per package. Groups byeffectivePackageso objects with a bare FQN (FR5d) still bucket correctly.perModel(fn)— app scope: run once over all matching objects. (oncePerRunis kept as a soft-deprecated alias — "run" is ambiguous under multi-target output;perModelnames the data scope.)
A generator picks its scope by which helper it composes — the same way it picks object vs model today. Reference templates and the guidance call out the scope each starting point is for, so "I need a per-package service layer" maps to a clear pattern instead of hand-rolled grouping.
A templateGenerator can take the scope walk declaratively instead of a hand-written
walk — pass scope ("perEntity" | "perPackage" | "perModel") plus an
outputPattern, and the generator derives the neutral template data dict per unit and
names each file via the pattern. walk and scope are mutually exclusive — provide
exactly one. The outputPattern placeholders are {name}, {Name}, and {package}
(an unknown placeholder throws); {package} resolves through effectivePackage and
renders ::-separated segments as nested path directories.
templateGenerator({
name: "entity-doc",
scope: "perEntity",
outputPattern: "{package}/{Name}.md",
template: "entity-doc",
});The same declarative surface is available on the JVM (Java + Kotlin) via
TemplateScopeGenerator (com.metaobjects.generator.template), a Maven-wirable
generator over the byte-equivalent render engine. Wire it as a standard
<generator> in pom.xml with <template>, <scope>, <outputPattern>,
optional <format> (default text), and <templatesDir> args — no hand-written
walk class. The three scope walks, the neutral data dict, and the output-pattern
grammar are gated byte-identical against the same
fixtures/template-codegen-conformance/ corpus the TS port passes, so a template
renders identically on either side. This is where Kotlin gains a template
generator (it reuses the shared JVM engine — no KotlinPoet involvement).
The CLI-port surface (Python and C#) exposes the same declarative
walk through a JSON template-spec instead of code/XML wiring — the cross-port
contract shape ({"generators": [{ name, template, scope, outputPattern, format? }]},
JSON-schema'd beside the TS port at
codegen-ts/src/template-codegen/template-spec.schema.json). Pass it on gen:
metaobjects gen <metadataDir> --out <dir> --template-spec spec.json --templates <dir>
(Python), or dotnet meta gen <metadataDir> --out <dir> --template-spec spec.json --template-root <dir> (C#).
The named generators are appended to the default suite, resolving template refs
under the templates root (--templates in Python, --template-root in C#; default
templates). scope must be one of
perEntity/perPackage/perModel and format (when present) one of the
registered escaper formats; a target field is rejected (the CLI ports have no
output-target concept). The same neutral data dict, output-pattern grammar, and
conformance corpus apply, so a spec renders byte-identically across ports. (Because
a template's output flows through the standard write path — which refuses to
overwrite a file lacking the @generated marker — a regenerable template must emit
that header in its own body.)