An entity is a typed, named record shape declared in metadata. It is the unit of
codegen, the unit of persistence, and the unit of API exposure. Every port turns the
same object.entity declaration into idiomatic native code — a TS interface + Zod
schema, a Java POJO, a Kotlin data class, a C# record, a Python @dataclass.
The entity is metadata-first: you don't write the class, the loader reads the declaration, the codegen emits the class, and the runtime layer reads the same declaration to drive CRUD, validation, and relationships. Pattern-derivable code (FK columns, validator chains, type-safe finders) is generated from the metadata; business logic stays hand-written.
{
"metadata.root": {
"package": "acme::blog",
"children": [
{
"object.entity": {
"name": "Author",
"children": [
{ "source.rdb": { "@table": "authors" } },
{ "field.long": { "name": "id" } },
{ "field.string": { "name": "name", "@required": true, "@maxLength": 200 } },
{ "field.string": { "name": "bio", "@maxLength": 2000 } },
{ "identity.primary": { "@fields": "id", "@generation": "increment" } }
]
}
}
]
}
}metadata:
package: acme::blog
children:
- object.entity:
name: Author
children:
- source.rdb:
table: authors
- field.long:
name: id
- field.string:
name: name
required: true
maxLength: 200
- field.string:
name: bio
maxLength: 2000
- identity.primary:
fields: id
generation: incrementYAML is desugared to canonical JSON at load time. See yaml-authoring.md for the desugar rules.
| Child node | Purpose | Required |
|---|---|---|
source.rdb |
Declares physical storage (table/view/storedProc). See source-kinds.md. | Yes for persisted entities; abstract entities may omit. |
field.<subtype> |
One field per typed column. See field-types.md. | At least one. |
relationship.composition |
An FK relationship to another entity. See relationships.md. | No. |
identity.primary |
Designates the PK field(s). | Yes for persisted entities. |
identity.secondary |
Unique secondary index. | No. |
identity.reference |
Inbound FK from this entity to another. | No. |
identity.primaryis a singleton — its name is optional. An entity may carry at most oneidentity.primary; the loader names a name-less one"primary"automatically (so{ "identity.primary": { "@fields": "id" } }is the canonical minimal form — no need to invent a name). Declaring two primaries on one entity is anERR_TOO_MANY_OCCURRENCESload error.identity.secondary/identity.referenceare not singletons and DO require an explicitname.
Common base fields (id, createdAt, updatedAt) live on an abstract entity that
concrete entities extend. The loader resolves extends: after all files load, so
the base can live in any file in the corpus.
{
"object.entity": {
"name": "BaseEntity",
"abstract": true,
"children": [
{ "field.long": { "name": "id" } },
{ "field.timestamp": { "name": "createdAt", "@required": true } }
]
}
}{
"object.entity": {
"name": "Author",
"extends": "BaseEntity",
"children": [
{ "field.string": { "name": "name", "@required": true } },
{ "identity.primary": { "@fields": "id" } }
]
}
}BaseEntity need not live in this project. The same extends: resolves a base
published by a metadata dependency exactly as it
resolves one declared locally — dependency files load first, in the same
loader.load(...). The one rule that changes across that boundary: amending a node
you don't own (rather than extending it) must say overlay: true, so its removal
upstream fails loudly instead of silently becoming a new, disconnected object — see
abstracts-and-inheritance.md.
@metaobjectsdev/codegen-ts emits one file per entity with a Drizzle table, a Zod
schema, a TS type, and (with queriesFile() + routesFile()) typed finders and
Fastify routes. namesFile() — wired by meta init — emits Author.names.ts beside
it, holding the physical table and column names as constants; the table binding
references those rather than respelling them.
// generated/acme/blog/Author.names.ts
export const AuthorNames = {
type: "object",
subType: "entity",
name: "Author",
sources: {
primary: { type: "source", subType: "rdb", kind: "table", table: "authors" },
},
fields: {
bio: { name: "bio", column: "bio" },
id: { name: "id", column: "id" },
name: { name: "name", column: "name" },
},
identities: {
pk: { type: "identity", subType: "primary", name: "pk" },
},
indexes: {},
} as const;// generated/acme/blog/Author.ts
import { pgTable, bigint, varchar } from "drizzle-orm/pg-core";
import { z } from "zod";
import { AuthorNames } from "./Author.names";
export const author = pgTable(AuthorNames.sources.primary.table, {
id: bigint(AuthorNames.fields.id.column, { mode: "number" }).primaryKey().generatedByDefaultAsIdentity(),
name: varchar(AuthorNames.fields.name.column, { length: 200 }).notNull(),
bio: varchar(AuthorNames.fields.bio.column, { length: 2000 }),
});
export const AuthorSchema = z.object({
id: z.number(),
name: z.string().max(200),
bio: z.string().max(2000).optional(),
});
export type Author = z.infer<typeof AuthorSchema>;metaobjects-maven-plugin with MetaDataGeneratorMojo writes a POJO + an OMDB-backed
descriptor. The OMDB ObjectManager reads the same metadata at runtime to drive
CRUD.
// generated/acme/blog/Author.java
package acme.blog;
public class Author {
private Long id;
private String name; // required, maxLength=200
private String bio; // optional, maxLength=2000
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getBio() { return bio; }
public void setBio(String bio) { this.bio = bio; }
}metaobjects-codegen-kotlin (KotlinEntityGenerator) emits a plain
data class (no @Serializable — entities are Jackson-compatible, not
kotlinx-serialized) and (with KotlinExposedTableGenerator) an Exposed
Table object.
// generated/acme/blog/Author.kt
package acme.blog
data class Author(
val id: Long,
val name: String,
val bio: String? = null,
)// generated/acme/blog/AuthorTable.kt
package acme.blog
import org.jetbrains.exposed.sql.Table
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)
}MetaObjects.Codegen (via dotnet meta gen) emits an EF Core entity record, a
DbSet on the generated AppDbContext, and a CREATE TABLE in the migration
emitted by the Node meta migrate (schema is Node-owned, ADR-0015).
// generated/Acme/Blog/Author.cs
namespace Acme.Blog;
public record Author
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty; // required, maxLength=200
public string? Bio { get; set; } // optional, maxLength=2000
}// generated/AppDbContext.cs (excerpt)
public partial class AppDbContext : DbContext
{
public DbSet<Author> Authors => Set<Author>();
}metaobjects.codegen emits a Python @dataclass per entity, plus a shipped
FastAPI APIRouter (router_generator, FR-008 §2.3) and a shipped
ObjectManager runtime (metaobjects.runtime.object_manager) for CRUD
against the same metadata.
# generated/acme/blog/author.py
from dataclasses import dataclass
from typing import Optional
@dataclass
class Author:
id: int
name: str # required, max_length=200
bio: Optional[str] = Nonefrom metaobjects.runtime.object_manager import ObjectManager
om = ObjectManager(root, driver) # driver wraps a DB-API 2 connection
row = om.create("Author", {"name": "Ada", "bio": None})
found = om.find_by_id("Author", row["id"])The following conformance fixtures gate this feature's behavior across ports:
Basic entity loading
fixtures/conformance/smoke-empty-metadata/— empty metadata loads without errorfixtures/conformance/loader-basic-single-entity/— singleobject.entityround-tripfixtures/conformance/subtype-entity-with-identity/—object.entityacceptsidentity.primaryfixtures/conformance/subtype-entity-missing-primary-warning/— warning when an entity has no primary identityfixtures/conformance/subtype-value-without-identity/—object.valuedoes NOT require identity
Inheritance via extends:
fixtures/conformance/extends-single-level/— basicextendsresolutionfixtures/conformance/extends-multi-level/— multi-hop ancestor chainfixtures/conformance/extends-abstract-base/—abstract: trueparents are not instantiablefixtures/conformance/extends-cross-file/— deferred resolution across filesfixtures/conformance/error-extends-nonexistent/—ERR_UNRESOLVED_SUPERwhen parent missing
Identity
fixtures/conformance/identity-primary-and-secondary/— multiple identities on one entityfixtures/conformance/identity-reference-simple/— FK viaidentity.reference
Attributes (@-prefixed)
fixtures/conformance/attr-properties-basic/— typed attr parsingfixtures/conformance/attr-default-polymorphic/—@defaultadopts the field's value typefixtures/conformance/error-attr-missing-required/—ERR_MISSING_REQUIRED_ATTRfixtures/conformance/error-attr-wrong-type/—ERR_BAD_ATTR_VALUEon type mismatchfixtures/conformance/error-attr-bad-allowed-value/— out-of-enumeration attr value rejectedfixtures/conformance/error-reserved-word-as-attr/—ERR_RESERVED_ATTRon@-prefixing a reserved word
Documentation common attrs (description, notes, title, …)
fixtures/conformance/doc-common-attrs-basic/— single-line descriptionfixtures/conformance/doc-common-attrs-multiline/— multi-line description preservedfixtures/conformance/doc-common-attrs-on-all-types/— common attrs accepted on every node kindfixtures/conformance/doc-common-attrs-stringarray-shapes/—seeAlso/aliasesarray shapes
Auto-set timestamps
fixtures/conformance/auto-set-on-create/—@autoSetOnCreatehonoredfixtures/conformance/auto-set-on-update/—@autoSetOnUpdatehonoredfixtures/conformance/auto-set-on-create-and-update/— both flags coexist
Filter / sort / data-grid layout
fixtures/conformance/attr-filter-shorthand/—@filterable: trueshorthandfixtures/conformance/attr-filter-explicit-ops/— explicit op-list formfixtures/conformance/error-attr-filter-bad-field/—@filterableon a non-field rejectedfixtures/conformance/error-attr-filter-bad-op/— unknown operator rejectedfixtures/conformance/error-attr-filter-legacy-string/— legacy string form rejectedfixtures/conformance/loader-filterable-on-indexed-no-warning/— indexed field +@filterableis silentfixtures/conformance/warning-filterable-no-index/—@filterablewithout an index emits a warningfixtures/conformance/layout-data-grid-basic/—layout.dataGridcolumns + sortfixtures/conformance/layout-data-grid-multiple-named/— multiple named grids per entityfixtures/conformance/error-data-grid-bad-sort-field/—@defaultSortFieldmust be a real field
Overlay / merge
fixtures/conformance/overlay-same-object-different-files/— samepackage+namemerge across filesfixtures/conformance/overlay-attr-last-writer-wins/— a MARKED overlay overriding an attr: last-writer-wins, and no errorfixtures/conformance/merge-conflict-unmarked-attr-redeclaration/— the same collision UNMARKED:ERR_MERGE_CONFLICTfixtures/conformance/overlay-merge-flag-explicit/—overlay: trueis explicit-merge-intent
Two files can set the same attribute to different values two ways, and they mean different things:
- Unmarked — two declarations of the same
(type, package::name)that collided without knowing about each other. The value merges last-writer-wins so the loader sees one canonical tree, and the load emitsERR_MERGE_CONFLICTso somebody can fix it. - Marked
overlay: true— the author saying "I know about the other declaration and I mean to change it." The value wins and the load is silent.
The loader already treats the flag specially — overlay: true is find-or-throw, a plain
redeclaration is create-or-find — and this makes it mean one coherent thing rather than
two. It is how an adopter retunes an inherited attribute (a library's @status, a
dependency's @maxLength) without the toolchain calling a deliberate act a defect.
Mark the whole ancestor chain, not just the leaf. Addressing a nested node means
re-declaring its ancestors, and every one of them must carry overlay: true too. Left
plain, each ancestor is a same-shape redeclaration and emits
WARN_DUPLICATE_DECLARATION — three warnings to change one leaf in a depth-4 tree.
Marked, the load is silent.
Cross-port runner coverage: TS / Java / Kotlin / C# / Python all execute these
via their respective conformance runners. See docs/CONFORMANCE.md
for the per-port pass/skip ledger.
- field-types.md — all
field.*subtypes and their per-port mappings - source-kinds.md —
source.rdb@kind(table / view / storedProc / tableFunction) - relationships.md — composition + identity.reference for FKs
- migrations-and-drift.md — how the codegen output evolves with metadata