skip to content

Which schema lint rules cannot be judged from a schema diff alone?

level: seniorimportance: nice to knowfreq 22%

answer

  1. What does the rule have to read
  2. One definition, or the whole graph
  3. Broken at a distance from the edit
  4. Walk from the root operation types
  5. Compose first, then check reachability

basics

~20 s

Rules that are properties of the whole schema graph, above all reachability from the root operation types. A change can strand a type without touching that type's own definition, so a diff-scoped run never looks at it.

solid answer

~50 s

Split the rules in two. Per-definition rules — a reason on every `@deprecated`, a description on a field, a nullable item inside a list — are decidable by reading the changed definition and nothing else, which is what makes diff-scoped linting attractive on a large graph nobody is going to clean up today. Whole-schema rules are not. Reachability is computed by walking from `Query`, `Mutation` and `Subscription` through field return types, argument and input types, interface implementations and union members; anything unvisited is dead. Deleting the last field that returned `CarrierManifest` strands that type while its own definition stays byte-identical, so a run scoped to changed definitions sees nothing at all. The practical split is to scope the per-definition rules to the diff and always run the whole-schema rules against the full composed schema — it is one document, so it is cheap.

code

graphql · 13 lines
graphql
type Query {
  shipment(id: ID!): Shipment
}

type Shipment {
  id: ID!
  manifest: CarrierManifest    # delete this line...
}

type CarrierManifest {         # ...and this definition, unchanged,
  id: ID!                      # becomes unreachable from every root
  sealNumber: String!
}

go deeper

for a junior

Be ready to say what makes a type unreachable — nothing returns it and no union or interface admits it — and that the schema is still valid with such a type in it.

for a middle

Explain the traversal that decides reachability, including the edges people forget: argument and input types, interface implementations, union members. Then say why a single definition is not enough input for it.

for a senior

Show the failure mode concretely: a rule broken at a distance from the edit, where the offending definition never appears in the diff. Say which artefact you would lint on a composed graph and why the authored SDL gives false positives.

for a principal

Own where each family of rule runs in the pipeline. Fast per-definition feedback belongs with the author; graph-level checks belong after composition, and deciding that split is what keeps a schema-quality programme both fast and honest.

## Two families of rule, distinguished by what they have to read Every schema lint rule has an input footprint. Some rules need one definition: does this `@deprecated` carry a reason, does this field have a description, is this list's item type nullable. Read the definition, decide, move on. Others need the schema as a graph: is this type reachable, is this interface implemented by anything, is this input type used by any argument, do two enums differ only in the case of their values. That distinction decides whether a rule can be scoped to a change at all, and it is not the same distinction as "cheap versus expensive". Reachability over a schema is a single graph traversal over a document that is, even for a large federated graph, one artefact of a few thousand definitions — milliseconds. The reason people reach for diff scoping is not runtime; it is that a schema which predates the rules carries violations nobody is going to fix today, and scoping to the change is the obvious way to stop the new ones without drowning in the old. ## How reachability is actually computed Start from the root operation types the schema declares — the query root, and the mutation and subscription roots if present. Then walk: - from an object or interface field, to its return type and to the types of all its arguments; - from an input object field, to its type; - from an interface, to every object type declared as implementing it, and to any interfaces it itself implements; - from a union, to every member type; - from a directive definition, to the types of its arguments. Unwrap list and non-null modifiers as you go. Anything never visited is unreachable. It is still a perfectly legal part of the schema: type-system validity says nothing about reachability, the schema builds, and — for a schema built from an SDL document, where every definition in the document becomes a type — introspection still lists it among the schema's types. So it is shipped to every explorer, every generated client and every human browsing the graph, as a type that nothing can ever return. ## The case a diff-scoped run cannot see ```graphql # Before type Query { shipment(id: ID!): Shipment } type Shipment { id: ID! manifest: CarrierManifest # the only edge into CarrierManifest } type CarrierManifest { id: ID! sealNumber: String! } ``` Delete one line from `Shipment` and `CarrierManifest` becomes unreachable. The diff touches `Shipment` only. `CarrierManifest`'s definition is unchanged, character for character, so a run that lints changed definitions never evaluates a rule against it — and the rule it would have failed is not a rule about `Shipment` at all. This is the general shape: a whole-schema rule can be broken at a distance from the edit, so the set of definitions a change touches is not a safe unit of evaluation for it. The reverse also happens. A change can *fix* a whole-schema violation far from itself, so a diff-scoped run cannot tell you the total either. ## Which artefact you lint, on a composed graph On an eleven-service graph the question sharpens, because there are now two candidate artefacts. Each service's own SDL is what the authoring team edits and what their pull request diffs. The composed schema is what consumers actually see. Reachability is only meaningful on the composed schema. A type contributed by one service may be reached exclusively through a field declared by another, so a reachability rule run against a single service's SDL produces false positives by construction. There is a second trap in the same place: under a federated composition specification, a service's published schema gains an entity-lookup root field through which the router addresses any type carrying a key, so entity types are reachable there even when no ordinary field returns them. Run a naive reachability check against the author-written SDL, before those additions, and it will call live entity types dead. The workable arrangement is therefore two runs with different scopes. Per-definition rules run in each service's pipeline, scoped to the definitions the change touched, where the feedback is fast and lands on the person who wrote the line. Whole-schema rules run once against the composed schema after composition, unscoped, because there is no smaller correct input for them. ## What to say in an interview The answer that lands is not a list of rules; it is the reasoning that produces the list. Ask what the rule has to read. If the answer is "this definition", the diff is a valid scope. If the answer is "the reachable graph", or "every enum in the schema", or "every type implementing this interface", then scoping to a diff silently weakens it, and the honest fix is to run it on the whole schema rather than to pretend the diff covered it. Then add the corollary that matters operationally: an unreachable type is legal and harmless at execution time, so this is a tidiness and consumer-surface finding — whether it is safe to actually delete the stranded type is a breaking-change question with its own answer.

  • Is an unreachable type harmful, or only untidy?
    It is legal and costs nothing at execution time, so the harm is all on the consumer surface: it is published through introspection, it appears in explorers, and a typed client generator will emit a type nothing can ever return. It also usually means a deletion was left half-finished. Whether removing it is safe is a separate breaking-change question, not something the lint rule answers.
  • Name another lint rule that needs the whole schema rather than one definition.
    Any rule about relationships between definitions: an interface no type implements, an input object type no argument or input field references, a union with a single member, two enums whose values differ only in case, or a directive definition that is never applied. Each of them is decided by a fact about the rest of the schema, so the changed definitions are not a sufficient input.
  • On a composed multi-service graph, which artefact should the reachability rule read?
    The composed schema, after composition. A type contributed by one service is frequently reached only through a field declared by another, so running the rule against a single service's SDL reports false positives. Under a federated composition specification there is an extra trap: the published service schema gains an entity-lookup root field that reaches every type with a key, so entity types are reachable even without an ordinary field returning them.

Reachability is a question about the road network, not about the town. You cannot tell that a town has been cut off by inspecting the town.

saying these in an interview costs you the question

  • Assumes every lint rule reads only the definition it flags
  • Thinks an unreachable type fails type-system validation
  • Believes introspection hides types no field returns
  • Runs reachability against one service's authored SDL
  • Says a diff-scoped run is equivalent to a full run
  • Forgets that interface members and union members carry reachability

context