8. Inheritance & polymorphism
Grammar:
abstractprefix onAggregate,extends,inheritanceUsing:header modifier · Validators:loom.extends-non-abstract,loom.extends-self,loom.extends-cycle,loom.inheritance-modifier-misplaced,loom.abstract-aggregate-behavior,loom.abstract-aggregate-contains,loom.abstract-repository,loom.polymorphic-id-ref-unsupported,loom.polymorphic-id-ref-mixed-strategy,loom.es-tph-forced-own-table,loom.tph-own-override-unsupported,loom.tph-backend-unsupported,loom.seed-abstract-aggregate,loom.tenancy-inherited-stance-conflict· Docs:../inheritance.md
One aggregate may extend another so subtypes share a field set and can be read polymorphically. An abstract aggregate declares the base; concrete aggregates extends it; the inheritanceUsing: … header modifier chooses how the hierarchy maps to tables. The whole chapter hinges on one fork: sharedTable (TPH) — one table plus a kind discriminator — vs ownTable (TPC) — one table per concrete subtype. That choice changes the emitted SQL, the polymorphic reader, and whether <Base> id references are legal; everything below shows both.
All examples are generated from one scratch system (Parties TPH + Assets TPC) once per backend pin; output is excerpted.
abstract aggregate — the base
abstract aggregate <Name> is a base that is never instantiated. It declares no repository of its own and gets no write surface — only the shared fields the subtypes inherit. (It is not entirely invisible at the edges: under TPH the shared table is named for the base and .NET/Java/Elixir map it through a base entity/schema, and Phoenix additionally mounts read-only base routes — see the polymorphic reader below.) It may not declare lifecycle behaviour (create / destroy / operation → loom.abstract-aggregate-behavior), may not contains a part (loom.abstract-aggregate-contains — the base has no reader or writer for the part's table), and no repository may target it (loom.abstract-repository). A seed row on it is rejected too (loom.seed-abstract-aggregate).
abstract aggregate Party inheritanceUsing: sharedTable {
name: string
email: string
derived display: string = name
invariant name.length > 0
}
A subtype inherits the base's FIELDS and nothing else. The enrichment pass merges the base's declared fields (and the root's id value type) into each concrete; a derived, an invariant or a function written on the base is not lowered onto the subtype — Customer._assertInvariants() is empty on every backend, and display reaches the subtype only where the host language has a real base class (.NET / Java inherit Party.Display / display(); node and Python emit no display on Customer at all). Declare invariants and derived fields on each concrete. Capabilities are the exception: a base's with <Cap> applies to every subtype — its column, filter, stamp and tenancy stance — so ignoring <baseCap> works on a subtype read (../inheritance.md).
The base materialises as a host-language type carrying the shared fields — but no persistence of its own:
// domain/party.ts — a type alias over the concretes, no class of its own
// Polymorphic Party — the tagged union of its concrete subtypes
// (discriminated by the shared table's `kind` column at the data layer).
export type Party = Customer | Supplier;
// Domain/Parties/Party.cs (TPH) — abstract class, the concretes derive from it
// Abstract TPH base — the whole hierarchy maps to one table named
// for this base; it owns the shared Id + a 'kind' discriminator.
public abstract class Party
{
public PartyId Id { get; internal set; } = default!;
public string Name { get; internal set; } = default!;
public string Email { get; internal set; } = default!;
public int Version { get; internal set; } = default!;
public string Display => this.Name;
}
// features/parties/Party.java (TPH) — a JPA SINGLE_TABLE entity
@Entity
@Table(name = "parties", schema = "parties")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "kind")
public abstract class Party {
@EmbeddedId protected PartyId id;
@Column(name = "name") protected String name;
@Column(name = "email") protected String email;
public String display() { return this.name; }
}
// (TPC: `@MappedSuperclass public abstract class Asset { … }` — no table of its own)
# app/domain/party.py — a union alias, like node
Party = Customer | Supplier
# lib/d/parties/party.ex (TPH) — a base Ecto schema over the SHARED table,
# carrying every concrete's column; used only by the polymorphic reader.
defmodule D.Parties.Party do
use Ecto.Schema
@schema_prefix "parties"
schema "parties" do
field :kind, :string
field :name, :string
field :email, :string
field :version, :integer, default: 1
field :credit_limit, :decimal
field :rating, :integer
timestamps(type: :utc_datetime)
end
end
extends — a concrete subtype
aggregate <X> extends <Base> is a concrete subtype. <Base> must be an abstract aggregate in the same context (loom.extends-non-abstract, loom.extends-self; a longer loop is loom.extends-cycle). The subtype gets an ordinary repository, routes, and DTO; the enrichment pass merges its wireShape as id → base fields (declaration order) → own fields → version, so every backend's DTO for a subtype is the same shape. A like-named own field shadows the base field (the own declaration simply wins — no override semantics).
aggregate Customer extends Party with crudish {
creditLimit: decimal
}
aggregate Supplier extends Party with crudish {
rating: int
}
The merged wire shape, on Customer, is id, name, email, creditLimit, version:
// http/customer.routes.ts — id → base (name, email) → own (creditLimit) → version
export const CustomerResponse = z.object({
id: z.string(),
name: z.string(),
email: z.string(),
creditLimit: z.number(),
version: z.number().int(),
}).openapi("CustomerResponse");
// Domain/Customers/Customer.cs — derives from the base, inherits Name/Email
public sealed class Customer : Party
{
public decimal CreditLimit { get; private set; } = default!;
// …
private void AssertInvariants() { _ = this; } // the base's invariant is NOT inherited
}
// features/customers/Customer.java — a derived SINGLE_TABLE entity
@DiscriminatorValue("Customer")
public class Customer extends Party {
@Column(name = "credit_limit") BigDecimal creditLimit;
public void _assertInvariants() { } // the base's invariant is NOT inherited
}
// features/customers/CustomerResponse.java — the merged wire shape
public record CustomerResponse(UUID id, String name, String email, double creditLimit, int version) { }
# app/domain/customer.py — a plain class; no base class, no inherited `display`
class Customer:
def __init__(self, *, id: CustomerId, name: str, email: str, credit_limit: float, version: int, ...):
...
def _assert_invariants(self) -> None:
pass
# lib/d/parties/customer.ex (TPH) — its own schema over the SAME table
schema "parties" do
field :kind, :string
field :name, :string
field :email, :string
field :credit_limit, :decimal
field :version, :integer, default: 1
timestamps(type: :utc_datetime)
end
inheritanceUsing: … — the storage strategy
inheritanceUsing: sharedTable|ownTable is a header modifier on the abstract base (and optionally each concrete). It is legal only on an abstract base or a subtype (loom.inheritance-modifier-misplaced); omitted, it defaults to sharedTable. This single keyword is the whole fork — the same .ddd declaration produces fundamentally different schemas:
abstract aggregate Party inheritanceUsing: sharedTable { … } // TPH
abstract aggregate Asset inheritanceUsing: ownTable { … } // TPC
The SQL the migration emitter derives from the shared MigrationsIR is the clearest contrast. (Postgres SQL is byte-identical across node/Hono, Python, and Java — all three consume the same sql-pg.ts renderer; .NET wraps the same SQL in an EF migrationBuilder.Sql(…) call, and Elixir maps it to an Ecto migration, both shown after.)
-- db/migrations/…_registry_initial.sql
-- sharedTable: ONE table named for the base + a `kind` discriminator.
-- Every concrete's columns live here; per-concrete columns are forced NULL.
CREATE SCHEMA IF NOT EXISTS "parties";
CREATE TABLE "parties"."parties" (
"id" UUID NOT NULL,
"kind" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"credit_limit" DECIMAL NULL, -- Customer's, NULL for a Supplier row
"rating" INTEGER NULL, -- Supplier's, NULL for a Customer row
PRIMARY KEY ("id")
);
-- ownTable: ONE table per concrete, no base table, no discriminator.
-- Each table carries base + own columns, all NOT NULL.
CREATE SCHEMA IF NOT EXISTS "assets";
CREATE TABLE "assets"."machines" (
"id" UUID NOT NULL,
"label" TEXT NOT NULL,
"serial" TEXT NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY ("id")
);
CREATE TABLE "assets"."vehicles" (
"id" UUID NOT NULL,
"label" TEXT NOT NULL,
"plate" TEXT NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY ("id")
);
The two differences that follow from the table shape:
- Nullability. TPH forces every per-concrete column nullable (a
Customerrow has norating); TPC keeps themNOT NULLbecause each table is homogeneous. - Discriminator. TPH adds a non-null
kindcolumn that every concrete repo stamps and filters on (where: record.kind == "Customer"in the Ecto reads,put_change(:kind, "Customer")on insert); TPC has none — the table name is the type.
sharedTable beats the shape: modifier
A concrete carries its own storage modifiers (shape:, persistedAs:), and under sharedTable they collide with the strategy: the concrete owns no table of its own, so there is nowhere to put a document column. The rule is TPH wins (D-TPH-BEATS-SHAPE), and the language splits the three shapes by whether they can degrade:
| concrete declares | under a sharedTable base |
|---|---|
shape: relational (default) |
the ordinary TPH concrete — columns on the shared table, containments as child tables FK'd to it |
shape: embedded |
allowed, and degrades: its root already is a queryable row, so the row joins the shared table and its containments become the same relational child tables — no jsonb column anywhere |
shape: document |
refused — loom.es-tph-forced-own-table: a whole-aggregate jsonb blob has no queryable root row to share. Declare inheritanceUsing: ownTable on the concrete. |
persistedAs: eventLog |
refused, same code and same remedy — an event stream is not a row at all |
abstract aggregate Shipment inheritanceUsing: sharedTable { reference: string }
aggregate Container extends Shipment shape: embedded { // legal; `embedded` does not apply
harbour: string
contains packages: Package[]
entity Package { sku: string qty: int }
}
-- No `containers` table and no jsonb column: the row is a `shipments` row,
-- and the containment is a child table hanging off the SHARED id.
CREATE TABLE "depot"."shipments" (
"id" UUID NOT NULL, "kind" TEXT NOT NULL, "reference" TEXT NOT NULL,
"version" INTEGER NOT NULL, "harbour" TEXT NULL, PRIMARY KEY ("id")
);
CREATE TABLE "depot"."packages" (
"id" UUID NOT NULL, "shipment_id" UUID NOT NULL,
"sku" TEXT NOT NULL, "qty" INTEGER NOT NULL, PRIMARY KEY ("id"),
FOREIGN KEY ("shipment_id") REFERENCES "depot"."shipments" ON DELETE CASCADE
);
Phase ⑨ is the authority here — MigrationsIR decides what tables exist, and it tests the inheritance strategy before the saving shape — so every backend's schema and repository emission is measured against that DDL, not against the modifier.
.NET — HasDiscriminator vs Ignore<Base>
.NET maps the hierarchy through EF Core, so the strategy shows up in the entity configuration, not just the raw SQL.
// Infrastructure/Persistence/Configurations/PartyConfiguration.cs
// EF native TPH: the base maps the shared table + discriminator; the
// concretes are derived entities sharing it — no per-concrete config.
public void Configure(EntityTypeBuilder<Party> builder)
{
builder.ToTable("parties", "parties");
builder.HasKey(x => x.Id);
builder.Property(x => x.Name).HasColumnName("name");
builder.Property(x => x.Email).HasColumnName("email");
builder.Property(x => x.Version).HasColumnName("version").IsConcurrencyToken();
builder.HasDiscriminator<string>("kind")
.HasValue<Customer>("Customer")
.HasValue<Supplier>("Supplier");
}
// AppDbContext.cs: public DbSet<Party> Parties => Set<Party>();
// Infrastructure/Persistence/AppDbContext.cs — the abstract base is
// excluded from the model; each concrete maps its own table standalone.
modelBuilder.Ignore<Asset>();
// Infrastructure/Persistence/Configurations/MachineConfiguration.cs
public void Configure(EntityTypeBuilder<Machine> builder)
{
builder.ToTable("machines", "assets");
builder.HasKey(x => x.Id);
builder.Property(x => x.Label).HasColumnName("label");
builder.Property(x => x.Serial).HasColumnName("serial");
}
Elixir — shared table vs own table
Each concrete gets its own Ecto.Schema either way; under TPH they point at the same table and self-filter on the discriminator in every read, under TPC each names its own table.
# lib/d/parties/customer.ex — shared table, discriminator field
schema "parties" do
field :kind, :string
# …
end
# lib/d/parties/customer_repository.ex — every read self-filters on `kind`
from(record in D.Parties.Customer, where: record.kind == "Customer")
# lib/d/assets/machine.ex — its own table, no discriminator
@schema_prefix "assets"
schema "machines" do
field :label, :string
field :serial, :string
field :version, :integer, default: 1
timestamps(type: :utc_datetime)
end
find all <Base> — the polymorphic reader
find all <Base> is a name for the emitted reader, not surface syntax — there is no find all production in the grammar, and a repository … for <Base> is rejected outright (loom.abstract-repository). The abstract base therefore owns no repository, but a polymorphic read home returning the union of every concrete subtype is emitted per backend as infrastructure — read-only; writes go through the per-concrete repos. Where a backend emits one, it delegates to each concrete repository and concatenates (so every aggregate loads its full tree and through its own capability filters); the exceptions are the backends whose ORM makes the shared TPH table natively polymorphic. Not every backend emits a reader — Java emits none (the base is a JPA SINGLE_TABLE entity or a @MappedSuperclass, nothing more), and .NET emits none under TPH (EF's DbSet<Party> is already polymorphic through HasDiscriminator).
// db/repositories/party-repository.ts — same shape under TPH and TPC:
// delegate to each concrete repo, union the results
export class PartyRepository {
constructor(db: Db, events: DomainEventDispatcher) {
this.customerRepo = new CustomerRepository(db, events);
this.supplierRepo = new SupplierRepository(db, events);
}
async findById(id: Ids.PartyId): Promise<Party | null> {
const customerRepoHit = await this.customerRepo.findById(id as unknown as Ids.CustomerId);
if (customerRepoHit) return customerRepoHit;
const supplierRepoHit = await this.supplierRepo.findById(id as unknown as Ids.SupplierId);
if (supplierRepoHit) return supplierRepoHit;
return null;
}
async findAll(): Promise<Party[]> {
const results = await Promise.all([this.customerRepo.all(), this.supplierRepo.all()]);
return results.flat();
}
}
// TPC only — Infrastructure/Repositories/AssetRepository.cs (findAll only, no findById)
public async Task<IReadOnlyList<Asset>> FindAllAsync(CancellationToken cancellationToken = default)
{
var result = new List<Asset>();
result.AddRange(await _machineRepo.All(cancellationToken));
result.AddRange(await _vehicleRepo.All(cancellationToken));
return result;
}
// TPH: no PartyRepository is emitted — `DbSet<Party>` reads the shared table polymorphically.
// No polymorphic reader is emitted on Java — the ONLY artifact for the base is
// the entity itself (features/parties/Party.java @Inheritance(SINGLE_TABLE) under
// TPH, features/assets/Asset.java @MappedSuperclass under TPC). There is no
// PartyRepository, no PartyService and no PartiesController: read the base
// through JPA's own polymorphic `Party` query, or read each concrete repository.
# TPH — app/db/repositories/party_repository.py: reads the shared table once,
# then dispatches each row to its concrete loader by `kind`
async def all(self) -> list[Party]:
rows = (await self._session.execute(select(PartyRow))).scalars().all()
return [await self._dispatch(row) for row in rows]
async def _dispatch(self, row: PartyRow) -> Party:
if row.kind == "Customer":
return await CustomerRepository(self._session, self._events).get_by_id(CustomerId(row.id))
elif row.kind == "Supplier":
return await SupplierRepository(self._session, self._events).get_by_id(SupplierId(row.id))
raise ValueError(f"unknown Party kind: {row.kind}")
# TPC — asset_repository.py: delegate + concatenate
async def all(self) -> list[Asset]:
out: list[Asset] = []
out.extend(await MachineRepository(self._session, self._events).all())
out.extend(await VehicleRepository(self._session, self._events).all())
return out
# TPH — lib/d/parties/party_repository.ex: one read over the base schema
def list, do: {:ok, Repo.all(D.Parties.Party)}
def find_by_id(id) when is_binary(id) do
case Repo.get(D.Parties.Party, id) do
nil -> {:error, :not_found}
record -> {:ok, record}
end
end
# lib/d/parties.ex: defdelegate list_partys(), to: D.Parties.PartyRepository, as: :list
# TPC — lib/d/assets/asset_repository.ex: reduce over the concrete repos
def list do
Enum.reduce_while([D.Assets.MachineRepository, D.Assets.VehicleRepository], {:ok, []},
fn repo, {:ok, acc} ->
case repo.list() do
{:ok, rows} -> {:cont, {:ok, acc ++ rows}}
{:error, _} = err -> {:halt, err}
end
end)
end
Only Phoenix exposes the base over HTTP. The Elixir backend mounts a read-only base controller (GET /parties, GET /parties/:id → DWeb.PartyController, with its own PartyResponse/PartyListResponse OpenAPI schemas) and delegates it from the context module (defdelegate list_partys(), to: D.Parties.PartyRepository, as: :list). node, .NET, Python and Java emit no routes and no controller for the base — their readers are internal infrastructure only.
<Base> id references — TPH only
A <Base> id cross-aggregate reference is an FK to the base. Under TPH the shared table carries a single identity, so the FK target is unambiguous and the reference is allowed:
aggregate Order with crudish { payer: Party id } // Party is sharedTable
CREATE TABLE "parties"."orders" (
"id" UUID NOT NULL,
"payer" UUID NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY ("id"),
FOREIGN KEY ("payer") REFERENCES "parties"."parties" ON DELETE RESTRICT
);
CREATE INDEX "orders_payer_idx" ON "parties"."orders" ("payer");
Under TPC identity stays per-concrete; there is no shared <Base>Id and the FK would be ambiguous across the N concrete tables — so it is rejected at IR-validate time:
abstract aggregate Asset inheritanceUsing: ownTable { label: string }
aggregate Machine extends Asset { serial: string }
aggregate Order { asset: Asset id } // ← rejected under ownTable
error loom.polymorphic-id-ref-unsupported
'Asset id' references the abstract base 'Asset', which uses inheritanceUsing: ownTable
(TPC) — there is no single table to key against, so the foreign-key target is ambiguous
across the per-concrete tables. Reference a concrete subtype's id (e.g. 'Customer id'),
or change 'Asset' to inheritanceUsing: sharedTable (TPH) to allow polymorphic references.
Backend gating & validation
Both strategies emit on all five backends (node/Hono, .NET, Phoenix, Python, Java). The one storage gate (loom.tph-backend-unsupported) fires only when a sharedTable hierarchy's context is hosted on no DB backend — there is no emission target — and suggests a DB host or inheritanceUsing: ownTable.
Tenancy stance is per concrete. A base's with tenantOwned contributes the tenant_id NOT NULL column to every subtype's row, but the stance is read off each aggregate's own capability list: nothing stamps or filters until the concrete says with tenantOwned too (a silent subtype is loom.tenancy-stance-unmarked; one that says crossTenant against a tenantOwned base is loom.tenancy-inherited-stance-conflict, an error — the column would be NOT NULL with nothing writing it).
.NET EF hosts TPH filters on the root. EF Core registers a query filter on the root entity type only, so under TPH the .NET backend hosts every filter in the hierarchy on the root config — a subtype's own one type-guarded (!(x is Car) || !((Car)x).IsDeleted) and name-prefixed — and reads every concrete from the root set (_db.Vehicles.OfType<Car>()), where EF translates the guard. A subtype filter may read a column only that subtype declares.
| Code | Fires when |
|---|---|
loom.extends-non-abstract |
extends names an aggregate that is not abstract |
loom.extends-self / loom.extends-cycle |
an aggregate extends itself / an extends loop (A extends B extends A) |
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 / destroy / operation |
loom.abstract-aggregate-contains |
an abstract base declares contains — the part would have no reader and no writer |
loom.abstract-repository |
a repository targets an abstract base |
loom.seed-abstract-aggregate |
a seed row names an abstract base (no create factory, no repository) |
loom.polymorphic-id-ref-unsupported |
a <Base> id reference to an ownTable (TPC) base |
loom.polymorphic-id-ref-mixed-strategy |
a <Base> id reference into a hierarchy with an ownTable override concrete |
loom.es-tph-forced-own-table |
a persistedAs: eventLog / shape: document / shape: embedded concrete under a TPH base must declare inheritanceUsing: ownTable explicitly (the three non-relational shapes; D-ES-TPH + D-EMBEDDED-TPH) |
loom.tph-own-override-unsupported |
a voluntary per-concrete ownTable override under a TPH base (mixed strategy) |
loom.tenancy-inherited-stance-conflict |
a subtype takes the opposite tenancy stance (crossTenant) from its tenantOwned base |
loom.tph-backend-unsupported |
a TPH hierarchy whose context no TPH-capable backend deployable hosts (all five backends are TPH-capable, so in practice: no backend host at all) |
Mixed-strategy hierarchies (a per-concrete ownTable override of a TPH base and the UNION ALL reader it would need) are deferred; contains on a TPH concrete is supported (the part table FKs the shared row). See ../inheritance.md for the full strategy comparison and the deferred patterns.