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:
text("id").notNull()+ a CHECK constraint matching the regex - 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 base row shape is the union of all subtype
columns. Subtype entities emit no table of their own.
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). - 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.
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 specifically
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