GraphQL gives you two ways to spell "this field returns one of several object types": an interface whose implementers each implements it, and a union that lists member types. Graphitron supports both and projects each onto one of two layouts. Single-table polymorphism: every subtype shares one jOOQ table, and a discriminator column says which row belongs to which subtype. Multi-table polymorphism: each subtype has its own table, and the generator stitches results together with a two-stage UNION ALL fetcher. Pick by where the rows actually live; the directives follow from that choice.

Choose the layout

Single-table fits when the rows literally share a table and a column tells you the subtype. The classic shape is an entity_type discriminator with subtype-specific columns nullable on the rows that don’t use them. One SELECT against the shared table covers every subtype; row-mapping branches at the discriminator value.

Multi-table fits when subtypes are stored in different tables (heterogeneous shape, separate PKs, separate indexes). There is no shared discriminator column to pivot on; the generator emits a stage-1 narrow UNION ALL projecting (typename, pk, sort__) across the participants and a stage-2 per-typename batched lookup that hydrates each branch’s full row.

The two layouts are not mutually exclusive across a schema: a single GraphQL schema can carry single-table interfaces (e.g. Content) and multi-table interfaces (e.g. Searchable) side by side. Each interface or union picks its own layout independently of the others.

Single-table: @discriminate + @discriminator

The interface carries @table (the shared table) and @discriminate(on:) (the column that selects the subtype). Each implementer carries the same @table and @discriminator(value:) pinned to the column’s literal value:

interface Content @table(name: "content") @discriminate(on: "CONTENT_TYPE") {
    contentId: Int!    @field(name: "CONTENT_ID")
    title:     String! @field(name: "TITLE")
}

type FilmContent implements Content @table(name: "content") @discriminator(value: "FILM") {
    contentId: Int!       @field(name: "CONTENT_ID")
    title:     String!    @field(name: "TITLE")
    length:    Int        @field(name: "LENGTH")
    rating:    MpaaRating @reference(path: [{key: "content_film_id_fkey"}]) @field(name: "RATING")
}

type ShortContent implements Content @table(name: "content") @discriminator(value: "SHORT") {
    contentId:   Int!    @field(name: "CONTENT_ID")
    title:       String! @field(name: "TITLE")
    description: String  @field(name: "SHORT_DESCRIPTION")
}

A query selecting …​ on FilmContent { length } and …​ on ShortContent { description } produces one SELECT content.content_id, content.title, content.length, content.short_description, content.content_type FROM content. The row mapper inspects content_type per row and dispatches to the matching type. Subtype-specific fields are read from the unified projection as nullable; the row mapper ignores them on the wrong branch.

Subtype-only fields can still pull from another table. FilmContent.rating reaches into film via content_film_id_fkey; the generator projects it as a correlated subquery capped at one row, gated by content_type = 'FILM', so non-FILM rows read as NULL and the projection can never multiply the rows the query returns.

Multi-table: omit @discriminate

When the implementers live on different tables, drop @discriminate from the interface (or use a union, which never carries one). The interface itself stays unbound; it carries no @table, because there is no shared backing storage:

interface Searchable {
    name: String!
}

type Film implements Searchable @table(name: "film") {
    name: String! @field(name: "TITLE")
    # ...
}

type Actor implements Searchable @table(name: "actor") {
    name: String! @field(name: "FIRST_NAME")
    # ...
}

union Document = Film | Actor

The interface field name is synthetic on the GraphQL side; each implementer remaps its own meaningful column to it via @field(name:). The union form Document = Film | Actor produces the same fetcher path as the interface form; the generator’s MultiTablePolymorphicEmitter is shared between them.

At request time the rewrite emits a two-stage fetcher. Stage 1 is a narrow UNION ALL projecting (typename, pk, sort) per branch; stage 2 dispatches per typename to a batched lookup that hydrates the full row. The records carry typename so the GraphQL TypeResolver routes per row without a discriminator column.

Polymorphic child fields

A polymorphic interface or union can be the return type of a child field, not just a root query. Two shapes are common.

Auto-discovered per-branch FK back to the parent. If each implementer has exactly one FK back to the parent table, the rewrite infers the per-branch WHERE clause without any @reference on the child field:

type Address @table(name: "address") {
    addressId: Int! @field(name: "ADDRESS_ID")
    occupants: [AddressOccupant!]!
}

union AddressOccupant = Customer | Staff

Both customer.address_id and staff.address_id FK back to address.address_id, so the multi-table polymorphic emitter projects per-branch WHERE customer.address_id = parent.address_id and WHERE staff.address_id = parent.address_id in stage 1’s narrow UNION ALL. No directive is needed on Address.occupants.

Explicit per-participant path when auto-discovery is insufficient. Auto-discovery is the default; @referenceFor is the explicit per-participant surface for the cases it cannot serve. There are four:

  • Multi-FK disambiguation. A participant table with more than one FK back to the parent (auto-discovery finds two and fails). Pick one with {key:}.

  • Same-table self-FK participant. A participant backed by the parent’s own table, where no parent-to-participant FK can be auto-discovered. State the self-referencing {key:}.

  • Condition joins. A participant correlated by a non-FK predicate ({condition:}).

  • Multi-hop key chains. A participant reached through an intermediate join table (chain multiple elements).

All four are supported. @referenceFor binds one path per participant; participants you do not name keep auto-discovery. For example, film has two FKs to language, so a polymorphic child of a Language parent whose Film participant needs the original_language_id FK states it:

