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 thevalue:. -
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@discriminateand 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
enumdiscriminator 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 asNULLand 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
AddressOccupantshape);@referenceForis 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@multitableReferencedirective is rejected; see@multitableReference, whose successor for per-route paths is@referenceFor. -
An explicit field-level
@referenceon 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 typedsortcolumn across the branches.
See also
-
@discriminatedeclares the interface or union’s discriminator column. -
@discriminatorpins each implementer to a value. -
@referencefor cross-table access on subtype-specific fields and for FK-chain access to single-table polymorphic interfaces. -
@asConnectionfor cursor pagination across either layout.