Apache 2.0 · TypeScript · Java · Kotlin · C# · Python

One typed model for every app that shares your data

Your agent writes the model. Each app's code is generated from it, and each app's build is checked against it.

Rules files, memories and MCP servers give a coding agent context, but nothing fails when the code stops matching it. It gets worse when several apps share one data model: your API, your LLM worker, your admin app and your prompts can each look right alone and still disagree, and an agent working in one can't see the others.

MetaObjects gives them one typed model covering the data, the API contract, the prompt payloads, and what the software is supposed to do. Each app generates its types, validators and routes from that model, in its own language, and the output depends on no proprietary runtime. Each app's verify step fails CI when its generated code, a prompt or a claimed capability no longer matches the model.

Less code to write and review. You, or your coding agent, write one declaration in the model instead of the same thing once per layer: the types, validators, queries, routes and UI. The generators write the rest. In one app, 18 model objects become 129 generated files. In another, 13 become 117.

Write it once. Generate it for every entity. Any layer you repeat for every entity (API client, DTO mapping, audit log, admin screen, docs) becomes a small generator in your repo, so it can't drift from the model and nobody hand-writes it again. The shipped generators (Drizzle, Spring, EF Core, FastAPI and more) run as they are or can be copied in and changed.

It protects what the model declares; your hand-written logic is still yours. If your app is single-language with no LLM calls, a good ORM is probably enough, and the comparison below says so up front.

When AI-written code drifts from your typed model, the build fails.

A field renamed in one service misses the worker that reads the same table. A required field reaches the API but not the Python model that mirrors it. A prompt template still says {{userName}} after the payload moved to user.name. Each of these looks fine inside the one codebase an agent is editing. Here is what MetaObjects 1.1 checks, the command that runs the check, what happens when it fails, and where it does not reach.

When this happens What fails, and how Where it runs today
The shared model changes and one app or service is not regenerated That app's verify exits 1 and lists the generated files that no longer match the model. It checks generated files; your hand-written code that calls them is caught by your compiler once you regenerate (Python: with mypy or pyright). All five ports. TS meta verify --codegen · Python metaobjects verify · Java and Kotlin mvn metaobjects:verify · C# dotnet meta verify --codegen. Each app runs its own check in its own CI; nothing links the apps for you.
A prompt or output template (email, export, docs) uses a field its typed payload doesn't carry — or the payload field was renamed ERR_VAR_NOT_ON_PAYLOAD, exit 1, with the template file and line. A missing template file fails too. A payload field the template never uses is not flagged. Templates are Mustache, logic-less: no expressions or filters, so a computed value belongs in the payload. All five ports. TS and C# run it by default; Python --templates; Java and Kotlin -Dmeta.verify.mode=templates.
The live database no longer matches the model — a column renamed, missing or extra meta verify --db <url> exits 1 and lists the column differences. The Node meta CLI, for any port's database: Postgres, SQLite, Cloudflare D1. Not MySQL, SQL Server or Oracle.
The model lives in another repo and its publisher changed it meta verify --deps exits 1 with ERR_DEPENDENCY_UPSTREAM_DRIFT. Opt-in; it needs the publisher's checkout path. TypeScript publishes and checks; Python can consume the snapshot but not run the check. Java, Kotlin, C#: not yet.
Someone hand-edits a generated file, then the model changes The edit is never silently overwritten. TypeScript three-way merges it on the machine that generated the file (a conflict leaves markers and fails); elsewhere, and in a fresh CI checkout, regeneration refuses the file by name. In TypeScript, meta verify --codegen lists hand-edited files as a notice; add --forbid-hand-edits to fail the build on any hand edit. All five ports refuse to overwrite. Three-way merge: TypeScript only.
A requirement marked live points at a field or route that was renamed or removed ERR_REQUIREMENT_DANGLING_REF, exit 1. A live requirement that names nothing implementing it is a warning, or a failure with --require-implementers. It proves the links resolve, not that the code is correct. All five ports, on every verify run.

One thing to set up deliberately: in TypeScript a bare meta verify runs the prompt and requirement checks only. Name --codegen (and --db or --deps if you use them) in CI. The ports differ in their defaults, so the commands above are the ones to copy.

The checks above only protect code that is generated. Every layer you still write by hand for each entity is one more unchecked copy of the model, so write a generator for each layer that repeats per entity. Change the model, regenerate, and verify --codegen fails the build for anyone who forgot. Running a shipped generator as it is works too.

A generator is small: a name plus a function from the loaded model to a list of { path, content } files. The runner writes them, and verify --codegen re-runs it and fails the build when the committed output is stale. This one, from the write-your-own-generator recipe, writes one file per entity listing its fields, inherited ones included:

import { isAbstract, perEntity, type Generator } from "@metaobjectsdev/codegen-ts";

export function fieldListGenerator(): Generator {
  return {
    name: "field-list",                          // shows in diagnostics
    filter: (obj) => !isAbstract(obj),           // ctx.entities has EVERY object, abstract ones too
    generate: perEntity((obj) => ({              // perPackage / perModel for other scopes
      path: `field-list/${obj.name}.txt`,        // relative to outDir
      content: obj.fields()                      // fields() includes inherited fields
        .map((f) => `${f.name}: ${f.subType}${f.resolvedIsArray() ? "[]" : ""}${f.isRequired ? "" : "?"}`)
        .join("\n") + "\n",
    })),
  };
}
PortHow you start one
TypeScriptmeta generator new <name> writes and wires a generator that already runs. A team can keep its house generators in one package and import it from every repo's config.
PythonA function returning a generator, named as a module:symbol entry in metaobjects.config.yaml.
Java, KotlinExtend FileEmittingGenerator and read the model through ModelWalk.
C#An IGenerator class listed in codegen/Generators.cs.

What it costs, measured

Three agent sessions each wrote a first generator from scratch, using only the public docs and packages: a typed fetch client for TypeScript (172 lines), an audit log for TypeScript (148 lines, plus 32 that declare its model markers), and a typed httpx client for Python (203 lines). Each session took 8 to 10 minutes of agent time end to end, setup included. Each generator passed verify --codegen on its first run and failed it after a model change that was not regenerated. Each had to read shipped generator source for details the docs don't state yet. Java, Kotlin and C# use the same shape and were not measured.

These are our own measurements, so check them: model Customer and Order, run the shipped entity and route generators, then write your own generator that emits a typed API client for those routes, and wire it so verify --codegen covers it.

The fastest test of fit is one generator of your own

Pick a layer you hand-write for every entity and write that one generator. Then rename a field in the model. If the generator plus its output is smaller than the copies it replaces, and the rename fails the build, that is your answer. If not, stop there.

What owning one means

A generator you write has no upstream copy, so there is nothing to fall behind. A shipped generator you eject does: fixes to it no longer reach your copy, so run shipped ones as shipped until you need them to differ. Only TypeScript has a scaffold command. meta gen does not typecheck a generator, so run tsc on it in CI. A one-off layer that won't repeat can stay hand-written.

Several apps on one data model

