Idiomatic Kotlin codegen target for Spring-Boot-Kotlin consumers on Exposed +
Flyway. The Kotlin port is a codegen tier built on top of the Java port — the
loader, OMDB persistence engine, render engine, Maven plugin, and conformance
runners are all Java; Kotlin emits idiomatic Kotlin (data class,
Exposed Table objects, extension-fn relationship helpers, Spring @Configuration
wiring) via KotlinPoet.
Two modules:
metaobjects-codegen-kotlin— 15 KotlinPoet-based generators.metaobjects-metadata-ktx— thin Kotlin facade over the Java loader + render engine for idiomatic Kotlin runtime use.
<!-- pom.xml -->
<dependencies>
<dependency>
<groupId>com.metaobjects</groupId>
<artifactId>metaobjects-metadata</artifactId>
<version>${metaobjects.version}</version>
</dependency>
<dependency>
<groupId>com.metaobjects</groupId>
<artifactId>metaobjects-metadata-ktx</artifactId>
<version>${metaobjects.version}</version>
</dependency>
<dependency>
<groupId>com.metaobjects</groupId>
<artifactId>metaobjects-render</artifactId>
<version>${metaobjects.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.exposed</groupId>
<artifactId>exposed-core</artifactId>
<version>${exposed.version}</version>
</dependency>
<!-- The generated tables import these two: instant (tz-aware timestamp) columns come from
exposed-java-time, jsonb columns from exposed-json. exposed-jdbc is what
`Database.connect(...)` needs at RUNTIME — without it the app starts and the first
query fails. -->
<dependency>
<groupId>org.jetbrains.exposed</groupId>
<artifactId>exposed-jdbc</artifactId>
<version>${exposed.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.exposed</groupId>
<artifactId>exposed-java-time</artifactId>
<version>${exposed.version}</version>
</dependency>
<dependency>
<groupId>org.jetbrains.exposed</groupId>
<artifactId>exposed-json</artifactId>
<version>${exposed.version}</version>
</dependency>
<!-- Generated typed `field.object @storage:jsonb` / `field.map` columns serialize through a
generated per-package `MetaJsonbMapper.kt` Jackson `ObjectMapper` (no kotlinx-serialization
compiler plugin required). -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-kotlin</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>${jackson.version}</version>
</dependency>
<!-- FR-006 output parser + prompt-payload lane; also backs the open-bag
`field.string @dbColumnType:jsonb` → kotlinx `JsonElement` path. -->
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-serialization-json</artifactId>
<version>${kotlinx-serialization.version}</version>
</dependency>
</dependencies>The 15 generators registered in codegen-kotlin (GeneratorRegistry.kt):
| Generator | Output | Per |
|---|---|---|
KotlinEntityGenerator |
<Entity>.kt — Kotlin data class (Jackson-compatible; no @Serializable). For a value object this data class IS the template tier's payload/response type (ADR-0056); a value object a responding prompt parses into also gets <Vo>Extracted.kt (the lenient mirror, written by the parser tier once per run, beside it) |
every object.entity, object.value, and object.projection |
KotlinExposedTableGenerator |
<Entity>Table.kt — Exposed Table object with PK + FK + @storage columns |
entities with source.rdb |
KotlinNamesGenerator |
<Entity>Names.kt — physical database name constants mirroring the metadata tree (per-role source name + kind + schema, columns, identity/index names) |
every object with a declared/inherited primary source.rdb |
KotlinRelationsGenerator |
<Entity>Relations.kt — extension fns for cardinality=many query helpers |
entities with to-many relationships |
KotlinRepositoryGenerator |
<Entity>RepositoryBase.kt — persistence repository base (row-mapper + CRUD + patch) |
writable entities (source.rdb @kind="table") |
KotlinFilterAllowlistGenerator |
<Entity>FilterAllowlist.kt — FR-009 filter allowlist (filterable field names + allowed ops per field) |
writable entities (source.rdb @kind="table") |
KotlinOutputParserGenerator |
<Prompt>Parser.kt — object with parseXxx (Jackson; throws on a malformed or mismatched reply) + safeParseXxx (returns Result<TResponse>), where TResponse is the @responseRef value object's own data class |
every responding template.prompt (FR-006); strict tier JSON-only |
KotlinOutputPromptGenerator |
<Prompt>ResponseFormat.kt — response-format prompt fragment (FR-010) |
every responding template.prompt |
KotlinRenderHelperGenerator |
<Template>RenderHelper.kt — typed render() wrappers (document/email, keyed off @kind) |
every template.output |
KotlinExtractorGenerator |
<Prompt>Extractor.kt — strict typed extract<Name> response helper (FR-010) |
every responding template.prompt |
KotlinValidatorGenerator |
MetadataStartupValidator.kt + ExposedTableValidator.kt |
once per project |
KotlinSpringConfigGenerator |
MetadataExposedConfig.kt — @Configuration wiring Database.connect() + auto-validator |
once per project |
KotlinStoredProcGenerator |
Stored-procedure call wrappers | entities with source.rdb @kind="storedProc" |
KotlinSpringControllerGenerator |
<Entity>Controller.kt — Spring @RestController (5 CRUD endpoints; cross-port API contract). Select KotlinRelationsGenerator with it when the model has a M:N relationship: the traversal routes call the <rel>Query helpers only that generator emits, and without it the controller does not compile |
entities with source.rdb @kind="table" |
Maven wiring:
<plugin>
<groupId>com.metaobjects</groupId>
<artifactId>metaobjects-maven-plugin</artifactId>
<version>${metaobjects.version}</version>
<configuration>
<loader>
<sourceDir>src/main/metaobjects</sourceDir>
</loader>
<generators>
<generator>
<classname>com.metaobjects.generator.kotlin.KotlinEntityGenerator</classname>
<args><outputDir>${project.build.directory}/generated-sources/kotlin</outputDir></args>
</generator>
<generator>
<classname>com.metaobjects.generator.kotlin.KotlinExposedTableGenerator</classname>
<args><outputDir>${project.build.directory}/generated-sources/kotlin</outputDir></args>
</generator>
<generator>
<classname>com.metaobjects.generator.kotlin.KotlinRelationsGenerator</classname>
<args><outputDir>${project.build.directory}/generated-sources/kotlin</outputDir></args>
</generator>
<generator>
<classname>com.metaobjects.generator.kotlin.KotlinValidatorGenerator</classname>
<args>
<outputDir>${project.build.directory}/generated-sources/kotlin</outputDir>
<packageName>com.yourapp</packageName>
</args>
</generator>
<generator>
<classname>com.metaobjects.generator.kotlin.KotlinSpringConfigGenerator</classname>
<args>
<outputDir>${project.build.directory}/generated-sources/kotlin</outputDir>
<packageName>com.yourapp</packageName>
<metadataResource>meta.blog.json</metadataResource>
</args>
</generator>
</generators>
</configuration>
</plugin>Own a generator. mvn metaobjects:eject -Dnames=<name,...> copies a reference generator into a codegen/ Maven module under your own package, and prints the module, dependency and <classname> to wire. See Own your codegen → Java and Kotlin.
Nothing else to hand over. Unlike the Java routes/dto/repository output, no Kotlin generator's output imports helper runtime. The generated controller declares its filter parser, constraint mapping and patch handling inline, per file, and the rest imports only core: render and extract, the loader, and metadata-ktx. So an ejected Kotlin generator's output depends on nothing you do not own beyond the core. EjectRuntimeRoundTripTest in the Maven plugin checks this against every Kotlin generator's real output. com.metaobjects.generator.kotlin.runtime.M2mJoinResolver is a helper you may call from hand-written traversal code. No generated file imports it, so eject does not copy it; copy it yourself if you use it and want to own it.
The 15 generators above are a starting point, not the ceiling. When you need a shape
none of them emits, Kotlin has both authoring paths — and which to reach for is a
real decision (tradeoff table: codegen-concepts.md §3).
Programmatic means implementing com.metaobjects.generator.Generator in your own
project and naming the class in <classname>; the plugin loads it from the project
classloader, so your generator wires exactly like a built-in one.
Declarative means a Mustache template and no generator code at all. Kotlin gets
this from the shared JVM engine — TemplateScopeGenerator is a plain <generator>,
wired in the same <generators> block as the Kotlin* generators above (it is
language-neutral, so there is no KotlinPoet involvement and no Kotlin-specific
variant):
<generator>
<classname>com.metaobjects.generator.template.TemplateScopeGenerator</classname>
<args>
<templatesDir>src/main/templates</templatesDir>
<template>service/entity-service</template>
<scope>perEntity</scope>
<outputPattern>{package}/{Name}Service.kt</outputPattern>
<outputDir>${project.build.directory}/generated-sources/kotlin</outputDir>
</args>
</generator>template, scope (perEntity | perPackage | perModel), outputPattern,
templatesDir and outputDir are required; format defaults to text. The
outputPattern placeholders are {name}, {Name} and {package} (whose ::
segments become nested directories). Abstract objects are excluded from every scope.
The walks, the data dict and the pattern grammar are gated byte-identical against the
shared fixtures/template-codegen-conformance/ corpus, so one template emits the same
output here, in Java, in TypeScript, in C# and in Python. Full arg table and the
@generated-marker note: the Java port page.
The data dict itself: codegen-data-shapes.md.
Kotlin inherits Java's SPI-based provider discovery directly — write a
MetaDataTypeProvider implementation (or its Kotlin DSL equivalent in
metadata-ktx), drop the FQCN into
META-INF/services/com.metaobjects.registry.MetaDataTypeProvider, and the
loader picks it up alongside the core providers. See the Java port's
Custom providers section for the
mechanism; the
../features/extending-with-providers.md
reference covers the cross-port contract.
Every generator above that emits Exposed code reads one shared exposedApi arg:
<generator>
<classname>com.metaobjects.generator.kotlin.KotlinExposedTableGenerator</classname>
<args>
<outputDir>${project.build.directory}/generated-sources/kotlin</outputDir>
<exposedApi>1</exposedApi>
</args>
</generator>0(default, unset) — Exposed 0.x (org.jetbrains.exposed.sql.*). Byte-identical to every release before this arg existed; omit it and nothing changes.1— Exposed 1.x (org.jetbrains.exposed.v1.*). Set it on every generator you wire that touches Exposed —KotlinExposedTableGenerator,KotlinRepositoryGenerator,KotlinRelationsGenerator,KotlinSpringControllerGenerator,KotlinStoredProcGenerator,KotlinSpringConfigGenerator,KotlinValidatorGenerator— a run that sets it on some but not others emits a mix oforg.jetbrains.exposed.sql.*and.v1.*imports that cannot compile together.
Why 1.x. Exposed 0.x shares one IdentifierManagerApi per Database across all
threads, and its identifier caches are plain LinkedHashMaps mutated on every query —
under concurrent load they corrupt (ClassCastException, or a thread spinning forever
inside HashMap). Upstream fixed this only in Exposed 1.3.0 (JetBrains/Exposed
PR #2783). 0.x output can never reach that fix; exposedApi=1 is the migration path.
The floor this implies, per the official 1.0 migration
guide and Exposed
1.3.1's own published POM (checked directly — its kotlin-stdlib dependency is
2.3.20): your project's own Kotlin compiler must be >= 2.2 to read Exposed 1.3.x's
metadata; exposed-spring-boot-starter 1.3.0 is built against Spring 6.2 / Boot 3.5.
codegen-kotlin itself stays on Kotlin 2.0.21 — it only emits text (see this file's
production vs. helper note at the top of the repo's CLAUDE.md) — so this floor applies to
the CONSUMER project wiring exposedApi=1, never to the generator module.
One silent trap the migration guide calls out by name: Table.uuid() binds
kotlin.uuid.Uuid under 1.x, not java.util.UUID — it still compiles, it just changes the
Kotlin type. Every field.uuid column emits javaUUID(col) under exposedApi=1, never
bare uuid(col), so the entity's java.util.UUID property and its Exposed column always
agree. The field.string @dbColumnType=uuid escape hatch is a SEPARATE case — ADR-0037:
the hatch is physical-only, so the entity's property stays String (not UUID) on either
exposedApi — and emits the package-shared uuidString(col) extension (a Column<String>
persisted through the native Postgres uuid type, delegating every JDBC/DDL concern to
Exposed's own UUIDColumnType), never a native uuid(col)/javaUUID(col) Column<UUID>,
so the entity's String property and its Exposed column always agree too.
Proof, not aspiration. server/java/codegen-kotlin-exposed1x-check (excluded from the
default reactor — not purely docker, unlike integration-tests-kotlin: its two compile-only
tests need no docker at all, but the whole module needs a newer Kotlin compiler than the
reactor's 2.0.21; its round-trip test additionally needs docker. Run via
mvn -f server/java/codegen-kotlin-exposed1x-check/pom.xml test or
scripts/integration-test.sh kotlin) proves THREE things, each a separate test:
- Model + persistence tier compiles. The same generator selection
CodegenCompileConformanceTestuses for 0.x (Entity, ExposedTable, Names, Relations, FilterAllowlist, Validator) compiles the full shared fitness corpus underexposedApi=1against the real Exposed 1.3.x jars. - Controller tier compiles. Adding
KotlinSpringControllerGeneratorto that same selection compiles every entity's generated<Entity>Controller.kt— TPH, M:N, view projections, and everyemitPerFieldDispatchArmoperator arm across every scalar subtype — against real Spring 6.2 (spring-webmvc) + jakarta.servlet + Jackson on the module's test classpath (real dependencies, not stubs), with no exclusions. The per-field dispatch gate is derived from the SAME single source of truth the generated<Entity>FilterAllowlistreads (com.metaobjects.query.FilterOps), so the controller can never emit an operator the allowlist itself would refuse to admit — this is also why a filterablefield.inetcolumn no longer gets ordering operatorsjava.net.InetAddresscannot satisfy (it is notComparable;FilterOps'sinetband has no ordering ops to begin with). Thefield.string @dbColumnType=uuidescape hatch (ADR-0037: physical-only, so the property staysString) binds aColumn<String>table-side (uuidString(...), persisted through the native Postgresuuidtype) that agrees with the controller'sStringassumption, rather than the nativeColumn<UUID>uuid(...)/javaUUID(...)emits for a genuinefield.uuid. TheexposedApi=0sibling of this same controller-tier compile pass — over the identical full fitness corpus, with the SAME zero exclusions — lives inintegration-tests-kotlin(it already has Exposed 0.x + Spring on its classpath), closing the gapCodegenCompileConformanceTestdeliberately leaves open for 0.x (that module has no Spring dependency at all). - Round-trip. The hand-rolled
Meta*ColumnTypesupport classes (MetaInstantWithTimeZoneColumnType,MetaUriColumnType,MetaInetColumnType,MetaUuidStringColumnType) round-trip through a real Postgres — the generatedreadObject(rs: RowApi, …)signature 1.x requires, not just a compile check.
mvn compile # runs the codegen as part of generate-sourcesSchema migrations are owned by the TypeScript toolchain — see the
Migrations section for the meta migrate commands.
For the Author example (see entities.md), the codegen
emits:
// generated/acme/blog/Author.kt (jakarta.validation imports elided)
/**
* GENERATED — do not hand-edit. Regenerated from metadata.
*/
public data class Author(
public val id: Long? = null, // field.long PK → nullable, auto-assigned on insert
@field:NotNull
@field:Size(min = 1, max = 200)
public val name: String, // @required + @maxLength: 200
@field:Size(max = 2000)
public val bio: String? = null, // optional + @maxLength: 2000
)
// generated/acme/blog/AuthorTable.kt
object AuthorTable : Table("authors") {
val id = long("id").autoIncrement()
val name = varchar("name", 200)
val bio = varchar("bio", 2000).nullable()
override val primaryKey = PrimaryKey(id)
}…and the Spring wiring is also generated, so consumer Kotlin code is purely business logic:
// Your AuthorService.kt — handwritten
@Service
class AuthorService(private val db: Database) {
fun list(): List<Author> = transaction(db) {
AuthorTable.selectAll().map {
Author(
id = it[AuthorTable.id],
name = it[AuthorTable.name],
bio = it[AuthorTable.bio],
)
}
}
fun create(name: String, bio: String? = null): Long = transaction(db) {
AuthorTable.insert {
it[AuthorTable.name] = name
it[AuthorTable.bio] = bio
} get AuthorTable.id
}
}For a concrete object.report that declares a read-only source.rdb of @kind: view, four
generators write one file each. KotlinExposedTableGenerator writes a read-only Exposed table
object, <Report>Table, bound to that view, with one column per derived field (dimensions,
then measures). KotlinEntityGenerator writes <Report>, an immutable data class with one
property per derived field and no validation annotations. KotlinFilterAllowlistGenerator
writes <Report>FilterAllowlist, and KotlinSpringControllerGenerator writes
<Report>Controller: one @GetMapping list handler, a @PostMapping answering 405, and no
/{id} mapping. Every derived field with filter operators is filterable and sortable. No
<Report>Names is written, and nothing from any other generator. A report with no view source,
or an abstract one, generates nothing. See reporting for the vocabulary, the columns a
report gets and the REST contract. An excerpt of the table, under the default snake_case
column naming:
object ProgramMinutesTable : Table("v_program_minutes") {
val program = long("program")
val weeks = long("weeks")
val totalMinutes = long("total_minutes").nullable()
val avgMinutes = decimal("avg_minutes", 38, 18).nullable()
// … one column per derived field
}- A report has no identity, so the object has no
primaryKey. List it and count it; there is no by-id read and no write. - A column is nullable exactly when the derived field can be null: a
sum,avg,min,maxor ratio, and a dimension reached through@via. - A derived decimal with no declared precision (an
avg, a ratio, asumof a decimal) is read asdecimal(name, 38, 18). Exposed rounds a decimal to the column's scale when it reads it, so the value is exact to 18 places. The object maps a view, so those numbers never reach DDL. - An enum dimension is typed by the enum class of the entity it reads (
ProgramStatusfor a dimension overProgram.status), so it compares against the same constants as the entity's own column. No per-report enum is generated. - The view and its columns are bound by string literal even when
useNamesis on. genfails, naming the report and the dimension or measure, when a derived field is named after a Kotlin hard keyword or when two derived fields land on one column property (see below for theColumnsuffix). Rename the item.genalso fails, naming the report and the dimension or measure, when a derived field reads afield.object(a dimension over an embedded value object, say). A report over afield.objectis not supported; group by a scalar field.- An abstract report generates nothing, view or not.
A column property whose name is a member of Exposed's Table gets a Column suffix
(source becomes sourceColumn); the physical column name does not change. The reserved set
follows the output mode: options and storageParameters are Table members only in Exposed
1.x, so they are suffixed only with exposedApi=1.
names (KotlinNamesGenerator) is not wired above — it is opt-in, like
every Kotlin generator (there is no default suite on the JVM; <generators>
in the pom is the complete list, per generator). Add it explicitly:
<generator>
<classname>com.metaobjects.generator.kotlin.KotlinNamesGenerator</classname>
<args><outputDir>${project.build.directory}/generated-sources/kotlin</outputDir></args>
</generator>It emits one <Entity>Names.kt per object with a declared or inherited
primary source.rdb:
// generated/acme/blog/AuthorNames.kt (package line elided)
object AuthorNames {
const val TYPE: String = "object"
const val SUB_TYPE: String = "entity"
const val NAME: String = "Author"
const val SOURCE_PRIMARY_TYPE: String = "source"
const val SOURCE_PRIMARY_SUB_TYPE: String = "rdb"
const val SOURCE_PRIMARY_KIND: String = "table"
const val SOURCE_PRIMARY_TABLE: String = "authors"
const val NAME_FIELD: String = "name"
const val NAME_COLUMN: String = "name"
const val IDENTITY_PK_TYPE: String = "identity"
const val IDENTITY_PK_SUB_TYPE: String = "primary"
const val IDENTITY_PK_NAME: String = "pk"
val COLUMNS_BY_FIELD: Map<String, String> = mapOf(
"name" to NAME_COLUMN,
)
}The object MIRRORS THE METADATA TREE. Every node carries its own TYPE,
SUB_TYPE and NAME, so AuthorNames.NAME is the OBJECT's name ("Author")
and a physical name sits under the member that says what it IS —
SOURCE_<ROLE>_TABLE / _VIEW / _MATERIALIZED_VIEW / _PROC / _FUNCTION,
from the metamodel's own @kind-to-alias map. <ROLE> is PRIMARY or
REPLICA, so a write-through entity — one table, one replica view, two physical
names — has a member for each instead of one member between them.
An identity.secondary or index.lookup also carries IDENTITY_<NAME>_INDEX /
INDEX_<NAME>_INDEX, the database index name that the generated
init { uniqueIndex(…) } / init { index(…) } block references;
identity.primary deliberately carries none, because migrate names a primary
key by a dialect-conditional formula this artifact must not restate.
There is no READ_ONLY. It was never metadata — it is a derivation over
@kind — so ask SOURCE_<ROLE>_KIND.
Prefer a typed handle where one exists. If the ORM gives you a type-checked object for the same thing, use that. Replacing it with a string constant trades an error the compiler catches for one the database raises at runtime. These constants are for the places with no typed handle: raw SQL, a migration script, a log line, an external system's column mapping.
Here, that handle is the Exposed Column object on the generated Table —
AuthorTable.name, not AuthorNames.NAME_COLUMN, is what a query should
bind against; Exposed's DSL is already type-checked column-by-column. Reach
for the constant instead in raw SQL, a Flyway migration, a log line, or an
external system's column mapping — the places AuthorTable gives you nothing
to hold onto.
KotlinExposedTableGenerator reads these constants instead of independently
re-deriving the same table/column names whenever the names generator is in the same
run. You do not have to ask: the Maven plugin builds the whole <generators> list
before executing any of it, so adding KotlinNamesGenerator above is what switches
the table binding over. The useNames arg exists to override that decision — pin it
false to keep byte-identical output, or true for a direct programmatic call
outside the plugin, where nothing aggregates the run and it defaults false:
<generator>
<classname>com.metaobjects.generator.kotlin.KotlinExposedTableGenerator</classname>
<args>
<outputDir>${project.build.directory}/generated-sources/kotlin</outputDir>
<useNames>true</useNames>
</args>
</generator>With useNames on, AuthorTable reads Table(AuthorNames.SOURCE_PRIMARY_TABLE),
varchar(AuthorNames.NAME_COLUMN, 200) and
uniqueIndex(AuthorNames.IDENTITY_BY_NAME_INDEX, name) rather than the literals shown
above —
useful once you have hand-written code depending on AuthorNames too, so the
table binding and that code share one resolution instead of two independent
ones that could drift. Both generators must be given the same
columnNaming argument, or the table and the constants file disagree with
each other about a column's name.
A suite without the names generator keeps the literals, which is what makes the output
compile either way — referencing AuthorNames in a run that generated no such object
would not.
It follows extends, so a constant you do not find in an object is in its parent's.
Kotlin has no static inheritance — an object cannot extend another — so an artifact whose
object extends another re-exports the inherited constants by REFERENCE:
object CopayAuthNames {
const val NAME: String = "CopayAuth" // its OWN name, always restated
const val COPAY_AMOUNT_COLUMN: String = "copay_cents"
// A TPH subtype declares no source of its own, so the SHARED table — and every
// inherited column — is re-exported by reference and spelled once, on the base.
const val SOURCE_PRIMARY_TABLE: String = AuthNames.SOURCE_PRIMARY_TABLE
const val ID_COLUMN: String = AuthNames.ID_COLUMN
// COLUMNS_BY_FIELD stays complete — inherited entries included.
}An abstract base a persisted entity extends gets an object of its own carrying the columns
and keys it declares and no SOURCE_* block at all — it has no table, and must never
acquire one.
metadata-ktx wraps the Java Renderer in an idiomatic Kotlin builder. The payload is
the @payloadRef value object's own data class from KotlinEntityGenerator (ADR-0056),
so the builder is type-safe end-to-end.
import com.metaobjects.metadata.ktx.render
import com.metaobjects.render.FilesystemProvider
import java.nio.file.Path
val out = render {
ref = "lobby/welcome"
payload = WelcomePayload(
displayName = "Ada",
postCount = 12,
posts = listOf(PostSummary("Hello")),
)
provider = FilesystemProvider(Path.of("./prompts"))
format = "xml"
}KotlinOutputParserGenerator emits a typed parser per responding template.prompt —
one declaring @responseRef. It decodes into the @responseRef value object's own data
class (ADR-0056) with Jackson (jackson-module-kotlin), the codec those data classes are
built for, and pairs the throwing entry with the stdlib's Result<T> convention.
ADR-0052: the shape parsed INTO is @responseRef, never @payloadRef (which types the
request the prompt renders outbound), and template.output gets no parser at all. The
strict tier is JSON-only — an @responseFormat: xml reply gets the tolerant extract and
nothing strict.
// generated/acme/ai/prompts/NpcResponseParser.kt
import acme.ai.NpcReply // the @responseRef value object's own data class
object NpcResponseParser {
private val mapper = jacksonObjectMapper().findAndRegisterModules()
/** @throws com.fasterxml.jackson.core.JsonProcessingException on bad input. */
fun parseNpcResponse(text: String): NpcReply =
mapper.readValue(text, NpcReply::class.java)
/** Result-style — does not throw. */
fun safeParseNpcResponse(text: String): Result<NpcReply> =
runCatching { parseNpcResponse(text) }
}Consumer wiring:
val response: String = myLlmClient.complete(promptText)
// Throwing path — propagate to your error handler
val npc = NpcResponseParser.parseNpcResponse(response)
// Or Result-style
NpcResponseParser.safeParseNpcResponse(response)
.onSuccess { npc -> /* use it */ }
.onFailure { ex -> log.warn("LLM returned malformed payload", ex) }Consumer dependency. The emitted strict parser uses Jackson with the Kotlin module:
dependencies {
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.x")
}No kotlinx-serialization plugin is needed for the template tier: the value objects' data
classes carry no @Serializable (it was decorative — no build enabled the compiler plugin
it needs). See
codegen-kotlin/KNOWN_GAPS.md
for the full consumer-wiring contract. Cross-port design is at
ADR-0010;
the feature reference is at
features/templates-and-payloads.md.
KotlinSpringControllerGenerator emits a Spring @RestController per writable
entity (source.rdb @kind="table") conforming to the cross-port REST contract
at docs/features/api-contract.md. Any
universal browser client built against that contract — including the
@metaobjectsdev/angular runtime + the @metaobjectsdev/codegen-ts-angular
codegen (source-only; not published to npm) — consumes it directly: services,
reactive forms, and grids point at
the same URL grammar (/api/<entity-plural>), the same ?withCount=1
envelope, and the same JSON wire format used by the C# .NET 8 + ASP.NET
Minimal API backend.
The C#-side recipe at
docs/recipes/csharp-angular18.md walks
through the dev-server CORS wiring, provideHttpClient(), and grid/form/
service usage end-to-end. Swap the ASP.NET sections for Spring Boot
configuration (Spring WebMvcConfigurer instead of AddCors, application
port 8080 instead of 5000) — every other line carries over verbatim because
the contract is universal.
| Drift source | Where caught | When |
|---|---|---|
| Code-vs-DB | KotlinEntityGenerator + KotlinExposedTableGenerator (one metadata, two emitters) |
Build time |
| Code-vs-API-doc | Cross-port codegen from same metadata | Build time |
| DB-vs-metadata | MetadataStartupValidator.validate(loader) at Spring ApplicationReadyEvent; live-DB schema drift: TS toolchain meta verify --db |
App startup; CI on every PR (TS) |
| Migration-vs-metadata | TS toolchain meta migrate emits from metadata diffs (meta:migrate Maven goal was removed) |
Build time |
| Generated-edited | @generated KotlinPoet headers |
Code review |
| Prompt-vs-payload | KotlinRenderHelperGenerator's build-time drift gate + Java Renderer.verify |
Build time + runtime |
| Generated-vs-runtime | MetadataStartupValidator.validate(loader) from Spring ApplicationReadyEvent |
App startup |
| Feature | Status |
|---|---|
| Entities + fields | Yes |
| Relationships + FK | Yes |
| Source kinds (table / view / storedProc) | Yes — storedProc has its own generator |
REST controllers (Spring @RestController) |
Yes — KotlinSpringControllerGenerator per writable entity; cross-port API contract |
field.currency / field.enum / field.object + @storage |
Yes (incl. flattened per-sub-field columns) |
| Templates + render (FR-004) | Yes (wraps the Java engine) |
| Output parser codegen (FR-006) | Yes (KotlinOutputParserGenerator — Jackson + Result<T> dual API) |
| Payload-VO codegen | Yes — the payload IS the value object's own data class, from KotlinEntityGenerator (ADR-0056); no separate payload generator |
| Migrations | Via the TS toolchain (@metaobjectsdev/cli migrate) |
| Drift verify | Template-drift: Renderer.verify (build-time); generated-table drift: MetadataStartupValidator (startup) |
| Runtime metadata | Via Java OMDB (or hand-written Exposed transactions) |
Several hundred tests in codegen-kotlin (mvn -pl codegen-kotlin test; ~290
@Test methods across ~50 test files). Snapshot tests gate
within-Java output stability; kotlin-compile-testing gates generated-code
validity; an end-to-end test exercises the full loop including the Java
Renderer. Persistence-conformance + the cross-port API contract run in
integration-tests-kotlin (33 / 33 — 12 persistence + 20 api-contract + 1
codegen-matches-reference, all runnable via scripts/integration-test.sh kotlin).
codegen-kotlin-exposed1x-check (same command) adds three exposedApi=1 tests: the model
- persistence tier and the controller tier (each the full fitness corpus against Exposed
1.3.x, with no exclusions) compiling against the real jars, and a real-Postgres round-trip
of the four custom column types — see
Exposed 1.x output above.
integration-tests-kotlinadds theexposedApi=0sibling of the controller-tier compile (FitnessCorpusControllerCompileTest), also with no exclusions.
server/java/codegen-kotlin/README.md— generator-level detailsserver/java/metadata-ktx/README.md— Kotlin facade API- Java port — the underlying tier
docs/superpowers/specs/2026-05-25-codegen-kotlin-design.md