Aggregate inheritance
Loom lets one aggregate extend another so subtypes share a common field set
and can be queried polymorphically. An abstract aggregate declares the shared
base; concrete aggregates extends it; the inheritanceUsing: … header modifier
picks how the hierarchy maps to tables.
Design rationale, the full strategy comparison, and the deferred patterns live in
proposals/aggregate-inheritance.md. This page is the reference for what ships today.
Declaration surface
context Parties {
// The abstract base: shared fields, no table/repo/routes of its own.
abstract aggregate Party inheritanceUsing: ownTable {
name: string
email: string
}
// Concrete subtypes inherit Party's fields and add their own.
aggregate Customer extends Party inheritanceUsing: ownTable {
creditLimit: decimal
}
aggregate Supplier extends Party inheritanceUsing: ownTable {
rating: int
}
}
abstract aggregate <Name>— a base that is never instantiated directly. It owns no table, repository, controller, or routes. It may declare fields (and derived getters); it may not declare lifecycle behaviour (create/operation) or arepository(loom.abstract-aggregate-behavior,loom.abstract-repository).aggregate <X> extends <Base>— a concrete subtype.<Base>must be anabstract aggregatein the same context (loom.extends-non-abstract,loom.extends-self).inheritanceUsing: sharedTable|ownTable— the table-mapping strategy, declared on the base (and optionally each concrete). Allowed only on an abstract base or a concrete subtype (loom.inheritance-modifier-misplaced). When omitted it defaults tosharedTable(TPH).
Field inheritance
A concrete's inherited fields are merged into its wireShape by the enrichment
pass: id, then the base fields (in declaration order), then the concrete's own
fields. An own field shadows a like-named base field (no override semantics — the
own declaration simply wins). Because the merge is backend-neutral, every backend
DTO for Customer carries the same name / email / creditLimit shape.
Storage strategies
ownTable — TPC (Table-per-Concrete) |
sharedTable — TPH (Table-per-Hierarchy) |
|
|---|---|---|
| Tables | one per concrete (customers, suppliers); no base table |
one shared table named for the base (parties) |
| Columns | base + own columns duplicated on each concrete table | base columns + every concrete's columns, per-concrete columns forced nullable |
| Discriminator | none (the table identifies the type) | a non-null kind column; each repo filters/stamps it |
| Backends | all five (node/Hono, .NET, Phoenix, Python, Java) | all five (node/Hono, .NET, Phoenix, Python, Java) |
<Base> id references |
rejected (loom.polymorphic-id-ref-unsupported — ambiguous FK across N tables) |
allowed (unambiguous single-table FK) |
The default is sharedTable, so a hierarchy with no inheritanceUsing: … is TPH.
Polymorphic reads (find all <Base>)
Both strategies provide a polymorphic read home that returns the union of all concrete subtypes. The abstract base owns no repository, so the reader is emitted per backend as infrastructure.
The contract is one native read: every row comes back as
its real subtype, a row filtered by the base's restriction or by its own
subtype's is absent, and the order (id) and the page window are global across
subtypes — never per-subtype reads concatenated. The IR fact every backend
renders is polymorphicReadFor (src/ir/enrich/read-scope.ts): under TPH the
root's restrictions plus each concrete's own, root-hosted and guarded by its
discriminator (kind <> 'Car' OR <pred>); under TPC each concrete's full scope,
unioned or merged in the global order.
On the wire the read is GET /api/<bases>?page=&pageSize= answering the paged
envelope (items, page, pageSize, total, totalPages), each item the
tagged-union shape of payloads.md: { "type": "Car", …Car's own wire fields }. Under TPH GET /api/<bases>/{id} answers one tagged row, or 404
when a restriction hides it. The route is served only where every concrete's own
list read is open — a concrete whose all carries a requires gate (including
the denyByDefault one) or whose wire masks a field keeps the reader unrouted.
Backend (platform:) |
polymorphic base reader |
|---|---|
node (Hono / Drizzle) |
a read-only <Base>Repository. TPH: one select over the shared table carrying the root's and every subtype's kind-guarded restrictions, ordered by id, paged in SQL, each row hydrated as its kind. TPC: each concrete repo's own read, merged in id order, then paged. Routed as GET /api/<bases> (+ /{id} under TPH) with a <Base>Response discriminated union. A query-time projection from <TPH base> applies the same guarded subtype restrictions |
node (Hono / MikroORM 7) |
a read-only <Base>Repository with the same findById / findPage / toWire as the Drizzle one, routed the same way. TPH is native single-table inheritance: an abstract root EntitySchema with discriminatorColumn: "kind" + discriminatorMap, and an extends schema per concrete, so the read is ONE em.find(<Root>Row, {}, { orderBy: { id: "asc" }, limit, offset }) that the ORM hydrates as each row's subclass. The read scope is declared once on the schemas as named default: true filters (below), so the reader states no restriction. TPC: each concrete repo's own read, merged in id order, then paged |
dotnet (.NET / Dapper, TPH) |
a read-only I<Base>Repository / <Base>Repository: one SELECT over the shared table under the root's and every subtype's kind-guarded restrictions (the base's generated <Base>Scope.Polymorphic), ORDER BY id LIMIT/OFFSET plus a COUNT(*) for total, and reader.GetRowParser<BaseRow>(typeof(<Concrete>Row)) switching the row parser per kind. A concrete with contained parts or X id[] references cannot hydrate from its root row, so its ids are loaded in order through its own repository (same scope). Routed as GET /api/<bases> (+ /{id}) answering the concrete's own <Concrete>Response plus the type tag. Dapper TPC shares the EF merged-in-Id-order reader |
dotnet (.NET / EF Core) |
a read-only I<Base>Repository / <Base>Repository. TPH: one query over DbSet<Base>, where EF applies the root's filters and every subtype's type-guarded ones, OrderBy(Id) + Skip/Take, each row materialised as its concrete type. TPC (the base excluded via modelBuilder.Ignore<<Base>>()): each concrete repo's own read, merged in Id order, then paged. Routed as GET /api/<bases> (+ /{id} under TPH), each item the concrete's <Concrete>Response plus type. The Dapper TPH reader is the row above |
phoenix (Ecto) |
a read-only <Base>Repository whose list concatenates each concrete repository's list (draining a paged concrete's pages) and whose find_by_id tries each concrete in turn; the context's list_<bases> / get_<base> delegate to it and take current_user \\ nil whenever a concrete read does. TPH included: the kind conjunct and every capability / inline filter live in the concrete repositories. A shape: document concrete is flattened (row.data) before the base serializer runs |
python (FastAPI / SQLAlchemy) |
native SQLAlchemy single-table inheritance: the root row class is mapped polymorphic_on its kind (polymorphic_abstract), each concrete is a mapped subclass with its polymorphic_identity, and every restriction rides the read-scope hook (app/db/read_scope.py, a do_orm_execute listener adding with_loader_criteria), each subtype's own root-hosted and kind-guarded. TPH: a read-only <Base>Repository whose reads are one select(<Base>Row) ordered by id and paged in SQL, each row hydrated as its subclass. TPC: each concrete repo's own read, merged in id order, then paged. Routed as GET /api/<bases> (+ /{id} under TPH) with a <Base>Response discriminated union. A query-time projection from <TPH base> gets the guarded subtype restrictions from the same hook |
java (Spring Boot / JPA) |
a <Bases>Controller serving GET /api/<bases> (+ /{id} under TPH). TPH: one JPQL select e from <Base> e order by e.id, paged with setFirstResult/setMaxResults. Hibernate hydrates each row as its @DiscriminatorValue subclass. Every restriction of the hierarchy is hosted on the root entity, kind-guarded (@SQLRestriction, or an auto-enabled @Filter when some read ignorings it or it reads the principal), so the query applies the base's restrictions and each row's own subtype's. The by-id read is a query, not EntityManager.find. TPC (@MappedSuperclass, which JPQL cannot select from): each concrete repository's scoped findAll(), merged in id order, then paged. Each item is the concrete's own response body plus type. Tenancy is one of those root-hosted filters (its claims per-query @ParamDef parameters), so a tenant-scoped hierarchy is routed too |
Read scope helpers (drizzle and Dapper). Neither has a global query filter or
an inheritance mapping, so the kind scope and every capability / tenancy / deny
predicate must be applied by every read and write statement. Both adapters render
the read-scope fact once per aggregate into a generated helper and every statement
calls it: drizzle's db/read-scopes.ts exports <agg>Scope({ ignoring?, write? })
(forkliftScope() for a read, forkliftScope({ ignoring: ["softDeletable"] }) for an
ignoring read, forkliftScope({ write: true }) for the kind scope alone on a
save / delete); Dapper emits an internal static class <Agg>Scope beside each
repository ("… WHERE id = @id AND " + ForkliftScope.Where, a Where…Ignoring_<cap>
constant per ignoring clause, Runtime(FilterBypass) for a retrieval whose clause
arrives at runtime). Only a capability-contributed restriction is droppable by
ignoring; the kind scope, a deny carve-out and a bare filter never are.
test/generator/hono/drizzle-read-scope-census.test.ts and
test/generator/dotnet/dapper-read-scope-census.test.ts pin that no other generated
statement spells a scope predicate.
Under TPC, identity stays per-concrete (each concrete keeps its own
strongly-typed <Concrete>Id); there is no shared <Base>Id, and a polymorphic
<Base> id reference is rejected — so the readers expose findAll only, with no
polymorphic findById target. Under TPH the shared table does carry a
single identity, so <Base> id refs and a findById on the base reader are
available there.
MikroORM: schema filters, not hand-ANDed predicates
persistence: mikroorm declares an aggregate's read scope once, on its
EntitySchema (src/generator/typescript/emit/mikroorm-schema-filters.ts,
rendered from readScopeFor), and the repositories state only their own where:
abstract aggregate Vehicle inheritanceUsing: sharedTable with tenantOwned { plate: string }
aggregate Car extends Vehicle with tenantOwned, softDeletable, softDelete {
seats: int
filter this.seats > 0
}
repository Cars for Car {
find byPlate(p: string): Car? where this.plate == p
find byPlateAll(p: string): Car? where this.plate == p ignoring softDeletable
}
// db/entities.ts — the root hosts its own AND every concrete's restrictions
export const VehicleRowSchema = new EntitySchema<VehicleRow>({
class: VehicleRow, tableName: "vehicles", abstract: true,
discriminatorColumn: "kind",
discriminatorMap: { "Car": CarRow, "Truck": TruckRow },
properties: { /* id, kind, plate, tenantId … */ },
filters: {
"tenantOwned": { name: "tenantOwned", cond: () => ({ tenantId: requireCurrentUser().tenantId }), default: true },
"softDeletable": {
name: "softDeletable", args: false, default: true,
cond: (_args, _type, _em, _options, entity) => {
const parts: object[] = [];
if (entity === "VehicleRow" || entity === "CarRow") parts.push({ $or: [{ kind: { $ne: "Car" } }, { isDeleted: false }] });
return parts.length === 0 ? {} : { $and: parts };
},
},
"loom.filter": { /* Car's bare `filter this.seats > 0`, guarded the same way */ },
},
});
export const CarRowSchema = new EntitySchema<CarRow, VehicleRow>({ class: CarRow, extends: VehicleRow, properties: { seats: { … } } });
// db/repositories/car-repository.ts — no `kind`, no capability conjunct
em.findOne(CarRow, { plate: p })
em.findOne(CarRow, { plate: p }, { filters: { "softDeletable": false } }) // ignoring softDeletable
- Named by provenance. A capability's filter is named after the capability, so
ignoring Xis{ filters: { X: false } }. A barefilterand apolicydeny have fixed names (loom.filter,loom.deny) noignoringcan name. - Root-hosted, kind-guarded. MikroORM applies a filter declared on a subclass
only to queries of that subclass, so a retired
Carwould leak fromfind(Vehicle). The root therefore hosts every concrete's restrictions, guarded by its discriminator. A concrete's predicate may name a column only that concrete has, and MikroORM rejects a condition on a property the queried entity lacks, so the guarded condition joins the filter only for a query of the root or of that concrete (theentityargument of thecond). - Fail closed. A principal predicate is a function
cond: it reads the request principal through the ambient accessor when the query runs, so a query with no principal throws instead of reading unscoped. - Writes. Filters also apply to
nativeUpdate/nativeDeleteand to the versioned save's existence probe. Those statements are scoped by id (and version) alone — the caller already loaded the row through the read scope — so they pass{ filters: false }: a soft-deleted row is still found as existing (else the save would INSERT a duplicate key) and anignoring-loaded aggregate stays savable. The UPDATE runs through the concrete's Row, so a foreign-subtype id matches nothing and raisesConcurrencyError. The discriminator is only stamped on an insert/upsert payload (a native insert does not set it). - QueryBuilder reads (query-time projection aggregations) are not filtered by
themselves: they call
await qb.applyFilters({ … })(the sameignoringmap). - Not filtered: the tenant-registry
orgPathlookup that builds the principal ({ filters: false }), andshape: document/ event-sourced aggregates, which have no queryable columns and evaluate their scope in the app.
Backend gating
TPC emission is wired on every backend. TPH emission ships on all five backends
(node/Hono, .NET, Phoenix, Python, Java) — the gate fires only when no DB
backend hosts the context, which is an IR-validate error (not a warning):
there is no implemented emission target. The error names the offending
platform(s) and suggests either hosting the context on a DB backend deployable
or switching to inheritanceUsing: ownTable (which works everywhere).
Platform-literal note (D-PHOENIX-SURFACE / D-NODE-PLATFORM): the canonical backend literals are
node(the JS runtime, ex-hono),dotnet, andphoenix(ex-phoenixLiveView). The legacy spellings still parse as aliases.
Unique keys on a TPC base — loom.unique-tpc-not-hierarchy-wide
A unique (…) key on an abstract base follows the base's table layout:
sharedTable(TPH): the hierarchy has one table, so it gets one index (parties_email_uq), and the key is unique across every subtype. The enrich pass still copies the key onto each concrete, because the concrete's write surface has to know a duplicate can happen. The Hono per-router23505→ 409 arm and the Ectounique_constraintgate on it. The Ecto constraint names the shared table's index. Without the copy, a duplicate posted through/customersanswered 500.ownTable(TPC): the base owns no table. The enrich pass copies the key onto each concrete, beside the merged fields. Each concrete table then gets its own index, named for that table. Every per-table consumer of the key sees it: the migration on all five backends, the .NET DapperDbSchemabootstrap, the Hono per-router23505→ 409 arm, and the Ectounique_constraint.
Both layouts are checked at runtime, on every SQL backend, by the
migration-evolution tier (runUniqueIndexEvolutionGate).
abstract aggregate Party inheritanceUsing: ownTable {
name: string
email: string
unique (email)
}
aggregate Customer extends Party inheritanceUsing: ownTable with crudish { creditLimit: decimal }
aggregate Supplier extends Party inheritanceUsing: ownTable with crudish { rating: int }
CREATE UNIQUE INDEX "customers_email_uq" ON "parties"."customers" ("email");
CREATE UNIQUE INDEX "suppliers_email_uq" ON "parties"."suppliers" ("email");
Each subtype rejects its own duplicates: a second Customer with the same
email answers 409. A Customer and a Supplier can still share an email,
because no index spans the two tables. When a TPC base with two or more
concretes declares a key, it raises a warning:
loom.unique-tpc-not-hierarchy-wide warning: `unique (email)` on abstract aggregate 'Party' (inheritanceUsing: ownTable) is enforced per concrete table (customers, suppliers) — each subtype rejects its own duplicates, but the same value can still exist once in EACH sibling table. If the key must be unique across the whole hierarchy, map it to one shared table: `inheritanceUsing: sharedTable`.
If the key must be unique across the whole hierarchy, use inheritanceUsing: sharedTable. A concrete's own unique (…) keys stay as they are, and a
concrete that redeclares the same column list does not get a second copy. Before
this, a TPC base's keys reached no table, so no backend enforced them.
A key declared on a TPH concrete
A unique (…) that a sharedTable concrete declares itself is an index on
the shared table, partial on the concrete's kind discriminator, and named
with it (<table>_<kind>_<cols>_uq):
abstract aggregate Machine inheritanceUsing: sharedTable { serial: string unique (serial) }
aggregate Lathe extends Machine with crudish { model: string unique (model) }
aggregate Press extends Machine with crudish { model: string }
CREATE UNIQUE INDEX "machines_serial_uq" ON "floor"."machines" ("serial");
CREATE UNIQUE INDEX "machines_lathe_model_uq" ON "floor"."machines" ("model") WHERE kind = 'Lathe';
A second Lathe with the same model answers 409 on every backend (the Ecto
changeset names the scoped index). A Press may reuse a Lathe's model.
The index is partial because the table, and any column two concretes both
declare, is shared. Lathe.model and Press.model are the same model
column, so a full index would also refuse a Press whose model matches a
Lathe's, and nobody declared that constraint. WHERE kind = 'Lathe' limits the
index to the rows the declaration covers. A key declared on the base stays a
full index, because the base declared it for the whole hierarchy. (On a
softDeletable hierarchy the predicate gains AND is_deleted = false.)
Before this, only the base's keys were read, so a concrete's own key reached no index and a duplicate answered 201. The migration-evolution tier checks both the 409 and the cross-concrete 201 on all five SQL backends.
Validation rules
| Code | Fires when |
|---|---|
loom.extends-non-abstract |
extends names an aggregate that is not abstract |
loom.extends-self |
an aggregate extends itself |
loom.inheritance-modifier-misplaced |
inheritanceUsing: … on an aggregate that is neither an abstract base nor a subtype |
loom.abstract-aggregate-behavior |
an abstract base declares create / operation lifecycle behaviour |
loom.abstract-aggregate-contains |
an abstract base declares contains — the base owns no repository and concretes do not inherit its parts, so the part's table would have no reader and no writer. Declare the containment on each concrete instead |
loom.abstract-repository |
a repository targets an abstract base |
loom.polymorphic-id-ref-unsupported |
a <Base> id reference to an ownTable (TPC) base |
loom.unique-tpc-not-hierarchy-wide |
warning — an ownTable (TPC) base with two or more concretes declares unique (…). Each concrete table enforces the key on its own, but nothing spans the sibling tables (see below) |
loom.es-tph-forced-own-table |
event-sourced / document / embedded opt-out forces ownTable on a TPH member (D-EMBEDDED-TPH) |
loom.tenancy-inherited-stance-conflict |
a subtype declares the opposite tenancy stance from the base it inherits it from (abstract … with tenantOwned + … extends … crossTenant) |
| (storage gate) | a sharedTable (TPH) hierarchy whose context has no node/Hono, .NET, Phoenix, Python, or Java host |
Capabilities and inheritance — a base's capabilities apply to every subtype
A Car is a Vehicle. When the abstract base applies a capability, every
concrete subtype gets all of it, not just its columns: the enrich pass merges
the base's fields, capability filters and bare filter declarations, stamps,
capability tags and tenancy stance onto each concrete (D-INHERITED-CAPABILITIES). Under ownTable, its unique (…) keys are merged too
(above).
abstract aggregate Vehicle with softDeletable { name: string plate: string }
aggregate Car extends Vehicle { seats: int }
aggregate Truck extends Vehicle { payload: int }
repository Trucks for Truck { }
criterion TrucksByPlate(plate: string) of Truck = this.plate == plate
// … let all = Trucks.run(TrucksByPlate(plate)) ignoring softDeletable
// node — truck-repository.ts: every Truck read hides soft-deleted rows…
.where(and(eq(schema.vehicles.kind, "Truck"), not(eq(schema.vehicles.isDeleted, true))))
// …and `ignoring softDeletable` lifts exactly that filter
.where(and(eq(schema.vehicles.plate, plate), eq(schema.vehicles.kind, "Truck")))
ignoring <baseCap>works on a subtype. The capability is the subtype's own as far as the bypass validator is concerned.- The tenancy stance is inherited too.
abstract aggregate Vehicle with tenantOwnedmakes every subtype tenant-owned (filter, stamp and stance); an unmarked subtype is fine. A subtype declaring the opposite stance (crossTenantunder atenantOwnedbase) isloom.tenancy-inherited-stance-conflict. - Repeating the base's capability on a subtype is harmless. The inherited filter is applied once, not twice.
- A subtype's
filterreads through the hierarchy.criterion Live of Car = !this.retiredworks whenretiredlives on the abstract base — the predicate is typed and lowered against the merged field set on every backend.
How .NET EF hosts TPH filters (D-EF-TPH-ROOT-READS)
EF Core registers a query filter on the root entity type of a hierarchy
only. Loom therefore hosts every TPH filter on the root config. A filter the
root declares (and every subtype inherits) is registered once, unguarded, and
an ignoring on any subtype names that root filter. A subtype's own filter is
type-guarded so it constrains that subtype's rows alone — its predicate may read
a column only that subtype has — and prefixed (Car_IsDeletedFilter) so two
subtypes carrying the same capability cannot overwrite each other's key. Every
read of a concrete starts from the root set, the one query source over which EF
translates the guard; the AppDbContext exposes no DbSet<Car> at all, so a
read naming one does not compile.
abstract aggregate Vehicle inheritanceUsing: sharedTable with tenantOwned { plate: string }
aggregate Car extends Vehicle with softDeletable, softDelete { seats: int }
repository Cars for Car {
find byPlateAll(p: string): Car? where this.plate == p ignoring softDeletable
}
// VehicleConfiguration.cs — Car's own filter, hosted on the root
builder.HasQueryFilter("Car_IsDeletedFilter", x => !(x is Car) || (!((Car)x).IsDeleted));
// AppDbContext.cs — the base's tenancy filter, per request
modelBuilder.Entity<Vehicle>().HasQueryFilter("TenantIdFilter", x => x.TenantId == _currentUser.User.TenantId);
// CarRepository.cs — every Car read starts from the root; `ignoring` names the filter
_db.Vehicles.OfType<Car>().IgnoreQueryFilters(["Car_IsDeletedFilter"]).Where(x => x.Plate == p)
WHERE v."TenantId" = @tenant AND (v.kind <> 'Car' OR NOT (v."IsDeleted")) AND v.kind = 'Car' AND v.plate = @p
TPC hierarchies keep modelBuilder.Ignore<Base>(): each concrete is a
standalone entity type with filters of its own.
Deferred (gated, not emitted)
- Mixed strategy (proposal Pattern 3) — a per-concrete
inheritanceUsing: ownTableoverride of a TPH base, and theUNION ALLfind all <Base>it would require, are rejected (loom.tph-own-override-unsupported,loom.polymorphic-id-ref-mixed-strategy). containson a TPH concrete (proposal Pattern 4) — now supported: the part emits its own table FK'd to the shared base table (TPT-via-contains, since a TPH concrete's id is the shared-table row id);loom.tph-contains-unsupportedis no longer emitted. TPC concretes are unaffected — each is a standalone table and its parts join normally.- TPH on React — N/A; the frontend consumes the concrete wire shapes, it
does not own storage. TPH ships on node/Hono (
kindcolumn), .NET (EF CoreHasDiscriminator), Phoenix (Ecto shared-table schemas self-filtering on thekindcolumn), Python, and Java; see backend gating above.