skip to content

In a large GraphQL schema, how do you manage the input object types that shadow output object types?

level: seniorimportance: should knowfreq 40%

answer

  1. The pair is not meant to match
  2. Ask what the server owns
  3. One shape per operation
  4. Nullable everywhere hides the contract
  5. Ownership follows the mutation

basics

~20 s

Treat each input as the argument shape of one operation rather than a writable copy of an entity. Divergence from the output type is expected and should be deliberate; the failure mode is a shared entity-shaped input reused by every mutation.

solid answer

~50 s

The type system forbids reusing an object type on the argument side, so an input family is structural, not a style choice. The senior move is to reject the premise that the pair should match: an input legitimately omits server-owned fields such as ids, timestamps and computed values, cannot carry relationships that are themselves object types, and often has different nullability because required-ness belongs to an operation. What you manage is that every difference is **deliberate**. In practice: one input per mutation rather than one entity-shaped input reused by create, update and release - a shared input forces every field nullable and pushes required-ness out of the type system into resolver code no client can see; ownership of an input sitting with the operation's team, not the entity's; a review question of the form "does this field exist only because the output type has it?"; and schema checks in the pipeline that report input-surface changes as loudly as output ones.

code

graphql · 19 lines
graphql
# One entity-shaped input reused by three mutations: every field must be nullable
input CompartmentInput {
  lockerId: ID
  size: CompartmentSize
  holdUntilIso: String
  label: String
}

# One input per operation: required-ness moves back into the type system
input ReserveCompartmentInput {
  lockerId: ID!
  size: CompartmentSize!
  holdUntilIso: String
}

input RelabelCompartmentInput {
  compartmentId: ID!
  label: String!
}

go deeper

for a junior

Know that a schema needs a separate input type for the argument side, and that it is written by hand rather than derived from the type the graph returns.

for a middle

Explain which fields do not belong on an input - server-generated ids, timestamps, computed values - and why an object-typed relationship becomes a plain key field on the way in.

for a senior

Demonstrate judgement: argue that divergence is correct and only accidental drift is a defect, and show how one input per operation restores required-ness to the type system instead of resolver code.

for a principal

Own the input surface as a contract in its own right - who owns each input, how its changes are reviewed and checked, and why a wide mirrored input commits the service to work it may not be able to serve.

## Why the parallel family exists at all A GraphQL object type may never be used where an input type is required, and one name defines one type, so an operation that accepts a compartment cannot accept `Compartment`. It accepts a separately declared input object type. Every schema of any size therefore carries two families, and the duplication is a consequence of the type system rather than a decision anyone made. That framing matters, because the naive reaction is to make the duplication invisible - generate `CompartmentInput` from `Compartment`, keep them field-for-field identical, and treat any difference as drift to be repaired. That is the wrong goal, and an interviewer asking this question is usually checking whether you know why. ## Divergence is correct; accidental drift is the bug An input should differ from the output type it shadows, in specific ways: - **Server-owned fields have no input.** `id`, `createdAt`, `lastOpenedAt`, `occupancyRatio` - the server decides these. Putting them on the input either invites a caller to lie or forces you to ignore what they send. - **Relationships cannot cross over.** `Compartment.currentParcel: Parcel` is an object type. On the input side the only thing that can appear is a key - `parcelId: ID` - so any relationship in the output shape becomes a different field with a different type on the way in. - **Nullability belongs to the operation.** `Compartment.size` is non-null on the way out because every compartment has one. Whether `size` must be supplied depends entirely on whether you are reserving a compartment or relabelling one. - **Permissions narrow the input.** A field readable by everyone may be writable by two roles, and the input is where that shows up as a field that simply does not exist for most callers to send. So the maintenance question is not "are they the same?" but "is every difference one we chose?" ## Shape inputs around the operation, not the entity The single most common failure in a large schema is one entity-shaped input reused everywhere: ```graphql input CompartmentInput { lockerId: ID size: CompartmentSize holdUntilIso: String parcelId: ID label: String } type Mutation { reserveCompartment(input: CompartmentInput!): Compartment! releaseCompartment(input: CompartmentInput!): Compartment! relabelCompartment(input: CompartmentInput!): Compartment! } ``` Every field has to be nullable, because each of the three operations needs a different subset. The schema now says almost nothing: a caller cannot tell from the type what `releaseCompartment` actually requires, a typed client generator produces one all-optional shape for three different calls, and the real contract lives in resolver code that no consumer can read. Splitting into `ReserveCompartmentInput`, `ReleaseCompartmentInput` and `RelabelCompartmentInput` puts required-ness back in the type system, where validation enforces it before execution and every explorer and generated client shows it. ## Controls that actually work at graph scale Across an 11-service graph, four things keep the input surface honest: 1. **Ownership follows the operation.** The team that owns `reserveCompartment` owns its input. When an input is owned by whoever owns the entity, it accumulates fields for operations that team does not run. 2. **Never generate inputs from persistence models.** An input generated from a table gets the table's columns, including the ones the server owns, and its shape then changes whenever storage does. 3. **Schema checks cover the input surface.** Adding a non-null field with no default to an input is as breaking as removing an output field, and pipelines that only diff the output side miss it. 4. **A one-line review question.** "Does this input field exist only because the output type has it?" catches most copied fields before they ship. ## The failure that teaches this A worked example worth carrying: a locker graph whose `CompartmentFilterInput` was grown by mirroring the output type, field by field, until callers could filter on fields the storage layer had no way to narrow by. Nothing failed in review, nothing failed in staging against 1,400 lockers, and the same document turned into a query that times out only in production against 7,412 lockers across 63 sites. The input had been treated as a reflection of the output type rather than as a contract about what the service can actually serve - and an input object, unlike a resolver, is a promise made to every caller at once. ## What to say in the room Start by refusing the symmetry: the pair is not supposed to match. Then give the mechanism - one input per operation, non-null where the operation truly requires it - and finish with the organizational controls, because at scale the input surface degrades through ownership, not through ignorance.

  • What is wrong with generating input object types automatically from the output types?
    It reproduces exactly the fields that should not be there - server-generated ids, timestamps, computed values - and turns object-typed relationships into something the type system rejects. It also freezes the input surface to the output model, so a rename on the read side silently reshapes what callers must send.
  • Why does reusing one input across create and update force every field to be nullable?
    Because required-ness differs per operation: create needs the identifying fields, update needs the target plus whatever is changing. A single type can only state one set of non-null fields, so the union of the operations' needs collapses to all-optional and the real rule moves into resolver code.
  • Should schema checks treat input changes and output changes the same way?
    They are both contract changes, but the direction of breakage flips. Adding a non-null input field with no default breaks existing senders immediately, whereas adding an output field is safe. A pipeline that only diffs the output surface will pass a change that no deployed caller can satisfy.

saying these in an interview costs you the question

  • Insists the input must mirror the output type field for field
  • Generates input types from database tables or persistence models
  • Reuses one entity-shaped input across every mutation
  • Puts server-generated ids and timestamps on the input
  • Leaves every input field nullable and validates in resolvers
  • Treats input-surface changes as non-breaking by default

context