A TypeScript API and a Python LLM worker reading the same Postgres. A Spring service and a React admin app. Put the apps in one repo and point each at the same model directory, and each app's build fails when it falls behind. Any mix of the five languages works.

Prompts and output templates filled from your data

A prompt or output template (email, export, docs) is a Mustache template, logic-less, plus a payload. When the payload is a type generated from the model, a renamed field fails the build instead of quietly sending the LLM an empty slot. Render, payload types and the drift check ship in all five ports, and so do typed response parsers for prompts that declare a response. If your prompts are Jinja or carry logic, expect to move that logic into the payload.

Code that agents write

The checks above run in CI whether or not the agent remembered what the other services assume, and the model is small YAML or JSON an agent can read and edit.

Not yet: separate repos on the JVM or .NET

Sharing one model across repos with an upstream check works when the consumer is TypeScript (Python can consume, not check). A Kotlin, Java or C# service in its own repo can only vendor a copy of the model today, with nothing telling it the original moved. Keep those services in one repo with the model, or wait for Phase 2.

Not for: SQL Server or Oracle schema management

Migrations and the live-database check cover Postgres, SQLite and D1. On SQL Server or Oracle, MetaObjects won't manage the schema; the contract types and the prompt and requirement checks still work beside your ORM.

Probably not: one app, one language, no LLM

If a single service owns its data and talks to no LLM, Prisma, Hibernate, EF Core or Django already give you one schema. The layers you repeat per entity are still generated for you, and the cross-app and prompt checks are what you would add MetaObjects for.

One model keeps your apps consistent at build time. It does not check wire compatibility during a rolling deploy or for outside API consumers, and verify does not stop a team that ignores the model and keeps its own DTO.

If the tool falls short, you can fix it. Most changes a team needs are to generated output: your own generators are already yours, and eject copies any shipped one into your repo to change the same day. For the core (loader, verify, migrate, render) it is Apache 2.0 with all five ports in one public repo: patch a fork, pin to it, and drop the patch when upstream catches up. Every upstream release you then want is a merge you do.

You don't have to leave Prisma, Hibernate, EF Core or SQLAlchemy. Every port can generate just the types and validators (the API contract and the prompt payloads) and leave persistence to the ORM you already run. MetaObjects does not generate into your ORM's schema files, and your ORM's schema file stays yours to write and maintain; MetaObjects sits beside it.

If you use Generate only this How
TypeScript — Prisma, Drizzle, TypeORM, Kysely TypeScript types and Zod schemas, with no Drizzle import Add an output target with runtime: false, or model the shape without a source.rdb table.
Java — Hibernate/JPA, jOOQ, JDBC DTO records with Jakarta validation, and repository interfaces with no JPA annotations The dto and value-object generators; implement the repository with whatever you use.
Kotlin — Exposed, jOOQ, Spring Data Data classes with Jakarta annotations The entity generator; leave out exposed-table.
C# — EF Core, Dapper Plain POCOs with DataAnnotations and no EF mapping Model the shape without a source.rdb table, and leave out db-context.
Python — SQLAlchemy, Django ORM Pydantic v2 models, with no SQLAlchemy or Django import The entity generator.

Databases. Generated code is tested against Postgres in every port, and against SQLite and MySQL for parts of it. meta migrate and the live-database check support Postgres, SQLite and Cloudflare D1; on MySQL you own the DDL. SQL Server and Oracle are not supported today: the C# AppDbContext generator emits Postgres column types, and the Java runtime's older Oracle and SQL Server drivers are untested. If you run on either, generate the contract types above and keep your own mapping.

Python + TypeScript · production

Draagon AI

From 17 runtime schema patches to zero. One ~7,000-line YAML model generates more than 27,000 lines of drift-checked Python and TypeScript: 44 entities and more than 100 LLM prompts declared once, on the published packages.

The small-adoption case

Roxy

An existing Python voice assistant adopted two parts: prompt templates checked against a declared payload, and its Postgres schema managed by the CLI. That took about seventy lines of metadata, and the rest of the codebase was left alone.

All three are the maintainer's own projects; nobody else has adopted MetaObjects yet. Wizards of Odd runs from a clone, so you can check these claims yourself. All case studies →

The problem MetaObjects solves, what it actually is, and how the codegen and runtime halves work together — in eight minutes.

One model — your objects, database, API, UI, and AI.

The fit and migration assessment is one Markdown prompt that your coding agent runs against your repo. It needs no database connection or signup. The agent reads your code, migrations and commit log and writes a report. The main part is a list of the drift in your own history: shapes you declare in more than one place, where those copies disagree today, and past fixes that patched one copy and missed the other. For each one, it names the verify check that would have failed the build instead.

Fetch https://metaobjects.dev/assess.md and run the MetaObjects
Fit & Migration Assessment against this repository.

Works in Claude Code, Cursor, Windsurf, GitHub Copilot, Gemini CLI, or any agent that can fetch a URL; pasting the prompt into any chat works too. It runs on your tokens, and results vary by model. Every claim cites a file:line or a commit so you can check it. Nothing is sent to us. The quick pass answers in the conversation and writes nothing; the full assessment takes an hour or more and writes its report outside your repo.

Read it first. /assess.md is the whole prompt, in plain Markdown. It is read-only: it tells your agent to read your repo and report, and never to create, edit, install or commit anything in it. The same prompt, with its reference files, is in the repo at a release tag.

The assessment can tell you not to adopt. Each pillar gets its own verdict, and NOT A FIT is one of the options. It only promises what your language's port ships, and every report includes a "what you will NOT get" section. We ran it blind on a production codebase from before that codebase adopted MetaObjects. It found drift incidents, confirmed in git, that had already caused trouble: a constraint mismatch that was fixed only after it was violated in production, and a schema divergence that was still live when the assessment ran. Where it was wrong, it understated the problems rather than overstating them.

Drift checks cannot see a second failure: your code agrees with your model, and the model describes a capability your software does not have. The columns exist, the types match and the tests pass, but no code ever writes the value.

This is for teams that have to prove what their software implements: regulated, audited, or built to a contract. Declared across three shipping products, that check found a bot that could not be retired, a season that could not be run privately, private tables silently running at the database default, and an interrupted-work recovery path that was never built, in a codebase with 1,280 tests passing and zero failures. A capability declared in MetaObjects is a node in the same model as your entities, and verify checks that the field or route it names exists.

How capability requirements work →

