When would you model an abstract GraphQL field as an interface rather than a union, and what does each cost later?
answer
- Ask what the client renders
- A shared contract, or an open set
- One shape grows types cheaply
- The other grows members dangerously
- Nothing fails; the response is thin
basics
~20 sUse an interface when the alternatives share fields a client should read without knowing which one it got; use a union when they share nothing but the field that returns them. The later cost is asymmetric: interfaces get expensive to widen, unions get risky to extend.
solid answer
~50 sThe decision test is whether a real shared contract exists. If clients want the same fields regardless of which concrete type arrived — an identifier, a title, a registration date across trials, sites and protocols — that is an interface, and it pays for itself immediately because one selection covers every implementer. If the alternatives genuinely have nothing in common, a union is honest and an interface is a lie you will pay for: fields that are meaningless for half the implementers, loosened nullability to make them fit. The costs diverge over time. **Adding an implementer to an interface is cheap** — existing documents keep working, because they only ever selected interface fields. **Adding a member to a union is not** — deployed documents stay valid but say nothing about the new type, so results arrive with no fields. Conversely, adding a field to an interface means editing every implementer.
code
graphql · 26 linesinterface RegistryRecord {
id: ID!
displayTitle: String!
registeredOn: String!
}
type Trial implements RegistryRecord {
id: ID!
displayTitle: String!
registeredOn: String!
phase: TrialPhase!
}
type Amendment implements RegistryRecord {
id: ID!
displayTitle: String!
registeredOn: String!
supersedes: ID!
}
union SearchResult = Trial | Site | Investigator | Publication
type Query {
timeline(limit: Int = 20): [RegistryRecord!]!
search(term: String!): [SearchResult!]!
}go deeper
Learn the decision rule rather than the tradeoffs: shared fields a client can read without knowing the concrete type mean an interface, and no shared fields mean a union. Being able to justify one choice is enough here.
Be ready to explain the client-side consequence of each shape and to spot the lowest-common-denominator smell — an interface field made nullable purely so a reluctant implementer can satisfy it.
Demonstrate the evolution asymmetry with a concrete example: new implementers are cheap on an interface, new union members quietly return empty results to deployed documents, and widening an interface costs one edit per implementer.
Own this as an extension-point strategy. Decide which abstract fields other teams may grow without coordinating with you, keep interfaces narrow enough to stay evolvable, and make union changes part of a release plan rather than a schema pull request.
## The decision is about clients, not about the data The question to ask is not "are these things related?" — almost anything can be argued into a taxonomy — but "is there a set of fields a client should be able to read *without knowing which concrete type it got*?" If yes, that set is your interface and the interface earns its keep on day one. If no, a union states the truth: this field may return several unrelated object types, and the client will have to handle each. In a clinical-trial registry graph both shapes appear side by side and neither is a compromise. A timeline of registry activity is genuinely uniform — every entry has an identifier, a display title, and a registration timestamp, whether it is a trial, a site or a protocol amendment — so `RegistryRecord` as an interface is exactly right. Global search over the same graph is not uniform at all: a trial, a site and an investigator have no field a UI would render the same way, so `SearchResult` as a union is exactly right. A team that forces search through the timeline's interface ends up with a `title` field that means a trial's official title, a facility name, and a person's surname, which is three different things wearing one name. ## The lowest-common-denominator trap The failure mode of over-using interfaces is subtle because it looks like good modelling. You want three of five implementers to expose `enrollmentTarget`, so you put it on the interface; the other two have to declare it too, so it becomes nullable, and now every client handles a null that means "not applicable here" rather than "unknown". Repeat a few times and the interface is a bag of optional fields — it still compiles, still validates, and has stopped being a contract, because nothing about it is actually promised. When you catch yourself weakening a field so an implementer can satisfy it, that field does not belong on the interface. The answer when only part of the set shares something is not a wider interface but a second, narrower one. Interfaces compose: a type may implement several, and since the October 2021 specification edition an interface may refine another. With 23 implementers and a field that is meaningful on 3 of them, the honest move is an interface those 3 implement — or simply the field on the 3 object types, if no client wants to select across them abstractly. ## The evolution asymmetry, which is the senior half of the answer The two shapes fail in opposite directions, and knowing which is which is what separates a schema-design answer from a syntax answer. **Adding an implementer to an interface is close to free.** Deployed documents only ever selected fields the interface declares, and the new type declares them all — the schema requires it. Existing clients render the new type with no code change and no redeploy, which is the whole reason to reach for an interface at an extension point other teams will grow. **Adding a member to a union is a client-visible change even though nothing breaks.** Existing documents stay valid — no validation error, no failed request — but they contain no selection for the type that just joined, so results for it come back empty. That is a genuinely nasty class of incident because every signal says success. A 4-person platform team adding `Publication` to `SearchResult` shipped it on a Tuesday, saw no error-rate change and no failed validations, and learned about it from a support ticket: the registry search page was rendering blank rows among the real hits, and 127 checked-in client documents across three apps all needed a new branch before those rows would show anything. Nothing was down; the response simply arrived half-empty. **Widening an interface is the expensive direction.** One new field on an interface is one edit per implementer before the schema builds again, plus a decision about nullability for each. Interfaces are cheap to *extend with types* and expensive to *extend with fields*; unions are cheap to define and expensive to *extend with members*. **Shrinking either is breaking, no matter what.** Removing a member from a union or an implementer from an interface can strand a document that references the type, and removing an interface field breaks every client selecting it. Neither has a graceful path other than deprecation and a client migration. ## Practical operating advice - Treat a union member addition as a coordinated release, not a schema-only change: land the client branches first, then the member. The same discipline is unnecessary for a new interface implementer. - Keep interfaces small enough that widening one is a reviewable change. If nobody can say who the implementers are without grepping, the interface is already too broad to evolve. - Do not create an interface with one implementer "for the future". It costs clients an extra step to reach concrete fields and buys nothing until the second implementer exists. The well-known exception is the conventional identifier-only interface used for global object identification, which is a lookup convention rather than a shared-behaviour contract. - When you cannot decide, ask what the client's rendering code looks like. One component that draws every case is an interface; a switch with a branch per type is a union.
- How do you add a member to a union without stranding deployed clients?Treat it as a coordinated release rather than a schema-only change: ship the client branches that handle the new type first, then add the member. Where clients cannot all be updated — mobile builds in the wild — assume some will never handle it, and make the surface degrade sensibly, because those documents will keep returning results with nothing selected.
- An interface has 23 implementers and you need a new field that is meaningful on 3 of them. What do you do?Do not put it on the interface. Either declare it on those three object types directly, or introduce a narrower interface that only they implement — types may implement several interfaces, so this composes cleanly. Adding it to the shared interface forces 23 edits and a nullable field that means 'not applicable' on twenty of them, which quietly dissolves the contract.
- Is an interface with exactly one implementer ever justified?Rarely, and not on the strength of a future second implementer. It costs clients an extra step to reach concrete fields and gives them nothing in return until that second type exists. The recognised exception is the conventional identifier-only interface used for global object identification, which exists for lookup rather than to express shared behaviour.
- Which is worse to remove: a union member or an interface field?Both are breaking, in different ways. Removing an interface field breaks every client that selects it, across every implementer, which is broad but loud — documents fail validation. Removing a union member strands only the documents that mention that type, which is narrower but easy to miss until a deploy. Either way the path is deprecate, measure usage, then remove.
saying these in an interview costs you the question
- Picks an interface because the types feel related
- Weakens a field's nullability so an implementer fits
- Thinks adding a union member breaks validation
- Assumes clients render new union members automatically
- Treats widening an interface as a one-line change
- Creates a one-implementer interface for future flexibility