← Back to home

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
  }
}

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

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, and phoenix (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:

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")))

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)