type Language @table(name: "language") {
    dubbedMedia: FilmMedia
        @referenceFor(type: "Film", path: [{key: "film_original_language_id_fkey"}])
}

Unlike repeated @reference (which concatenates into one chain), repeated @referenceFor applications are independent, one per participant. A bare field-level @reference on a multi-table child field is rejected; @referenceFor is the sanctioned per-participant surface.

A participant reached through a junction table chains its hops, and one correlated by a non-FK predicate states a {condition:} element. Both bind the same way, one per participant; participants you do not name keep auto-discovery:

type Film @table(name: "film") {
    cast: [FilmCastMember!]!
        # multi-hop: film -> film_actor -> actor
        @referenceFor(type: "ActorMember", path: [{key: "film_actor_film_id_fkey"}, {key: "film_actor_actor_id_fkey"}])
        # condition: a two-arg predicate between the parent film and the actor
        @referenceFor(type: "ActorViaPredicate", path: [{condition: {className: "com.example.FilmConditions", method: "filmActorsViaCondition"}}])
}

A multi-hop route bridges each intermediate table back toward the parent and value-binds the parent-adjacent hop to the parent’s key. A {condition:} route joins the parent table (aliased, bound to the parent’s key) and applies the two-arg condition method between the parent alias and the participant alias.

FK chain to a single-table interface. If the field returns a single-table polymorphic interface reached through one FK chain, use a regular @reference:

type Film @table(name: "film") {
    filmContent: Content @reference(path: [{key: "content_film_id_fkey"}])
}

content.film_id FKs back to film.film_id; the per-parent fetcher conditions on the FK and projects CONTENT_TYPE so the row mapper still routes per row.

Pagination across polymorphism

Both layouts compose with @asConnection. For multi-table polymorphism the connection emitter wraps the per-branch UNION ALL in a derived table so .seek/.limit and cursor encoding apply uniformly across the union; per-typename stage 2 additionally projects sort so cursor decoding can read it back per edge. Composite-PK participants project DSL.jsonbArray(k1, k2) as the synthetic sort column, typed as JSONB so PostgreSQL’s lexicographic ordering reproduces the multi-column ordering.

For single-table polymorphism, pagination works the same way as for non-polymorphic @table fields: the discriminator column is just another projected column, and the row mapper still branches per row. The connection’s order-by and cursor contract are unchanged.

Discriminator-value pitfalls

Single-table layouts hinge on the literal database value matching @discriminator(value:) verbatim. Common gotchas:

  • Case sensitivity. value: "FILM" does not match a stored 'film'. Match the column’s storage exactly.

  • Whitespace. Trailing spaces on CHAR(N) columns are part of the stored value. Either trim at write time or include the padding in the value:.

  • Duplicate values. Two implementers with the same value: collapse to one branch; the second is unreachable. The build does not currently reject this, so audit for it.

  • Unknown values. A row whose discriminator column carries a value no implementer claims fails to map and surfaces as a runtime error. Either backfill the column or add an implementer.

  • Mixing tables. Every implementer must declare the interface’s @table. An implementer on a different table is rejected at classify time; for cross-table layouts, omit @discriminate and use the multi-table fetcher instead.

  • Missing column. @discriminate(on:) must name a real column on the shared table. The build fails to resolve if it does not exist, listing the table’s columns as candidates.

  • Enum columns. A PostgreSQL enum discriminator works as it stands, with values bound through the column’s own type. value: names the enum literal as the database spells it ('PG-13'), not a Java constant name derived from it (PG_13), and the build rejects a value the enum does not carry rather than letting it bind as NULL and match nothing.

Constraints

  • The single-table layout requires every implementer to share the interface’s @table. Mixing tables breaks the contract.

  • The multi-table layout requires that each implementer be @table-bound; the interface or union itself is not.

  • Subtype-specific fields on a single-table interface may pull from another table via @reference; the generator projects a one-row-capped correlated subquery gated by the discriminator value.

  • Auto-discovery is the default multi-table-child idiom (the AddressOccupant shape); @referenceFor is the explicit per-participant surface for the four cases it cannot serve, all supported: multi-FK disambiguation, same-table self-FK participants, condition joins, and multi-hop chains. The legacy @multitableReference directive is rejected; see @multitableReference, whose successor for per-route paths is @referenceFor.

  • An explicit field-level @reference on a multi-table interface/union child field is rejected at build time: a single stated path applies the same hops to every participant, so it cannot express a distinct join per participant. Use @referenceFor (one application per participant) instead. A participant backed by the same table as the parent has no auto-discoverable FK; state the self-referencing key with @referenceFor.

  • A participant whose route correlates through a foreign key held on the parent’s own table (a single hop, or the first hop of a longer route) is served at single cardinality only: the relationship is single-valued (at most one participant row per parent), so a single-valued child field resolves it, and on a list or connection field it is rejected at build time as a deferred capability.

  • For composite-PK multi-table polymorphism with @asConnection, all participating tables must share the same composite-PK shape; the polymorphic emitter projects a single typed sort column across the branches.

See also

  • @discriminate declares the interface or union’s discriminator column.

  • @discriminator pins each implementer to a value.

  • @reference for cross-table access on subtype-specific fields and for FK-chain access to single-table polymorphic interfaces.

  • @asConnection for cursor pagination across either layout.