Language Status Details
TypeScript npm 1.1.4 · reference npm @metaobjectsdev/*@1.1.4 (the reference implementation). Drizzle + Zod + Fastify + Hono codegen, Kysely runtime, and meta migrate for Postgres / SQLite / Cloudflare D1 — the TS toolchain owns schema migrations. 2,500+ tests across the workspace.
Java Maven Central 8.1.4 com.metaobjects:metaobjects-*:8.1.4 on Maven Central. Spring REST + DTO + Repository + filter-allowlist codegen via codegen-spring; OMDB runtime persistence (CRUD / query / typed jsonb / Spring-tx); render + payload + verify + output-parser codegen.
Kotlin Maven Central 8.1.4 com.metaobjects:metaobjects-codegen-kotlin:8.1.4 + metadata-ktx Kotlin facade. KotlinPoet codegen: Exposed Tables, Spring controllers, payload VOs, output parsers, stored-proc helpers. Persistence-conformance against Testcontainers Postgres.
C#.NET NuGet 1.1.4 NuGet MetaObjects / MetaObjects.Render / MetaObjects.Codegen + the dotnet meta tool (MetaObjects.Cli), all 1.1.4. EF Core entities + AppDbContext (Postgres column types today) + ASP.NET minimal-API routes codegen, or plain POCOs with no EF mapping; render engine + payload-VO codegen + verify. (Schema migrations are TS-owned.)
Python PyPI 1.1.4 PyPI metaobjects 1.1.4 + the metaobjects CLI. Loader + canonical serializer + render + verify + codegen (Pydantic + FastAPI) + ObjectManager runtime. All five cross-port conformance corpora green.

31 shared conformance corpora at fixtures/ (metamodel with 413 fixtures, render, verify, extract, persistence with 24 query scenarios against Testcontainers Postgres, API contract in cross-port reference and generated lanes, YAML and more) validate every implementation against the same expectations. All ports implement metamodelVersion 1.2, the metadata contract, versioned separately from the package lines.

A typed entity in metadata, side-by-side with what every port actually generates. Each output uses that language's usual stack, and shared conformance corpora hold every port to the same reading of the model.

- object.entity:
    name: Subscriber
    children:
      - source.rdb:      { table: subscribers }
      - field.long:      { name: id }
      - field.string:    { name: email, maxLength: 320, required: true }
      - field.string:    { name: name }
      - field.enum:
          name: status
          values: [active, paused, cancelled]
          required: true
      - field.timestamp: { name: createdAt, column: created_at, autoSet: onCreate }
      - identity.primary: { name: primary, fields: [id], generation: increment }
// @generated by @metaobjectsdev/codegen-ts — hand edits are kept by a three-way merge on regeneration; meta verify --codegen checks only the generated parts.
…
export const subscribers = sqliteTable(
  "subscribers",
  {
    id: integer("id").primaryKey({ autoIncrement: true }),
    email: text("email").notNull(),
    name: text("name"),
    status: text("status", {
      enum: ["active", "paused", "cancelled"] as const,
    }).notNull(),
    createdAt: text("created_at").$defaultFn(() => new Date().toISOString()),
  },
…
);
export type Subscriber = InferSelectModel<typeof subscribers>;
export type SubscriberInsert = InferInsertModel<typeof subscribers>;
…
export const SubscriberInsertSchema = z.object({
  email: z.string().min(1).max(320),
  name: z.string().optional(),
  status: z.enum(["active", "paused", "cancelled"]),
…
});
…
Show the whole generated file (165 lines)
// @generated by @metaobjectsdev/codegen-ts — hand edits are kept by a three-way merge on regeneration; meta verify --codegen checks only the generated parts.
// Source metadata: Subscriber (Subscriber)
// Extend in your own module (e.g. Subscriber.extra.ts) — nothing imports it for you.
import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm";
import { check, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { z } from "zod";

export const subscribers = sqliteTable(
  "subscribers",
  {
    id: integer("id").primaryKey({ autoIncrement: true }),
    email: text("email").notNull(),
    name: text("name"),
    status: text("status", {
      enum: ["active", "paused", "cancelled"] as const,
    }).notNull(),
    createdAt: text("created_at").$defaultFn(() => new Date().toISOString()),
  },
  (table) => [
    check(
      "subscribers_status_chk",
      sql`status IN ('active', 'paused', 'cancelled')`,
    ),
  ],
);
export type Subscriber = InferSelectModel<typeof subscribers>;
export type SubscriberInsert = InferInsertModel<typeof subscribers>;
export type SubscriberUpdate = Partial<SubscriberInsert>;
export type SubscriberStatus = "active" | "paused" | "cancelled";
export const SubscriberStatusEnum = z.enum(["active", "paused", "cancelled"]);
/** A field.timestamp instant in UTC (`YYYY-MM-DDTHH:MM:SS[.fff]Z`): a value already spelled so is kept
 *  as sent, an offset is applied, a zoneless value is UTC and a date alone is midnight UTC.
 *  SQLite/D1 compare timestamps as text, so instants are stored in UTC. Generated. */
function utcIsoTimestamp(v: string): string {
  if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(v)) {
    return v;
  }
  const m =
    /^(\d{4}-\d{2}-\d{2})(?:[Tt ](\d{2}:\d{2})(?::(\d{2})(?:\.(\d+))?)?(?:([Zz])|([+-]\d{2}):?(\d{2})?)?)?$/.exec(
      v,
    );
  if (m === null) {
    return v;
  }
  const t = Date.parse(
    `${m[1]}T${m[2] ?? "00:00"}:${m[3] ?? "00"}.${(m[4] ?? "").slice(0, 3).padEnd(3, "0")}${
      m[6] === undefined ? "Z" : `${m[6]}:${m[7] ?? "00"}`
    }`,
  );
  return Number.isNaN(t)
    ? v
    : new Date(t).toISOString().replace(/\.?0+Z$/, "Z");
}

export const SubscriberInsertSchema = z.object({
  email: z.string().min(1).max(320),
  name: z.string().optional(),
  status: z.enum(["active", "paused", "cancelled"]),
  createdAt: z
    .string()
    .optional()
    .transform(() => new Date().toISOString()),
});

export const SubscriberUpdateSchema = z.object({
  email: z.string().min(1).max(320).optional(),
  name: z.string().optional().nullable(),
  status: z.enum(["active", "paused", "cancelled"]).optional(),
});

/** Typed create shape for Subscriber: the insert schema's INPUT (pre-transform) type. A
 * renamed/dropped/misspelt field is a compile error at every `createSubscriber` call site;
 * the schema still validates at runtime. */
export type SubscriberCreate = z.input<typeof SubscriberInsertSchema>;

/** Typed patch shape for Subscriber: every settable field, optional (FR-035 PATCH). A
 * renamed/dropped field is a compile error at every `updateSubscriber` call site. */
export type SubscriberPatch = z.input<typeof SubscriberUpdateSchema>;

/** Insert-shape for import / restore / replication of Subscriber: identical to
 * SubscriberInsertSchema, but the @autoSet timestamp columns are written VERBATIM
 * (no create-time now() stamp) so the caller's original values are preserved. */
export const SubscriberInsertPreservingSchema = z.object({
  email: z.string().min(1).max(320),
  name: z.string().optional(),
  status: z.enum(["active", "paused", "cancelled"]),
  createdAt: z
    .string()
    .regex(
      /^(?:\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|02-(?:0[1-9]|1\d|2[0-8]))|(?:\d{2}(?:0[48]|[2468][048]|[13579][26])|(?:[02468][048]|[13579][26])00)-02-29)(?:[Tt ](?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:[Zz]|[+-](?:[01]\d|2[0-3])(?::?[0-5]\d)?)?)?$/,
      "must be an ISO 8601 timestamp (YYYY-MM-DD[THH:MM[:SS[.fff]]][Z|±HH:MM])",
    )
    .transform(utcIsoTimestamp)
    .optional(),
});

