skip to content

questions

4

Why expose a related object field in a GraphQL schema instead of a bare foreign-key id?

level: juniorimportance: must knowfreq 74%

answer

  1. Who does the join, server or caller?
  2. Unselected fields cost nothing
  3. The key means something: say what
  4. Ids belong on the write side
  5. Object field for edges, scalar for opaque handles

basics

~20 s

A field typed as the related object lets one document walk the edge in a single request. A bare id forces the caller into a second request and makes every client hard-code how the two types join.

solid answer

~50 s

GraphQL's value is that the response is a graph, so a relationship should be declared as a field whose **type is the related object**: `Trip.vehicle: Vehicle!` rather than `Trip.vehicleId: ID!`. The object field lets one document select exactly as much of the neighbour as the screen needs, and it puts the join in the schema, where it is described once, instead of in every caller. It costs nothing when unselected, because a field's resolver runs only for fields the document actually selects, so callers that want only the identifier select `vehicle { id }`. A bare id is still the right shape in two cases: the referenced record lives in a system this schema cannot resolve, so the id is an opaque handle rather than an edge; or the field is a write-side input, where mutations take ids, not object types. Exposing both an object field and its redundant `...Id` twin is usually clutter.

code

graphql · 17 lines
graphql
type Trip {
  id: ID!
  startedAt: DateTime!
  distanceKm: Float!

  # table-mirroring: the caller must resolve this itself
  vehicleId: ID!

  # graph shape: the caller selects as much as it needs
  vehicle: Vehicle!
}

type Vehicle {
  id: ID!
  plate: String!
  odometerKm: Float!
}

go deeper

for a junior

Be ready to say what a relationship looks like in SDL and why vehicle: Vehicle! beats vehicleId: ID! for a reading client. Recall that a field is resolved only when it is selected.

for a middle

Explain the mechanics behind the preference: additive to add, resolved only when selected, and the batching that keeps a list of parents from firing one lookup per row. Name the two cases where a bare id is still correct.

for a senior

Show the judgement: nullability on an edge that can dangle, resisting the generated-from-tables shape, and deciding whether a redundant id twin earns its place given how callers actually read the schema.

for a principal

Own the rule across teams: a house convention that relationships are edges and ids are for writes and for genuinely external handles, plus the review that stops a table-mirroring schema before hundreds of key fields become contract.

