Metadata can declare abstract instances — nodes that describe a shape but
are never instantiated themselves. Concrete instances reference an abstract
via extends: to inherit its children and attrs. This is the author-side
mechanism for reuse: no provider code, no new subtype, no codegen change.
Abstracts and extends: are the lightest possible way to share metadata
shape. When you find yourself copy-pasting the same field/validator combo
across multiple entities, an abstract is the right answer.
For the provider-side mechanism (adding brand-new subtypes or
attributes), see extending-with-providers.md.
The decision table at the bottom of this page compares both.
An abstract node is any metadata node with abstract: true. It loads
normally but the loader / codegen treat it specially:
- It contributes shape but is never emitted as a concrete entity — no table, no API endpoint, no runtime class. Codegen skips it.
- It cannot be instantiated directly — every concrete instance must
declare its own
nameand reference the abstract viaextends:. - It can be
extendedby any number of concrete instances, which all inherit its children + attrs.
abstract and extends are structural keys (bare, no @ prefix) — the
same kind as name, package, children. They live alongside the node's
type/subtype, not as @-prefixed attributes.
Common audit fields (id, createdAt, updatedAt) belong on one abstract
entity that all concrete entities extend. Change them in one place; every
table updates on the next meta gen.
# metaobjects/abstracts/meta-base-entity.yaml
metadata:
package: acme
children:
- object.entity:
name: BaseEntity
abstract: true
children:
- field.long:
name: id
- field.timestamp:
name: createdAt
"@required": true
- field.timestamp:
name: updatedAt# metaobjects/meta-author.yaml
- object.entity:
name: Author
extends: BaseEntity
children:
- source.rdb: { "@table": authors }
- field.string:
name: name
"@required": true
- identity.primary: { "@fields": id }The codegen emits authors with id, created_at, updated_at, AND
name columns. BaseEntity itself is never a table — it has no
source.rdb, no identity, and abstract: true.
This is the pattern entities.md introduces — see
entities.md § extends for
the full Author example with per-port emission.
When the same constraint set shows up on multiple entity fields — e.g., a URL-safe opaque slug ID — declare it as an abstract field once.
# metaobjects/abstracts/meta-slug-fields.yaml
metadata:
package: acme
children:
- field.string:
name: shortSlug
abstract: true
"@maxLength": 8
children:
- validator.regex:
"@pattern": "^[A-Z2-9]{8}$"
- validator.required: {}# metaobjects/meta-council.yaml
- object.entity:
name: Council
children:
- source.rdb: { "@table": councils }
- field.string:
name: id
extends: shortSlug # ← inherits maxLength + regex + required
- identity.primary: { "@fields": id }Every port's codegen propagates the constraint:
- Drizzle column:
varchar(CouncilNames.fields.id.column, { length: 8 }).primaryKey()on Postgres (text(...)on SQLite). The regex becomes a DB CHECK viameta migrateand a.regex(...)in the generated Zod schema — Drizzle carries no CHECK for it. - Zod validator:
z.string().length(8).regex(/^[A-Z2-9]{8}$/) - Migration SQL:
CHECK (length(id) = 8 AND id REGEXP '^[A-Z2-9]{8}$')
Change the alphabet or length in shortSlug once; every field that extends
it updates on the next regen. No provider code, no codegen change in MO
itself — pure authored metadata.
The loader runs in passes (see loaders.md):
- Parse every metadata file in deterministic ordinal order.
- Build tree — every node exists in memory with its declared shape.
- Resolve
extends— walk every node; if it hasextends: "<name>", look up that name in the loaded tree (current package first, then via fully-qualified ref). Merge the abstract's children + attrs into the concrete node. - Validate — the merged node is checked against its subtype's rules (required attrs, child constraints, etc.) just like a flat node would be.
This means:
- Abstracts can live in any file in the corpus. The loader sees the whole tree before resolving extends — order doesn't matter.
- Forward references are fine. A field that
extends: shortSlugcan appear in a file the loader processes before the file that declaresshortSlug. - Dotted refs to inherited members resolve in any order. A field
extends: Owner.memberworks even whenmemberreachesOwneronly throughOwner's ownextends:. Super-resolution wires the owner's chain on demand before reading its effective members, so file/enumeration order never changes the result (#188). - Multi-level chains work.
Author extends BaseEntity extends Auditableflattens to one effective shape. Each level can add children or override attrs. - Cross-package references use fully-qualified names:
extends: "shared::auditable". Same-package references can use the bare name.
If extends: references a name that doesn't resolve, the loader emits
ERR_UNRESOLVED_SUPER with the unresolved reference and the source location.
The fixture
extends-nonexistent
covers this.
The two use cases above share shape — an abstract contributes columns to its extenders, but each extender is still its own table. Table-per-hierarchy (TPH) inheritance is the persistence-bearing form: several concrete entities are variants of one thing and share a single physical table, told apart by a discriminator column.
Declare it with two attributes:
- On the base entity,
@discriminatornames the discriminator field — afield.enumwhose@valuesare the subtype tags. - On each concrete subtype,
extendsthe base and@discriminatorValuegives the tag (one of the base enum's members).
metadata:
package: acme::auth
children:
- object.entity:
name: Auth # the TPH base — owns the single table
"@discriminator": type
children:
- source.rdb: { "@table": auths }
- field.long: { name: id }
- field.enum:
name: type
"@values": ["Bridge", "Copay", "PriorAuth"]
- field.string: { name: reference, "@required": true }
- identity.primary: { "@fields": id }
- object.entity:
name: BridgeAuth # subtype → folded into `auths`
extends: Auth
"@discriminatorValue": Bridge
children:
- field.int: { name: quantity, "@required": true }
- object.entity:
name: CopayAuth
extends: Auth
"@discriminatorValue": Copay
children:
- field.decimal: { name: copayAmount, "@precision": 10, "@scale": 2 }
- object.entity:
name: PriorAuthAuth
extends: Auth
"@discriminatorValue": PriorAuth
children:
- field.string: { name: approver }Every subtype persists to the base's one table (auths). Subtype-only columns
(quantity, copay_amount, approver) are folded into that table nullable —
a row of any other subtype stores NULL there, even when the field is
@required on its subtype (a required rule can't hold across the shared table, so
it is dropped on the folded column; an enum CHECK still passes because NULL IN (...) is NULL, not false). The same applies to @default — a subtype-only
column's default is suppressed on the folded column (an INSERT by another subtype
omits the column and must stay NULL, not take a default), though the base entity's
own columns keep their defaults because every row has them. The base row shape is
the union of all subtype columns. Subtype entities emit no table of their own.
A reference onto a subtype (@references: BridgeAuth) is a foreign key into the
base's table, auths. An M:N relationship onto a subtype traverses that same table and
returns only rows of the subtype: the junction's FK can only point at the base table,
so it can hold a sibling subtype's id, and that row is not a BridgeAuth. A reference
declared on a subtype, or on an abstract level between the base and a subtype, is
folded onto the base table with its FK, the same way its column is. These three rules
are what the TypeScript toolchain (meta migrate, meta verify --db and the generated
Drizzle schema and routes) enforces, what the Kotlin port's generated Exposed schema and
queries enforce in their own mapping, and — where persistence is consumer-owned — a
contract on the repository seam you implement.
TPH is supported and conformance-gated in all five ports — codegen lowers it to each port's idiomatic single-table mapping:
| Port | Single-table persistence | Polymorphic API |
|---|---|---|
| TypeScript | one Drizzle table (subtype cols nullable) + discriminated-union type + parse<Base> dispatcher |
Fastify polymorphic + per-subtype routes; runtime-ts ObjectManager scopes by subtype |
| C# | EF Core .HasDiscriminator(e => e.Type).HasValue<Sub>(...) on the single table |
ASP.NET minimal-API polymorphic + per-subtype routes |
| Java | union DTO (subtype cols nullable) + polymorphic / per-subtype-scoped repository seam | Spring Web MVC polymorphic + per-subtype controller |
| Python | Pydantic subtype pins the discriminator to a Literal; subtype-keyed repository Protocol |
FastAPI polymorphic + per-subtype router |
| Kotlin | one Exposed Table (subtype cols .nullable()) + union data class |
Spring polymorphic + per-subtype controller |
Where a port owns persistence (TS Drizzle, C# EF Core, Kotlin Exposed) the single-table mapping is generated directly; where persistence is consumer-owned (Java Spring, Python FastAPI) codegen emits the polymorphic controller/router plus a subtype-scoped repository seam you implement (idiomatically a Spring-JPA single-table mapping / a SQLAlchemy polymorphic mapping).
The generated polymorphic surface enforces a consistent subtype contract across every port:
- Per-subtype route segment = the
@discriminatorValuelowercased:/auths/bridge,/auths/copay,/auths/priorauth(not the subtype entity name). There is intentionally no polymorphicPOST /auths— you can't create the abstract base. - Create injects the discriminator from the URL —
POST /auths/bridgestampstype = "Bridge"; the request body omits it. - Reads/updates/deletes are scoped to the subtype —
GET|PATCH|DELETE /auths/bridge/{id}on aCopayrow → 404 (a different-subtype row is invisible to a subtype-scoped operation). - M:N traversal is served inside the hierarchy — a relationship declared on
the base mounts at the base path (
GET /auths/{id}/tags: every row of the shared table is a legitimate source), and every relationship a subtype resolves — declared on it or inherited, including one declared on an abstract level between base and subtype, which has no path of its own — also mounts under the subtype's segment (GET /auths/bridge/{id}/tags). The overlap is deliberate: only the subtype segment gates the source id. The junction FK points at the shared base table and cannot tell subtypes apart, so the mount reads the source row's own discriminator first: a sibling subtype's id — which names no row of this subtype and so has no relations here — answers 200 with[], where a subtype-scoped CRUD read of the same id answers 404. All five ports serve it today. The ports reach the sibling-id gate by different mechanics: TypeScript reads the source row's own discriminator in a stage 0 before the join, Kotlin runs the same stage 0 against the shared Exposed table ((id eq :id) and (type eq <Enum>.<Value>)) before delegating to the ONE shared query helper every mount of the relation calls, C# proves ownership withdb.<Base>.OfType<Sub>().AnyAsync(...), and the two consumer-owned persistence ports compose the repository seam's own lookup as the ownership check — Java's generated controller callsfindByIdAndType(id, "<disc>")(the same seam the per-subtype GET gates on) and answers[]on a miss, Python's callsfind_by_id(subtype, id). Java's subtype-scoped traversal is a DISTINCT repository method (find<Relation>For<Disc>, e.g.findTagsForBridge) — the interface needs both it and the whole-table finder for an inherited relation, and the two signatures would otherwise be identical. For a TPH-subtype TARGET, TypeScript adds a WHERE clause to its shared Drizzle helper, Java threads atargetSubtypeparameter to the finder seam (the repository method declaresfind<Relation>(Long sourceId, String targetSubtype)), Kotlin ANDs the target's discriminator into the emitted Exposed join — the join binds the base's table, because a subtype has no table or data class of its own, so the AND is what narrows the rows back to the declared target (an abstract mid-level target has no single discriminator value and stays unfiltered) — and Python widens thefind_related_<relation>seam with atarget_subtypeparameter carrying the resolved@discriminatorValue(threaded per relation name across every mount of that name —str | Nonewith an explicitNoneat non-TPH mounts, since a subtype may shadow a base-declared relationship with a differently-TPH target). - The discriminator is immutable — an update can't move a row to another subtype (the field is stripped from update patches).
- Polymorphic reads —
GET /authsandGET /auths/{id}return the union across subtypes, each row tagged by its discriminator value.
fixtures/api-contract-conformance/tph/pins the HTTP wire shape (polymorphic + per-subtype URLs, discriminator-by-value, cross-subtype 404), run in two lanes — a reference server AND the generated routes booted over HTTP.fixtures/persistence-conformance/tph-*.yamlscenarios pin the single-table runtime semantics (create injects the discriminator, subtype-scoped find, no cross-subtype update).
Both look like they're "merging shape" but they do different things:
| Mechanism | Purpose | Wire key |
|---|---|---|
extends: |
Concrete inherits from abstract — IS-A relationship | extends: "BaseName" |
overlay: |
Re-open an existing same-named node and add/override children — same-instance amendment across files | overlay: true |
Use extends: when two distinct entities share a shape. Use overlay: when
the same entity's declaration is split across files (e.g., domain code in
one file, persistence overlay in another). See
loaders.md for the overlay merge semantics.
Nothing above changes when the base you extends or the node you overlay lives in
a metadata dependency rather than your own tree —
dependency files load before yours in the same loader.load(...), so a foreign
abstract resolves and a foreign node re-opens exactly like a local one. Neither
actually depends on that ordering: extends resolves after every source is parsed
(#188) and overlay: true is applied in a deferred pass after that (ADR-0055), so
file order is not what makes either work. One rule
is stricter across that boundary, though: say overlay: true on every amendment to
a node you do not own. Within one project the parser merges a same-(type, package::name) redeclaration whether or not it carries the flag; only the flagged
form fails loudly (ERR_OVERLAY_NO_TARGET) when the target disappears. Skip the flag
on a dependency's node and an upstream removal turns your amendment into a silent
new local object under the same name instead of a build failure — the flag is what
makes that loud. Declaring a brand-new top-level node into a dependency's package
(rather than amending one it already exports) is refused outright
(ERR_DEPENDENCY_PACKAGE_NOT_OWNED); see
metadata-dependencies.md for the full rule and what
fails when the base you extended, or the node you overlaid, changes upstream.
This is the same decision as
extending-with-providers.md § When to add a subtype vs. an attr vs. an abstract,
viewed from the metadata author's side:
- ✅ Multiple metadata instances should share a constraint set that changes together (slug shape across entities; audit columns across tables).
- ✅ The shape is expressible in existing subtypes — you don't need new attrs or new node classes.
- ✅ The author wants to opt into the shape per-instance (a Council
idfield extendsshortSlug; a non-shareable Internaliddoesn't). - ✅ Zero code changes needed in MO itself.
- ✅ You need a new configuration knob that applies wherever an existing
subtype is used (
@toolNameontemplate.output;@audit-trailonfield.*). - ✅ The attr is universally accepted — every instance of that subtype CAN set it, even if most don't.
- ✅ Codegen needs to branch on the attr's presence (e.g., emit a tool-call
wrapper if
@toolNameis set).
- ✅ The new concept needs a different runtime node class (different
factory:returning a subclass ofMetaData). - ✅ It needs different
childRulesthat can't be expressed as additional attrs (e.g.,source.rdbaccepts@table/@kind/@rolebut not arbitrary attrs). - ✅ The concept is so universal it deserves a name in the closed core vocabulary — added to one of the core providers, not consumer-local.
Need to share shape across multiple instances?
├─ YES → Is the shape expressible with existing subtypes?
│ ├─ YES → Abstract + extends ← lightest
│ └─ NO → New attrs on existing subtype (extend)
│ ├─ Configuration only? → registry.extend
│ └─ Structural divergence? → registry.register
└─ NO → Don't make it shareable. Author it directly.
The marginal cost goes up sharply at each step: abstract (free) < attr-extension (cheap) < subtype-registration (expensive). A new subtype is a permanent commitment in every port's registry; an abstract is pure data the loader resolves at parse time. Default to the lightest mechanism that fits.
Abstracts are invisible to codegen output — the loader merges them into their extenders before any generator runs. Each port emits exactly what it would emit for the merged shape, as if the author had written all the inherited children and attrs inline.
Cross-port reading:
// TypeScript: the loader's effective view
import { MetaDataLoader } from "@metaobjectsdev/metadata";
const loader = await MetaDataLoader.fromDirectory("app", "./metadata");
const author = loader.metaObject("acme::Author");
// author.fields() includes "id" + "createdAt" + "updatedAt" (from BaseEntity)
// AND "name" (declared directly). The abstract origin is transparent.// Java
MetaObject author = loader.getMetaObjectByName("acme::Author");
// Same — author.getMetaFields() returns the merged set.# Python
author = loader.meta_object("acme::Author")
# author.fields() returns the merged set.The extends: chain itself is queryable via node.superRef (TS / Python)
/ node.getSuperRef() (Java / C#) for tooling that needs to walk the
inheritance chain.
extends-single-level— one level of inheritance, basic resolutionextends-multi-level— A extends B extends C, full chain flatteningextends-cross-file— forward reference across filesextends-dotted-inherited-member-load-order— a dottedextends:to a member the owner reaches only through its ownextends:resolves regardless of file order (load-order-independent super-resolution, #188)extends-abstract-base— abstract entities aren't instantiable; codegen skips themerror-extends-nonexistent—ERR_UNRESOLVED_SUPERwhen the reference doesn't resolveenum-abstract-extends— abstracts on enum fields specificallyenum-extends-two-hop-projection— inherited enum members resolve through twoextendshops (projection → entity → shared abstract enum)
entities.md— the canonicalBaseEntityextends exampleextending-with-providers.md— the provider-side reference (when abstracts aren't enough)field-types.md— the field subtypes you can abstract overloaders.md— load order, deferred resolution, overlay semantics