/** Typed input of `insertPreservingSubscriber` — SubscriberInsertPreservingSchema's pre-transform shape. */
export type SubscriberCreatePreserving = z.input<
  typeof SubscriberInsertPreservingSchema
>;
/**
 * Metadata constants for Subscriber.
 *
 * Use these instead of magic strings so TS catches typos and refactors stay
 * coherent. Each non-dollar-prefixed key is a per-field object carrying
 * name, label, view, an htmlType where the field's view maps to one, and the
 * RHF-shaped validation rules derived from the field's validator children.
 *
 * placeholder and helpText are NOT emitted — no provider registers an attribute
 * for them, so nothing here could derive one. (htmlType is emitted, but derived
 * from the view subtype; the @htmlType override was removed for the same reason.)
 * useEntityForm still honours placeholder if you add it: this file is generated,
 * hand edits inside it survive regeneration through the three-way merge, and the
 * consumers read this const rather than the metadata. That is the intended way
 * to set it.
 *
 * Typical usage with the metaobjects React form helper:
 *
 *   import { useEntityForm } from '@metaobjectsdev/react';
 *   const form = useEntityForm(Subscriber, SubscriberInsertSchema);
 *   <input {...form.input.id} />
 */
export const Subscriber = {
  $entity: "Subscriber",
  $table: "subscribers",
  $path: "/subscribers",
  id: { name: "id", label: "Id", view: "number", htmlType: "number" },
  email: {
    name: "email",
    label: "Email",
    view: "text",
    htmlType: "text",
    rules: {
      required: "Email is required",
      maxLength: { value: 320, message: "Must be 320 characters or fewer" },
    },
  },
  name: { name: "name", label: "Name", view: "text", htmlType: "text" },
  status: {
    name: "status",
    label: "Status",
    view: "dropdown",
    rules: { required: "Status is required" },
    options: ["active", "paused", "cancelled"] as const,
  },
  createdAt: {
    name: "createdAt",
    label: "Created At",
    view: "date",
    htmlType: "datetime-local",
  },
} as const;
import type { FilterAllowlist } from "../../codegen/runtime/drizzle-fastify/filter-allowlist";

export const SubscriberFilterAllowlist = {} as const satisfies FilterAllowlist;
import type { SortAllowlist } from "../../codegen/runtime/drizzle-fastify/filter-allowlist";

export const SubscriberSortAllowlist = {} as const satisfies SortAllowlist;
export type SubscriberFilter = {
  limit?: number;
  offset?: number;
  sort?: string;
  or?: SubscriberFilter[];
  and?: SubscriberFilter[];
};
…
/** GENERATED — wire DTO for Subscriber. Do not hand-edit; regenerated from metadata. */
public record SubscriberDto(
    Long id,
    @NotNull @Size(min = 1, max = 320) String email,
    String name,
    @NotNull SubscriberStatus status,
    java.time.Instant createdAt
) {
    public enum SubscriberStatus { active, paused, cancelled }

    /** Issue #203 @autoSet: a copy with every @autoSet column (onCreate AND onUpdate) stamped to now() on insert (the model value is ignored; a fresh row's updated_at equals its created_at). */
    public static SubscriberDto stampForInsert(SubscriberDto dto) {
        java.time.Instant __nowInstant = java.time.Instant.now();
        return new SubscriberDto(
            dto.id(),
            dto.email(),
            dto.name(),
            dto.status(),
            __nowInstant
        );
    }
…
}
…
Show the whole generated file (62 lines)
package acme;

import jakarta.validation.constraints.*;

/** GENERATED — wire DTO for Subscriber. Do not hand-edit; regenerated from metadata. */
public record SubscriberDto(
    Long id,
    @NotNull @Size(min = 1, max = 320) String email,
    String name,
    @NotNull SubscriberStatus status,
    java.time.Instant createdAt
) {
    public enum SubscriberStatus { active, paused, cancelled }

    /** Issue #203 @autoSet: a copy with every @autoSet column (onCreate AND onUpdate) stamped to now() on insert (the model value is ignored; a fresh row's updated_at equals its created_at). */
    public static SubscriberDto stampForInsert(SubscriberDto dto) {
        java.time.Instant __nowInstant = java.time.Instant.now();
        return new SubscriberDto(
            dto.id(),
            dto.email(),
            dto.name(),
            dto.status(),
            __nowInstant
        );
    }

    /** Issue #203 @autoSet: a copy with @autoSet onUpdate columns stamped to now(); onCreate columns are preserved (never rewrites created_at — the full-DTO update path). */
    public static SubscriberDto stampForUpdate(SubscriberDto dto) {
        return new SubscriberDto(
            dto.id(),
            dto.email(),
            dto.name(),
            dto.status(),
            dto.createdAt()
        );
    }

    /** Issue #203 @autoSet escape hatch: the DTO written VERBATIM (import/restore/replication). */
    public static SubscriberDto insertPreserving(SubscriberDto dto) { return dto; }

    /** A fluent builder, so a caller can set a subset of the components. */
    public static Builder builder() { return new Builder(); }

    /** Builds a {@link SubscriberDto}; an unset component stays null. */
    public static final class Builder {
        private Long id;
        private String email;
        private String name;
        private SubscriberStatus status;
        private java.time.Instant createdAt;

        private Builder() {}

        public Builder id(Long v) { this.id = v; return this; }
        public Builder email(String v) { this.email = v; return this; }
        public Builder name(String v) { this.name = v; return this; }
        public Builder status(SubscriberStatus v) { this.status = v; return this; }
        public Builder createdAt(java.time.Instant v) { this.createdAt = v; return this; }

        public SubscriberDto build() { return new SubscriberDto(id, email, name, status, createdAt); }
    }
}
…
/**
 * GENERATED — do not hand-edit. Regenerated from metadata.
 */
public data class Subscriber(
  public val id: Long? = null,
  @field:NotNull
  @field:Size(min = 1, max = 320)
  public val email: String,
  public val name: String? = null,
  @field:NotNull
  public val status: SubscriberStatus,
  public val createdAt: Instant? = null,
…
)
…
Show the whole generated file (75 lines)
package acme

import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size
import java.time.Instant
import kotlin.Long
import kotlin.String
import kotlin.jvm.JvmStatic

/**
 * GENERATED — do not hand-edit. Regenerated from metadata.
 */
