The Schema Must Have a Door
The Schema Must Have a Door
You can define fifty object types and still ship an API nobody can call. A schema needs an entry point: a type whose fields become the legal first words of every operation. The specification is blunt about it.
The query root operation type must be provided and must be an Object type.
Two details hide in that sentence. First, the query root is required while the mutation and subscription roots are optional; a service that only reads still needs type Query, but a service with no writes may omit Mutation entirely. Second, each root that is present must be an Object type, and all three roots must be distinct types - the schema cannot use one object as both query and mutation root, because their execution rules differ.
type Query {
product(id: ID!): Product
me: User
}
type Product {
id: ID!
title: String!
}
That three-line schema is complete and executable. Everything else in the file exists to be reachable from these fields: Product is queryable because Query.product returns it, and a type no root can ever reach is dead weight that only introspection will show.
The reverse case catches people out. If a document contains a type named Query and no schema block, SDL presumes that type is the query root; if it contains a type named Mutation but no schema block, the presumption silently applies there too, which can wire a domain type into the wrong role. Chapter three shows the fix.
One more property of the root: introspection. The meta-fields __schema and __type are available on the query root implicitly and do not appear in its declared field list, which is why a client can always ask the server what it can query, see introspection mechanics.
Designing Top-Level Entry Points
Designing Top-Level Entry Points
The fields of Query are the API surface. Everything a client can start with is on that one type, so its design decides how discoverable and how safe the API is.
type Query {
product(id: ID!): Product
products(filter: ProductFilter, first: Int = 20, after: String): [Product!]!
productsInStock(filter: ProductFilter): [Product!]!
me: User
node(id: ID!): Node
}
Read the list as a set of decisions rather than a list of names:
- Nouns, not actions.
productandproductsare reads; creating or changing things belongs on the mutation root, covered in mutation operations. AcreateProductfield onQuerylooks harmless until something caches that GET. - Nullability encodes the contract.
product(id: ID!)returnsProductbecause a missing id is a normal outcome, whileme: Userreturns a non-null only if your service really cannot represent an anonymous caller. Lists that clients always render are declared[Product!]!so a null item never blanks the screen; see enums, lists, and non-null. - Arguments do the filtering. One well-argumented field beats three near-duplicates:
filter, pagination arguments, and sorting cover the space thatproductsInStockand friends would otherwise fork into. Argument shape and pagination conventions live in field arguments design and Relay-style pagination. - A generic
node(id: ID!)field gives clients one way to fetch any object by id, which is how Relay clients navigate without knowing types up front.
At execution, each requested top-level field invokes its own resolver with an unusual first argument: the parent value for a root field is effectively nothing (the root object), so root resolvers start from args, context, and info rather than from a parent record. After that the mechanics are identical to any other field, see resolver fundamentals.
Interview angle: "How would you expose a search endpoint?" expects the whole design: the field name, its arguments, why the return type is a list of non-null items, and where the pagination cursor comes from.
Renaming the Roots
Renaming the Roots
By default SDL maps the roots by name: a type called Query is the query root, Mutation the mutation root, Subscription the subscription root. When the names do not match, an explicit schema block does the mapping.
schema {
query: RootQuery
mutation: RootMutation
subscription: RootSubscription
}
type RootQuery {
product(id: ID!): Product
}
type RootMutation {
addToCart(sku: String!, qty: Int!): Cart
}
Why would you want non-default names? Two situations come up repeatedly.
Composition. When several schemas are stitched or federated into one graph, each contributor needs its own root to avoid collisions. A schema block per subgraph (or the composition tooling's equivalent) keeps one team's Query from overwriting another's; see schema composition guidelines and federation architecture.
Domain collisions. If your domain already has a meaningful type called Mutation or Query, the default presumption wires the wrong type into the root role. The specification's own example makes this explicit: a schema containing a type named Mutation but no schema block would treat it as the mutation root, so the block is required to say "no, that type is just data."
Three rules bound what a schema block may contain:
- At most one
schemadefinition per document. - Every root it names must be an Object type.
- Roots must be distinct from one another.
The rules exist because execution depends on which type is which: query root fields may run in parallel while mutation root fields run serially, and subscription root fields drive event streams. If one type could serve two roles, those guarantees would contradict each other.
Interview angle: "What must every schema have?" Answer: a query root, provided and an Object type; mutation and subscription roots are optional, and if present they must be different types.
Practice Problems
Design the Query type for an inventory service. Requirements: fetch a single part by id (a part may not exist), list parts with a free-text search term and a maximum result count defaulting to 25, list parts below a given stock threshold, and fetch the signed-in operator. Types Part and Operator already exist. Avoid near-duplicate fields by carrying variation in arguments, and state the nullability of every return type.
Example:
Input: Four read needs: byId, search, low stock, current operator
Output: type Query { part(id: ID!): Part searchParts(term: String!, limit: Int = 25): [Part!]! lowStock(threshold: Int!): [Part!]! me: Operator }
The single-item lookup is nullable because a missing id is normal; lists are [Part!]! so one bad row cannot blank the response; search and threshold are arguments on list fields rather than four separate list fields.
Optimal Solution — O(f) fields validated where f is root size time, O(f) space
Group reads by shape: one nullable lookup for a single record, argument-driven list fields for collections, and a session-dependent field for the caller. Nullability states which outcomes are normal.
type Query {
part(id: ID!): Part
searchParts(term: String!, limit: Int = 25, offset: Int = 0): [Part!]!
lowStock(threshold: Int!): [Part!]!
me: Operator
} Edge Cases:
- part(id) must be nullable: returning null for an unknown id is a normal outcome, not an error
- An empty search result is an empty list, which satisfies [Part!]!
Quiz
1. What does the specification require of a valid schema?
2. A schema defines type Query and type Mutation but no schema block. What does SDL assume?
3. What does a resolver for a top-level Query field receive as its first argument?
4. Why do teams rename root types with a schema block?
Flashcards
Question
Required root types
Click to reveal answer
Answer
Query is mandatory and must be an Object type. Mutation and Subscription are optional; if present they must be Objects, and all three roots must be distinct types.
Question
Default root names
Click to reveal answer
Answer
Without a schema block, SDL presumes types named Query, Mutation, and Subscription are the roots. A domain type with one of those names gets wired in by accident.
Question
What a root resolver sees
Click to reveal answer
Answer
parent is undefined at the root; the useful inputs are args, context, and info. Everything below the root behaves like any other field.
Question
How to spot a dead type
Click to reveal answer
Answer
If no root field can reach a type, no operation can select it. Reachability from Query (and Mutation/Subscription) is the test.
Revision Notes
Key Takeaways
- 1. A schema without a query root is invalid; the root must be an Object type and distinct from the other roots
- 2. Query fields are the entire read surface: name them as nouns, put filtering in arguments, and let nullability state the contract
- 3. Root resolvers have no parent to start from, so they read args, context, and info
- 4. A schema block maps custom root names and prevents domain types from being presumed as roots
Interview Tips
- • Answer "what must every schema have" completely: a provided query root that is an Object type, plus the rules for optional roots
- • When asked to design an API, start at the Query type and defend each field name, argument, and return nullability
Cheat Sheet
Query root cheat sheet
schema { query: RootQuery mutation: RootMutation }
type RootQuery {
product(id: ID!): Product
products(filter: ProductFilter, first: Int = 20, after: String): [Product!]!
}
Rules
- query root required, must be Object; mutation/subscription optional
- all roots must be different types; one schema definition per document
- default names Query / Mutation / Subscription apply when no schema block exists
- introspection (__schema, __type) is implicit on the query root
Design: nouns not actions, args for filter/sort/paginate, nullable lookup + [T!]! lists, resolvers get (undefined, args, context, info).