skip to content

When does a GraphQL schema need a type for the relationship itself rather than a plain list?

level: seniorimportance: should knowfreq 46%

answer

  1. Does the pairing carry facts?
  2. Two pairings at once breaks one field
  3. Verbs need something addressable
  4. Would a root field want to return it?
  5. Every hop costs a resolver and a batch

basics

~20 s

When the pairing carries facts of its own — a role, a validity window, an approver — or an identity that mutations must target. Those facts belong to neither end, and break the moment one driver holds two assignments.

solid answer

~50 s

Model the relationship as a first-class object type when it has **attributes, identity or a lifecycle**. `Vehicle.drivers: [Driver!]!` says only that a pairing exists; the moment the business asks *in what role*, *from when until when*, or *who authorised it*, those facts describe the pair, not the driver and not the vehicle. Pushing them onto `Driver` breaks as soon as one driver is paired with two vehicles, because a single field cannot hold two answers. The tell is that it already has its own verbs — assign, amend, revoke — which need an id to target, and that callers want to read it from a root field. The cost is real: every document gains a hop, and each hop is another resolver to batch. So leave a pure many-to-many that carries nothing as two plain list fields.

code

graphql · 11 lines
graphql
type DriverAssignment {
  id: ID!
  vehicle: Vehicle!
  driver: Driver!
  role: DriverRole!
  startedAt: DateTime!
  endedAt: DateTime
  authorisedBy: Employee!
}

enum DriverRole { PRIMARY RELIEF TRAINEE }

go deeper

for a junior

Recall the difference between a list of related objects and a list of relationship objects, and that facts like a role or a start date belong to the pairing rather than to either side.

for a middle

Explain the concrete failure of hanging pair attributes on one type — one driver, two assignments, one field — and that the relationship needs an id before any mutation can target it.

for a senior

Weigh both directions: the signals that force promotion, the ceremony it imposes on every caller, the batching each new hop needs, and the additive migration path from an already-published plain list field.

for a principal

Own where the fields live relative to who owns the behaviour: a relationship type keeps one team's semantics out of another team's type, and sets the house rule for when a pairing earns a name.

## The two shapes A plain relationship is a list field on each side: ```graphql type Vehicle { id: ID! drivers: [Driver!]! } type Driver { id: ID! vehicles: [Vehicle!]! } ``` A promoted relationship puts an object type between them: ```graphql type Vehicle { id: ID! assignments: [DriverAssignment!]! } type Driver { id: ID! assignments: [DriverAssignment!]! } type DriverAssignment { id: ID! vehicle: Vehicle! driver: Driver! role: DriverRole! startedAt: DateTime! endedAt: DateTime authorisedBy: Employee! } ``` The first says a pairing exists. The second says what the pairing *is*. ## The signals that force the promotion **The pair has attributes.** `role`, `startedAt`, `endedAt`, `authorisedBy` are facts about the combination of a driver and a vehicle. They are not properties of the driver — a relief driver on a rigid this week and a primary on an artic next week has two roles at once — and not properties of the vehicle either. The failure mode is concrete: hang `role` on `Driver` and the field has no single correct value the first time someone holds two assignments, so it either lies or goes null. **The pair has a lifecycle.** It is created, amended and ended by real operations. Mutations need something to target, and "the assignment between driver 8812 and vehicle v-7731" is not a stable address — the same two can be paired twice with a gap between. An id on the relationship gives `endAssignment(input: { assignmentId: ID!, endedAt: DateTime! })` something unambiguous to act on. **Callers want to query it directly.** "Show every open assignment in depot 4, newest first" starts from the relationship, not from either end. That is a root field returning assignments, which is only possible if assignments are a type. **It needs to be filtered and ordered on its own terms.** Selecting within a plain `drivers` list can only filter by driver attributes. Once the interesting predicate is *when the pairing started*, the predicate has nowhere to live until the pairing is a type. ## The signals that say leave it alone A pure many-to-many that carries nothing — the tags on a maintenance job, the fault codes a device can emit — gains only ceremony from a join type. If the pairing has no attributes, no identity anyone refers to and no mutation of its own, `job.tags: [Tag!]!` is the honest shape and a `JobTag` type is noise, forcing every caller through a hop that reveals nothing. Be honest, too, about the direction of travel. It is easy to promote later in a way that is additive: add `assignments` beside `drivers`, deprecate `drivers`, remove it once usage is gone. It is much harder to demote, because callers will already be selecting the attributes you invented. ## The costs you should name before an interviewer does **Every document gains a hop.** `vehicle { drivers { name } }` becomes `vehicle { assignments { driver { name } } }`. That is more typing for every caller and, for the one screen that only ever wanted names, pure overhead. **More resolvers, more amplification.** Each hop is a field that must be batched per request, or a list of assignments turns into one driver lookup apiece. **Ownership gets political.** In an 11-service fleet platform, drivers were mastered by the people system and vehicles by asset management, while assignment was dispatch's business. Had `role` and `startedAt` been hung on `Driver`, a dispatch concern would have been written into a type another team owns, and every change to assignment semantics would have needed their release. A relationship type lands the fields where the behaviour lives. ## Where else the attributes could go, and why those options are weaker A paging convention that wraps each item in a per-item wrapper gives you somewhere to put metadata about the pairing, and for a read-only annotation that is a reasonable home. It is weak for anything with a lifecycle: the wrapper is a position in a result, not an addressable object, so mutations have nothing to target and no root field can return it. Computing the attribute as a field on the far type — `driver.roleFor(vehicleId: ID!): DriverRole` — works for a single lookup and collapses immediately for lists: you cannot render "all assignments for this depot" from a function you must call once per pair. ## The short version to say out loud Promote the relationship when it has facts, identity or verbs of its own; leave it as two list fields when it is nothing but the fact that two things are connected. The question to ask is not "is this many-to-many?" but "if I delete one side, is there anything left worth naming?"

  • Why not put the assignment's role and start date on the Driver type instead?
    Because they describe the pair. A driver assigned to two vehicles — relief on one, primary on another — has two roles and two start dates simultaneously, and a single field on `Driver` cannot hold both. It also writes a dispatch concern into a type another team owns, so every semantic change to assignments needs that team's release.
  • You have a published `drivers: [Driver!]!` field and now need assignment attributes. How do you get there?
    Additively: add `assignments: [DriverAssignment!]!` beside the existing field, deprecate `drivers` with a reason pointing at the replacement, watch field usage until it falls to zero, then remove it. Changing the existing field's type in place would break every document that selects driver fields under it.
  • When is a join type the wrong call?
    When the pairing carries nothing — tags on a job, fault codes a device can emit. With no attributes, no identity anyone refers to and no mutation of its own, the join type only adds a hop that every caller must traverse and every resolver must batch, in exchange for revealing nothing.
  • What operational cost does the extra hop introduce?
    Another field to batch. A list of assignments that each resolve their driver separately is the same multiplication as any relationship field, so the assignment type needs per-request batching on both of its edges. It also lengthens every document, which matters for whatever request-cost limits the server enforces.

saying these in an interview costs you the question

  • Hangs pair-specific attributes on one of the two types
  • Creates a join type for a pairing that carries nothing
  • Says any many-to-many needs a join type
  • Gives the relationship no id, then writes mutations for it
  • Ignores the extra hop's batching cost
  • Assumes promoting a published list field is non-breaking

context