public data class Subscriber(
  public val id: Long? = null,
  @field:NotNull
  @field:Size(min = 1, max = 320)
  public val email: String,
  public val name: String? = null,
  @field:NotNull
  public val status: SubscriberStatus,
  public val createdAt: Instant? = null,
) {
  /**
   * Fluent builder — lets a JAVA caller set a subset of properties.
   */
  public class Builder {
    private var id: Long? = null

    private var email: String? = null

    private var name: String? = null

    private var status: SubscriberStatus? = null

    private var createdAt: Instant? = null

    public fun id(v: Long?): Builder {
      this.id = v
      return this
    }

    public fun email(v: String?): Builder {
      this.email = v
      return this
    }

    public fun name(v: String?): Builder {
      this.name = v
      return this
    }

    public fun status(v: SubscriberStatus?): Builder {
      this.status = v
      return this
    }

    public fun createdAt(v: Instant?): Builder {
      this.createdAt = v
      return this
    }

    public fun build(): Subscriber = Subscriber(
      id = id,
      email = requireNotNull(email) { "email is required" },
      name = name,
      status = requireNotNull(status) { "status is required" },
      createdAt = createdAt
    )
  }

  public companion object {
    @JvmStatic
    public fun builder(): Builder = Builder()
  }
}
// <auto-generated/>
…
[Table(SubscriberNames.SourcePrimaryTable)]
public class Subscriber
{
    public enum SubscriberStatus { active, paused, cancelled }
    [Key]
    [Column(SubscriberNames.IdColumn)]
    public long Id { get; set; }
    [Column(SubscriberNames.EmailColumn)]
    [Required(AllowEmptyStrings = true)]
    [MaxLength(320)]
…
    public string Email { get; set; } = default!;
    [Column(SubscriberNames.NameColumn)]
    public string? Name { get; set; }
    [Column(SubscriberNames.StatusColumn)]
    public SubscriberStatus Status { get; set; }
    [Column(SubscriberNames.CreatedAtColumn)]
    public DateTimeOffset? CreatedAt { get; set; }
}
Show the whole generated file (29 lines)
// <auto-generated/>
// Generated by MetaObjects entity-generator. Do not edit by hand.
#nullable enable
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace Generated;

[Table(SubscriberNames.SourcePrimaryTable)]
public class Subscriber
{
    public enum SubscriberStatus { active, paused, cancelled }
    [Key]
    [Column(SubscriberNames.IdColumn)]
    public long Id { get; set; }
    [Column(SubscriberNames.EmailColumn)]
    [Required(AllowEmptyStrings = true)]
    [MaxLength(320)]
    [MinLength(1)]
    public string Email { get; set; } = default!;
    [Column(SubscriberNames.NameColumn)]
    public string? Name { get; set; }
    [Column(SubscriberNames.StatusColumn)]
    public SubscriberStatus Status { get; set; }
    [Column(SubscriberNames.CreatedAtColumn)]
    public DateTimeOffset? CreatedAt { get; set; }
}
# @generated by metaobjects — DO NOT EDIT.
…
import datetime
from typing import Literal

from pydantic import BaseModel, Field


class Subscriber(BaseModel):
    id: int | None = None
    email: str = Field(max_length=320)
    name: str | None = None
    status: Literal["active", "paused", "cancelled"]
    createdAt: datetime.datetime | None = None
…
Show the whole generated file (36 lines)
# @generated by metaobjects — DO NOT EDIT.
# Source metadata: Subscriber (acme::Subscriber)
# Extend in your own module (e.g. Subscriber_extra.py) — nothing imports it for you.

from __future__ import annotations

import datetime
from typing import Literal

from pydantic import BaseModel, Field


class Subscriber(BaseModel):
    id: int | None = None
    email: str = Field(max_length=320)
    name: str | None = None
    status: Literal["active", "paused", "cancelled"]
    createdAt: datetime.datetime | None = None


class SubscriberCreate(BaseModel):
    """GENERATED — CREATE input: auto-gen PK / @mutability readOnly omitted (writeOnce is settable here, once); @default/@autoSet optional; present values validated (FR-036)."""

    email: str = Field(min_length=1, max_length=320)
    name: str | None = None
    status: Literal["active", "paused", "cancelled"]
    createdAt: datetime.datetime | None = None


class SubscriberPatch(BaseModel):
    """GENERATED — PATCH input: all fields optional (PK, @mutability readOnly and writeOnce excluded); present values validated (FR-036)."""

    email: str | None = Field(default=None, min_length=1, max_length=320)
    name: str | None = None
    status: Literal["active", "paused", "cancelled"] | None = None
    createdAt: datetime.datetime | None = None
CREATE TABLE "subscribers" (
  "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
  "email" VARCHAR(320) NOT NULL,
  "name" TEXT,
  "status" TEXT NOT NULL,
  "created_at" TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
  CONSTRAINT "subscribers_status_chk" CHECK ("status" IN ('active', 'paused', 'cancelled'))
);
# Scaffold metaobjects/ + .metaobjects/ + metaobjects.config.ts
$ meta init

# Generate code (entities, validators, queries, routes, payload VOs, output parsers)
$ meta gen

# Introspect the live DB, diff it against the metadata, write + apply the SQL
$ meta migrate --from-db --db file:dev.sqlite --dialect sqlite --slug init --apply

Conformance fixtures (JSON form): fixtures/conformance/. YAML and JSON are equivalent — pick whichever your stack prefers.

Fields aren't just columns. They carry validators, views, and currency formatting as child metadata. Entities derive read-only projections (passthrough fields and aggregates over relationships) that generate as database views, not tables. Everything below is real meta gen output, conformance-gated.

# -- Author — minimal identity entity; exists so ProgramSummary has a real
#    cross-entity edge to demonstrate `origin.passthrough` over a join. --
- object.entity:
    name: Author
    children:
      - source.rdb: { table: authors }
      - field.uuid: { name: id }
      - field.string: { name: name, required: true, maxLength: 120 }
      # A field carries VALIDATORS as child metadata, not just a column type.
      # These generate Zod refinements in TS and the equivalent constraint
      # annotations in every other port — nothing is hand-wired.
      - field.string:
          name: email
          required: true
          maxLength: 320
          children:
            # UNANCHORED on purpose: validator.regex @pattern is FULL-MATCH
            # (FR-036), so codegen wraps this in ^(?:…)$ itself. Adding
            # anchors here emits ^(?:^…$)$.
            - validator.regex: { name: emailFmt, pattern: "[^@]+@[^@]+" }
      - field.string:
          name: bio
          maxLength: 2000
          children:
            - validator.length: { name: bioLen, min: 0, max: 500 }
      - identity.primary: { name: id, fields: id, generation: uuid }
…
export const AuthorInsertSchema = z.object({
  name: z.string().min(1).max(120),
  email: z.string().min(1).max(320).regex(new RegExp("^(?:[^@]+@[^@]+)$")),
  bio: z.string().max(500).optional(),
});
…
Show the whole generated file (113 lines)
// @generated by @metaobjectsdev/codegen-ts — hand edits are kept by a three-way merge on regeneration; meta verify --codegen checks only the generated parts.
// Source metadata: Author (Author)
// Extend in your own module (e.g. Author.extra.ts) — nothing imports it for you.
import type { InferInsertModel, InferSelectModel } from "drizzle-orm";
import { pgTable, uuid, varchar } from "drizzle-orm/pg-core";
import { z } from "zod";

