← Back to home

8. Inheritance & polymorphism

Grammar: abstract prefix on Aggregate, 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:

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.