## The two shapes Every relationship in a schema can be spelled one of two ways. Given a fleet telematics graph where a `Trip` belongs to a `Vehicle`, the table-mirroring shape is: ```graphql type Trip { id: ID! vehicleId: ID! startedAt: DateTime! } ``` and the graph shape is: ```graphql type Trip { id: ID! vehicle: Vehicle! startedAt: DateTime! } ``` Both are legal SDL. The second is what GraphQL exists for, and the reasoning behind that preference is the substance of this question. ## What the object field buys **One round trip instead of two.** With `vehicleId`, a caller rendering a trip list with plate numbers must read 40 ids, then issue a second operation to turn ids into vehicles, then stitch the two results together in client code. With `vehicle`, the same screen is one document: ```graphql query TripsForDepot($depot: ID!) { depot(id: $depot) { recentTrips { startedAt vehicle { plate odometerKm } } } } ``` **The join is described once.** A foreign key is only meaningful to someone who knows that a `vehicleId` is to be looked up through the vehicle-by-id field, that it is never null, and that the lookup can fail for a decommissioned vehicle. That knowledge is untyped and lives in prose or in every client's head. An object field encodes it in the type system, where introspection, editors and validation can all see it. **The caller chooses the depth.** A dispatch console needs `vehicle { plate depot { name } }`; a billing export needs only `vehicle { id }`. A single object field serves both without the server guessing which fields to embed. ## What it costs — usually nothing The most common objection is that an object field makes every response heavier or triggers a lookup even when nobody wants it. It does not. Execution resolves **only the fields a document selects**, so for a caller that never mentions `vehicle`, the field might as well not exist. Adding an object field to an existing type is an additive change, safe for existing callers. The real cost is on the server, and it is the classic one: a list of 40 trips that each resolve their vehicle separately becomes 40 lookups behind one document. That is a solved problem — per-request batching collapses the lookups — but it is the reason an object field is a design decision rather than a free win, and an interviewer will often follow up straight into it. Nullability is the second cost. `vehicle: Vehicle!` promises the neighbour always resolves; if the relationship can dangle — a trip whose vehicle was purged — the honest declaration is `vehicle: Vehicle`, nullable, because a non-null field that cannot be produced destroys more of the response than a null would. ## When a bare id is right There are two honest cases, and a candidate who names them is stronger than one who says "always use the object". First, **a reference this schema cannot resolve**. A telemetry device stamped with a manufacturer's serial that no service in the estate can expand is not an edge; it is a scalar fact about the vehicle. Declaring `deviceSerial: String!` is truthful. Inventing a `Device` type whose only field is the id it was built from is worse: it implies a neighbour that does not exist. Second, **inputs**. Mutations accept ids, not object types — the type system forbids an input field typed as an object type at all — so `assignVehicle(input: { tripId: ID!, vehicleId: ID! })` is the normal write shape even in a schema whose read side is pure edges. ## Should you expose both? Usually not. `vehicle { id }` already yields the identifier, so a sibling `vehicleId` is a second name for the same fact and a second field to keep in step. Two arguments are sometimes made for keeping it: a caller wanting the id without paying for the neighbour's resolver, and a normalized client cache that keys on ids. Both are usually satisfied by selecting `id` through the edge. Add the redundant field when a measured caller needs it, not by default — the schema is easier to shrink before anyone depends on it than after. ## The underlying principle A schema generated one-to-one from tables produces exactly the foreign-key shape, and the tell is `somethingId` fields everywhere. The graph is supposed to be the domain, not the storage layout: the storage keeps a key column because that is how rows point at rows, while the schema should say what the key *means* — a trip has a vehicle.

  • Doesn't adding a related object field slow down callers that only want the identifier?
    No. A resolver runs only for a field a document selects, so a caller that never mentions the edge pays nothing for it, and one that wants only the identifier selects `vehicle { id }` — the parent usually already holds that key, so a well-written resolver returns it without a lookup at all.
  • You resolve a list of 40 trips and each one resolves its vehicle separately. What do you do?
    Batch the lookups per request: the vehicle resolver collects the keys the execution asks for, dispatches one multi-key load, and hands each field its row. It is the standard answer to a relationship field multiplying backend calls, and it is why an object field is safe to expose in the first place.
  • Should the edge be `vehicle: Vehicle!` or `vehicle: Vehicle`?
    Non-null only if the relationship genuinely cannot dangle. If a trip can outlive its vehicle record, or the vehicle is fetched from a service that can be unavailable, declare it nullable — a non-null field that fails to produce a value removes more of the response than the null it was trying to avoid.

A foreign key is a page number scribbled in the margin; an object field is the link itself. Both get you to the other page, but only one lets the reader follow it without knowing how the book is indexed.

saying these in an interview costs you the question

  • Says every object field costs a lookup even when unselected
  • Mirrors table columns and ships `somethingId` on every type
  • Claims an object field is invalid because ids are the primary key
  • Exposes both `vehicle` and `vehicleId` with no reason
  • Uses an object type as a mutation input field
  • Declares an edge non-null when the reference can dangle

context

open as a page

Why is an unbounded list field on a GraphQL object type hard to fix later?

level: middleimportance: must knowfreq 63%

basics

~20 s

The field's return type is part of the contract. Adding arguments to it is safe, but replacing a plain list with a paged wrapper type changes what every existing document receives, so the fix is a breaking change to every caller.

open as a page

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

level: seniorimportance: should knowfreq 46%

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.

open as a page

Are circular references between two object types legal in a GraphQL schema?

level: juniorimportance: nice to knowfreq 27%

basics

~20 s

Yes. Object types refer to each other by name, so a schema is a graph and cycles — including a type referring to itself — are ordinary. What is finite is the document a caller writes, not the type graph.

open as a page