export const authors = pgTable("authors", {
  id: uuid("id").primaryKey().defaultRandom(),
  name: varchar("name", { length: 120 }).notNull(),
  email: varchar("email", { length: 320 }).notNull(),
  bio: varchar("bio", { length: 2000 }),
});
export type Author = InferSelectModel<typeof authors>;
export type AuthorInsert = InferInsertModel<typeof authors>;
export type AuthorUpdate = Partial<AuthorInsert>;
export const AuthorInsertSchema = z.object({
  name: z.string().min(1).max(120),
  email: z.string().min(1).max(320).regex(new RegExp("^(?:[^@]+@[^@]+)$")),
  bio: z.string().max(500).optional(),
});

export const AuthorUpdateSchema = z.object({
  name: z.string().min(1).max(120).optional(),
  email: z
    .string()
    .min(1)
    .max(320)
    .regex(new RegExp("^(?:[^@]+@[^@]+)$"))
    .optional(),
  bio: z.string().max(500).optional().nullable(),
});

/** Typed create shape for Author: the insert schema's INPUT (pre-transform) type. A
 * renamed/dropped/misspelt field is a compile error at every `createAuthor` call site;
 * the schema still validates at runtime. */
export type AuthorCreate = z.input<typeof AuthorInsertSchema>;

/** Typed patch shape for Author: every settable field, optional (FR-035 PATCH). A
 * renamed/dropped field is a compile error at every `updateAuthor` call site. */
export type AuthorPatch = z.input<typeof AuthorUpdateSchema>;
/**
 * Metadata constants for Author.
 *
 * Use these instead of magic strings so TS catches typos and refactors stay
 * coherent. Each non-dollar-prefixed key is a per-field object carrying
 * name, label, view, an htmlType where the field's view maps to one, and the
 * RHF-shaped validation rules derived from the field's validator children.
 *
 * placeholder and helpText are NOT emitted — no provider registers an attribute
 * for them, so nothing here could derive one. (htmlType is emitted, but derived
 * from the view subtype; the @htmlType override was removed for the same reason.)
 * useEntityForm still honours placeholder if you add it: this file is generated,
 * hand edits inside it survive regeneration through the three-way merge, and the
 * consumers read this const rather than the metadata. That is the intended way
 * to set it.
 *
 * Typical usage with the metaobjects React form helper:
 *
 *   import { useEntityForm } from '@metaobjectsdev/react';
 *   const form = useEntityForm(Author, AuthorInsertSchema);
 *   <input {...form.input.id} />
 */
export const Author = {
  $entity: "Author",
  $table: "authors",
  $path: "/authors",
  id: { name: "id", label: "Id", view: "text", htmlType: "text" },
  name: {
    name: "name",
    label: "Name",
    view: "text",
    htmlType: "text",
    rules: {
      required: "Name is required",
      maxLength: { value: 120, message: "Must be 120 characters or fewer" },
    },
  },
  email: {
    name: "email",
    label: "Email",
    view: "text",
    htmlType: "text",
    rules: {
      pattern: { value: /[^@]+@[^@]+/, message: "Invalid format" },
      required: "Email is required",
      maxLength: { value: 320, message: "Must be 320 characters or fewer" },
    },
  },
  bio: {
    name: "bio",
    label: "Bio",
    view: "text",
    htmlType: "text",
    rules: {
      minLength: { value: 0, message: "Must be at least 0 characters" },
      maxLength: { value: 500, message: "Must be 500 characters or fewer" },
    },
  },
} as const;
import type { FilterAllowlist } from "../../codegen/runtime/drizzle-fastify/filter-allowlist";

export const AuthorFilterAllowlist = {} as const satisfies FilterAllowlist;
import type { SortAllowlist } from "../../codegen/runtime/drizzle-fastify/filter-allowlist";

export const AuthorSortAllowlist = {} as const satisfies SortAllowlist;
export type AuthorFilter = {
  limit?: number;
  offset?: number;
  sort?: string;
  or?: AuthorFilter[];
  and?: AuthorFilter[];
};
…
  priceCents: {
    name: "priceCents",
    label: "Price Cents",
    view: "currency",
    currency: "USD",
    locale: "en-US",
  },
…
Show the whole generated file (183 lines)
// @generated by @metaobjectsdev/codegen-ts — hand edits are kept by a three-way merge on regeneration; meta verify --codegen checks only the generated parts.
// Source metadata: Program (Program)
// Extend in your own module (e.g. Program.extra.ts) — nothing imports it for you.
import { type InferInsertModel, type InferSelectModel, sql } from "drizzle-orm";
import {
  type AnyPgColumn,
  bigint,
  check,
  jsonb,
  pgTable,
  text,
  uuid,
  varchar,
} from "drizzle-orm/pg-core";
import { z } from "zod";
import { authors } from "./Author";
import {
  type InstructorProfile,
  InstructorProfileInsertSchema,
} from "./InstructorProfile";
import {
  type SyllabusSection,
  SyllabusSectionInsertSchema,
} from "./SyllabusSection";

export const programs = pgTable(
  "programs",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    title: varchar("title", { length: 200 }).notNull(),
    status: text("status", {
      enum: ["draft", "published", "archived"] as const,
    }),
    summary: varchar("summary", { length: 2000 }),
    priceCents: bigint("price_cents", { mode: "number" }),
    coverKey: varchar("cover_key", { length: 80 }),
    authorId: uuid("author_id")
      .notNull()
      .references((): AnyPgColumn => authors.id),
    syllabus: jsonb("syllabus").$type<SyllabusSection[]>(),
    instructorProfile: jsonb("instructor_profile").$type<InstructorProfile>(),
  },
  (table) => [
    check(
      "programs_status_chk",
      sql`status IN ('draft', 'published', 'archived')`,
    ),
  ],
);
export type Program = InferSelectModel<typeof programs>;
export type ProgramInsert = InferInsertModel<typeof programs>;
export type ProgramUpdate = Partial<ProgramInsert>;
export type ProgramStatus = "draft" | "published" | "archived";
export const ProgramStatusEnum = z.enum(["draft", "published", "archived"]);
export const ProgramInsertSchema = z.object({
  title: z.string().min(1).max(200),
  status: z.enum(["draft", "published", "archived"]).optional(),
  summary: z.string().max(2000).optional(),
  priceCents: z.number().int().optional(),
  coverKey: z.string().max(80).optional(),
  authorId: z.string(),
  syllabus: z.array(SyllabusSectionInsertSchema).optional(),
  instructorProfile: InstructorProfileInsertSchema.optional(),
});

export const ProgramUpdateSchema = z.object({
  title: z.string().min(1).max(200).optional(),
  status: z.enum(["draft", "published", "archived"]).optional().nullable(),
  summary: z.string().max(2000).optional().nullable(),
  priceCents: z.number().int().optional().nullable(),
  coverKey: z.string().max(80).optional().nullable(),
  authorId: z.string().optional(),
  syllabus: z.array(SyllabusSectionInsertSchema).optional().nullable(),
  instructorProfile: InstructorProfileInsertSchema.optional().nullable(),
});

/** Typed create shape for Program: the insert schema's INPUT (pre-transform) type. A
 * renamed/dropped/misspelt field is a compile error at every `createProgram` call site;
 * the schema still validates at runtime. */
export type ProgramCreate = z.input<typeof ProgramInsertSchema>;

/** Typed patch shape for Program: every settable field, optional (FR-035 PATCH). A
 * renamed/dropped field is a compile error at every `updateProgram` call site. */
export type ProgramPatch = z.input<typeof ProgramUpdateSchema>;
/**
 * Metadata constants for Program.
 *
 * Use these instead of magic strings so TS catches typos and refactors stay
 * coherent. Each non-dollar-prefixed key is a per-field object carrying
 * name, label, view, an htmlType where the field's view maps to one, and the
 * RHF-shaped validation rules derived from the field's validator children.
 *
 * placeholder and helpText are NOT emitted — no provider registers an attribute
 * for them, so nothing here could derive one. (htmlType is emitted, but derived
 * from the view subtype; the @htmlType override was removed for the same reason.)
 * useEntityForm still honours placeholder if you add it: this file is generated,
 * hand edits inside it survive regeneration through the three-way merge, and the
 * consumers read this const rather than the metadata. That is the intended way
 * to set it.
 *
 * Typical usage with the metaobjects React form helper:
 *
 *   import { useEntityForm } from '@metaobjectsdev/react';
 *   const form = useEntityForm(Program, ProgramInsertSchema);
 *   <input {...form.input.id} />
 */
export const Program = {
  $entity: "Program",
  $table: "programs",
  $path: "/programs",
  id: { name: "id", label: "Id", view: "text", htmlType: "text" },
  title: {
    name: "title",
    label: "Title",
    view: "text",
    htmlType: "text",
    rules: {
      required: "Title is required",
      maxLength: { value: 200, message: "Must be 200 characters or fewer" },
    },
  },
  status: {
    name: "status",
    label: "Status",
    view: "dropdown",
    options: ["draft", "published", "archived"] as const,
  },
  summary: {
    name: "summary",
    label: "Summary",
    view: "textarea",
    rules: {
      maxLength: { value: 2000, message: "Must be 2000 characters or fewer" },
    },
  },
  priceCents: {
    name: "priceCents",
    label: "Price Cents",
    view: "currency",
    currency: "USD",
    locale: "en-US",
  },
  coverKey: {
    name: "coverKey",
    label: "Cover Key",
    view: "image",
    rules: {
      maxLength: { value: 80, message: "Must be 80 characters or fewer" },
    },
  },
  authorId: {
    name: "authorId",
    label: "Author Id",
    view: "text",
    htmlType: "text",
    rules: { required: "Author Id is required" },
  },
  syllabus: {
    name: "syllabus",
    label: "Syllabus",
    view: "text",
    htmlType: "text",
  },
  instructorProfile: {
    name: "instructorProfile",
    label: "Instructor Profile",
    view: "text",
    htmlType: "text",
  },
} as const;
import type { FilterAllowlist } from "../../codegen/runtime/drizzle-fastify/filter-allowlist";

export const ProgramFilterAllowlist = {} as const satisfies FilterAllowlist;
import type { SortAllowlist } from "../../codegen/runtime/drizzle-fastify/filter-allowlist";

export const ProgramSortAllowlist = {} as const satisfies SortAllowlist;
export type ProgramFilter = {
  limit?: number;
  offset?: number;
  sort?: string;
  or?: ProgramFilter[];
  and?: ProgramFilter[];
};
// @generated by @metaobjectsdev/codegen-ts — hand edits are kept by a three-way merge on regeneration; meta verify --codegen checks only the generated parts.
…
// View declaration — Drizzle uses this for typed SELECT queries.
// The SQL view is created/managed by migrate-ts; .existing() tells Drizzle
// not to attempt DDL for this declaration.
export const programSummaryView = pgView("v_program_summary", {
  id: uuid("id").notNull(),
  title: varchar("title", { length: 200 }).notNull(),
  authorName: text("author_name"),
  lessonCount: bigint("lesson_count", { mode: "number" }),
  completedRevenueCents: bigint("completed_revenue_cents", { mode: "number" }),
  hasCoverArt: boolean("has_cover_art"),
}).existing();
export const ProgramSummarySchema = z.object({
  id: z.string(),
  title: z.string(),
  authorName: z.string().nullable(),
  lessonCount: z.number().int().nullable(),
  completedRevenueCents: z.number().int().nullable(),
  hasCoverArt: z.boolean().nullable(),
});
…
Show the whole generated file (79 lines)
// @generated by @metaobjectsdev/codegen-ts — hand edits are kept by a three-way merge on regeneration; meta verify --codegen checks only the generated parts.
// Source metadata: ProgramSummary (ProgramSummary)
import {
  bigint,
  boolean,
  pgView,
  text,
  uuid,
  varchar,
} from "drizzle-orm/pg-core";
import { z } from "zod";

// View declaration — Drizzle uses this for typed SELECT queries.
// The SQL view is created/managed by migrate-ts; .existing() tells Drizzle
// not to attempt DDL for this declaration.
export const programSummaryView = pgView("v_program_summary", {
  id: uuid("id").notNull(),
  title: varchar("title", { length: 200 }).notNull(),
  authorName: text("author_name"),
  lessonCount: bigint("lesson_count", { mode: "number" }),
  completedRevenueCents: bigint("completed_revenue_cents", { mode: "number" }),
  hasCoverArt: boolean("has_cover_art"),
}).existing();
export const ProgramSummarySchema = z.object({
  id: z.string(),
  title: z.string(),
  authorName: z.string().nullable(),
  lessonCount: z.number().int().nullable(),
  completedRevenueCents: z.number().int().nullable(),
  hasCoverArt: z.boolean().nullable(),
});
export type ProgramSummary = z.infer<typeof ProgramSummarySchema>;
export const ProgramSummary = {
  $entity: "ProgramSummary",
  $view: "v_program_summary",
  $path: "/program_summaries",
  id: { name: "id", label: "Id", view: "text", dbCol: "id" },
  title: { name: "title", label: "Title", view: "text", dbCol: "title" },
  authorName: {
    name: "authorName",
    label: "Author Name",
    view: "text",
    dbCol: "author_name",
  },
  lessonCount: {
    name: "lessonCount",
    label: "Lesson Count",
    view: "number",
    dbCol: "lesson_count",
  },
  completedRevenueCents: {
    name: "completedRevenueCents",
    label: "Completed Revenue Cents",
    view: "currency",
    dbCol: "completed_revenue_cents",
    currency: "USD",
    locale: "en-US",
  },
  hasCoverArt: {
    name: "hasCoverArt",
    label: "Has Cover Art",
    view: "checkbox",
    dbCol: "has_cover_art",
  },
} as const;
import type { FilterAllowlist } from "../../codegen/runtime/drizzle-fastify/filter-allowlist";

export const ProgramSummaryFilterAllowlist =
  {} as const satisfies FilterAllowlist;
import type { SortAllowlist } from "../../codegen/runtime/drizzle-fastify/filter-allowlist";

export const ProgramSummarySortAllowlist = {} as const satisfies SortAllowlist;
export type ProgramSummaryFilter = {
  limit?: number;
  offset?: number;
  sort?: string;
  or?: ProgramSummaryFilter[];
  and?: ProgramSummaryFilter[];
};

Run it yourself: every metadata and generated-code block on this page is cut from a committed example that loads and generates a full typed stack with zero errors and zero warnings, and the cuts are checked against the real generated files on every release, so a block here cannot drift from what the tool emits. Every construct it uses (projections, origins, currency views, field-level validators) is conformance-gated across all five ports.

The popular per-language ORMs are excellent and likely enough for a single-language project with no LLM calls. MetaObjects adds value when AI agents touch your codebase at volume, you ship in more than one language, several apps share one data model, or your prompts are filled from that data. By language:

Language Popular alternatives What they do well What MetaObjects adds
TypeScript Prisma, Drizzle, TypeORM, Kysely Mature schema → migration loops. Excellent type-safe SQL ergonomics (Drizzle, Kysely). Mature ecosystem. Drizzle + Zod output, or Zod types alone beside Prisma. Plus four other languages from the same source, typed prompt rendering, output parsers and meta verify drift gates.
Java Hibernate / JPA, jOOQ, Spring Data JPA Industry-standard ORM (Hibernate). Type-safe SQL with code-first generation (jOOQ). Repository abstractions (Spring Data). DTO records with Jakarta validation, Spring controllers, and repository interfaces you implement with JPA, jOOQ or JDBC. It does not generate Hibernate entities. Plus four other languages from the same source.
Kotlin Exposed, Ktor + jOOQ, kotlinx.serialization Idiomatic Kotlin SQL DSL (Exposed). Solid web stack (Ktor). First-class coroutines. KotlinPoet-generated data classes + Exposed Tables from the same metamodel that drives the Java / TS / Python / C# stacks. Held to the same reading of the model as the other ports by the shared conformance corpora.
C#.NET EF Core, Dapper, NHibernate Microsoft-supported, deeply integrated with ASP.NET (EF Core). Lightweight micro-ORM speed (Dapper). Plain POCOs beside your own EF Core context, or a generated AppDbContext (Postgres column types today) + ASP.NET routes. Plus the cross-port REST API contract: your C# server agrees with the TS / Java / Kotlin / Python services on filter operators, pagination envelopes and 404 shapes.
Python SQLAlchemy, Django ORM, Pydantic + FastAPI, SQLModel Mature ORM (SQLAlchemy). Batteries-included framework (Django). Excellent validation (Pydantic). Pydantic schemas + FastAPI routers from the same metamodel that drives every other port. They sit beside SQLAlchemy or Django ORM, with the same verify gates as every other port.
Schema-only IDLs OpenAPI, Smithy, gRPC / protobuf Strong API-shape governance. Wide client/server tooling. Industry-standard. Describes the whole stack (persistence, prompt render, UI bindings and validation) from one declaration. IDLs generate clients, server stubs and model types from the API shape; MetaObjects also gives you the table, the validator, the route, the prompt template, and the migration.

Capability matrix, including AI-agent suitability

The last four rows are about how easily an AI coding agent can work with each tool. MetaObjects was designed around them from the start.

Capability MetaObjects Prisma / Drizzle JPA / EF Core / SQLAlchemy OpenAPI / Smithy
Per-language codegen 5 languages TS only single-lang clients, stubs, model types
Cross-language source of truth five ports, conformance-tested no no API shape only
Schema migrations diff + emit + apply: Postgres, SQLite, D1 (Node CLI) yes yes no
Typed validation at the wire boundary Zod / Pydantic / Jakarta / DataAnnotations runtime only annotations schema-only
Runtime metadata (drive behavior dynamically) TS / Java / Kotlin / Python no reflection no
Drift detection (build-time) verify in every port migration diff migration diffno
Typed prompt construction + render 5 ports, conformance-gated no no no
Typed parser for the LLM's response 5 ports (Zod / Pydantic / Jackson / kotlinx / STJ) no no no
LLM tool-call envelopes (template.toolcall) declared vocabulary only; nothing consumes it yet no no no
Hand-edit-preserving regen never silently overwritten; 3-way merge in TS user-edits adjacent scaffolding only codegen reruns
Machine-readable metadata (grep-friendly) YAML/JSON, named constants DSL or schema.prisma annotations / decorators YAML/JSON
Generated code is plain (no proxy/decorator magic) plain native code yes proxy/runtime magicyes
Deterministic regen (same in → same out) conformance-gatedyes yes yes
Small surface area for agent context window17 base types + extensions single-lang ORM API large framework surfacesmall spec

For a 5k-line TS app with no LLM integration, Drizzle is probably enough. Where MetaObjects earns its setup is several apps or services sharing one model, especially in more than one language or with prompts filled from that data, because that is the drift no single-codebase tool, and no single smarter agent, can see. For a multi-language team in one repo (TS + Python, TS + Java, or all five), no other system in this matrix generates and checks every app from one model.

Every contract is grep-friendly (named constants, never magic strings), and generated code is plain code in each language. Your agent (Claude Code, Cursor, GitHub Copilot, Windsurf) edits the model files and the generators write the code.

Why Claude Code in particular. MetaObjects was designed around Claude Code from the start. The model vocabulary fits in a context window, conformance is checked by the build rather than found at runtime, and an agent can read the generated code without knowing a framework's internals. A first-party MCP server exposing the spec, conformance fixtures, and codegen tools as agent-callable functions is on the roadmap.

Client-side

Universal across backends -- a Java server serving React uses the same TypeScript packages.

MetaObjects is an open-source project led by its creator, Doug Mealing, who wrote its first version in 2001 and rebuilt it in the open in 2026, with coding agents doing much of the typing. Today it has one human maintainer. The README says so, and the GitHub contributor list and the npm, PyPI, NuGet and Maven owners show it. That is the biggest risk in adopting it, and the fix is more maintainers.

What a co-maintainer gets. Credit by name in MAINTAINERS.md and in the release notes. Review and merge rights on the part you own after a run of merged pull requests, and publish rights for a port you own. A vote on the model format and on design decisions. The rules are in GOVERNANCE.md and MAINTAINERS.md.

How to start. Read CONTRIBUTING.md, then pick an open issue or open one that says which port or generator you would like to own. Run scripts/ci-local.sh --quick before a pull request: CI on pull requests runs only the leak scan today. Reviews take days, not hours.

Need help adopting it? The creator takes a few paid consulting engagements at a time, through metaobjects.com: an architecture review of whether it fits, a pilot of one slice built with your team, help writing your first generators, and support while you pilot. It is one person, so capacity is small and the open-source project comes first. Paid work funds the maintenance.

Built by one person since 2001. Open now, and looking for co-maintainers. The idea is old; the code, and the project around it, are new. It was used privately for 25 years on secure messaging, payments, online gaming and pharmaceutical regulatory systems, and one approach from it is patented in the US. It was set aside in 2022 and is now open source for the first time, rebuilt for AI coding agents.

Read the story →