GraphQL Schemas, Resolvers, and Tradeoffs
Resource endpoints work right up until a client wants a response the endpoint never promised to return. Say you have the usual set of endpoints -
GET /users/42
GET /users/42/orders
GET /orders/991/itemsGET /users/42 hands back the whole user record, which is more than a profile card needs. GET /users/42/orders has the reverse problem on an order history screen, it gives back too little, so you call the third endpoint once per order just to show item counts. Subchapter 04 called that trimmed-down response a field projection. GraphQL takes the projection and makes it the contract.
GraphQL is two things, a typed operation language and an execution model. In a typical Node service the client sends a GraphQL document over HTTP, the server validates it against a schema, and the executor calls resolver functions to build the response body the client asked for.
So the contract lives somewhere new. A REST-style API spreads its external contract across routes, HTTP methods, status behavior, query parameters, and resource representations. GraphQL pulls almost all of that into one schema and the fields a client can select from it.
A resource route lets the server decide the full representation. With GraphQL the schema publishes a typed field graph and each request picks a path through it. The server still holds the data, the validation, and the side effects. The client controls one thing, how the response is structured.
The HTTP body that carries all this is small:
{
"query": "query UserOrders($id: ID!) { user(id: $id) { name } }",
"operationName": "UserOrders",
"variables": { "id": "42" }
}query holds the GraphQL document text. The name is a little misleading, because that document might actually hold a query, a mutation, or a subscription operation. operationName picks one operation when the document declares more than one. variables supplies runtime values for the placeholders the operation declared.
GraphQL over HTTP is the convention for moving that document across HTTP. A Node server usually exposes a single route, often /graphql, takes a JSON body on POST, and returns JSON with data, errors, or both. GET can carry query operations through the URL query string. Mutations go on POST, since they can produce side effects. The newer response media type is application/graphql-response+json, and plenty of deployments still accept and return application/json for compatibility.
The route count shrinks while the API surface stays just as large. All of that surface now lives in the schema.
A backend team still has to design names, failure behavior, compatibility rules, and data ownership. The difference is the artifact you sit down to review. Instead of a growing route table, you review root operation fields, object fields, nullability, argument names, input objects, enum values, and deprecation paths. The schema becomes the file that clients read, that codegen consumes, that tests validate against, and that resolver authors break without meaning to.
For a Node team this is a real shift. Framework routing trains people to think in request handlers, and a GraphQL server asks them to think in fields instead. The handler takes a document, but the real work starts after that document is parsed and the executor walks the selected fields. A single HTTP request can trigger dozens or hundreds of resolver calls. Some of them read a value already sitting on the parent object, some run a database query, and some call out to another service. The whole thing still comes back as one JSON response.
Partial data works differently too. A REST-style endpoint can bolt on ?fields=id,name or some sparse fieldset parameter, and that projection usually covers a single representation. A GraphQL selection set projects across nested objects inside one operation. A single document can ask for a user name, recent order totals, and shipment statuses at once, as long as the schema exposes that path.
The flexibility comes with bookkeeping. The backend carries the cost of every path a client can select. If the schema exposes User.orders.items.product.vendor, someone on the team has to know what that path does under load. A client only sees fields, while the server runs the code that sits behind each one.
The HTTP Envelope Stays Small
A GraphQL request still runs through the same HTTP server lifecycle from Chapter 10, the same framework layer from Subchapter 03, and the same validation thinking from Subchapter 02. The application payload is the only part that changes. The handler receives a document and variables instead of a route-specific JSON body.
POST is the common path:
POST /graphql
Content-Type: application/json
Accept: application/graphql-response+jsonThe body carries query, operationName, variables, and an optional extensions. The server parses the JSON body first, then the GraphQL layer parses the document string inside query. Those are two separate parse steps with two separate failure modes. If the JSON itself is malformed, that fails as an HTTP body problem before GraphQL ever sees it. If the JSON is fine but the document text has a syntax error, that fails later, inside the GraphQL layer.
GET works for cacheable query operations and developer tooling:
/graphql?query=query%20UserCard%7Bviewer%7Bname%7D%7DThe same GraphQL-over-HTTP fields go in the URL query string instead. Mutations stay on POST. A server that receives a mutation over GET should reject it, since GET is supposed to be a safe method.
Body limits still apply. An endpoint that accepts arbitrary document text needs the same request-size protections as any other JSON endpoint, since both the variables and the query text can get large. File upload behavior is a separate extension area and sits outside this chapter.
One endpoint also changes what your metrics can key on. A resource API gives you GET /users/:id or POST /orders as natural labels. A plain GraphQL metric gives you POST /graphql and nothing else. To get useful numbers you need operation names, persisted query IDs, or normalized operation signatures, because the HTTP route on its own no longer identifies which operation ran.
Keep the framework handler thin:
app.post('/graphql', async (req, reply) => {
const context = await buildContext(req);
return executeGraphQL({ body: req.body, context });
});It parses transport-level input, builds the request context, and hands the operation to the GraphQL layer. The business behavior lives in resolvers and the service code beneath them. If you start adding special cases for specific operation names to this handler, you have built a second route table by accident, and it has fewer guardrails than the real router.
Thin handlers also help tests. Transport tests assert method, media type, body size, and JSON parsing. GraphQL execution tests assert schema validation, resolver behavior, and how responses get built. Mixed end-to-end tests are useful for confidence, but they should not be the only place a schema field ever gets exercised.
The Schema Is the API Surface
A GraphQL schema is the typed contract for a service, the operations, object types, fields, arguments, and values it accepts or returns. Clients build against that schema, and the HTTP endpoint does nothing more than carry each request to it.
A small schema starts with root operation types:
type Query {
user(id: ID!): User
orders(userId: ID!, limit: Int = 20): [Order!]!
}Query holds the read entry points. user and orders are fields. Every field has a name, optional arguments, and a return type. ID! says the id argument takes an ID value and cannot be null. [Order!]! says the return is a non-null list where every item is a non-null Order.
Object types describe what comes back:
type User {
id: ID!
name: String!
status: UserStatus!
orders(limit: Int = 10): [Order!]!
}An object type is a named set of fields that can appear in a response. User can return scalars like id and name, an enum like status, and nested objects like orders.
Scalars are leaf values. The built-in ones cover String, Int, Float, Boolean, and ID. A scalar field ends the selection path, because the response holds a plain JSON value at that point.
Enums are named sets of allowed symbolic values:
enum UserStatus {
ACTIVE
SUSPENDED
DELETED
}An enum hands the client a closed set of values right in the schema. The response still serializes as JSON, but the contract promises the field returns one of those enum values.
Input types describe structured arguments:
input RenameUserInput {
id: ID!
name: String!
clientMutationId: String
}Input types carry values into an operation, and object types carry the values that come back out. GraphQL keeps the two kinds separate because output fields can have resolvers, arguments, and nested object behavior, while an input field is only a validated value with nothing running behind it.
The GraphQL type system is the set of schema rules that gives these declarations meaning. It decides which types can sit in input positions, which can sit in output positions, how nullability works, which fields exist on each type, and where query execution can begin.
JSON Schema and OpenAPI already did their work earlier in this chapter. JSON Schema validates JSON values as they enter the system. OpenAPI describes HTTP operations along with their request and response schemas. A GraphQL schema defines executable fields. It says which fields exist, which arguments they accept, and which nested selections can follow from a returned object. That last part, the nested selection, is what no other contract here gives you. The schema validates legal documents and decides the execution path that builds the response.
Root operation types set the starting points. Every schema has a query root. It can also have mutation and subscription roots. The default names are Query, Mutation, and Subscription, though a schema definition can wire different type names into those roles.
schema {
query: Query
mutation: Mutation
}Most schemas drop that block and take the default names. The roots are still there. A query operation starts at the query root, a mutation at the mutation root, a subscription at the subscription root.
Fields are the unit clients select and resolvers implement. A field definition can take arguments:
type Query {
searchUsers(status: UserStatus, limit: Int = 20): [User!]!
}status and limit are field arguments. The schema fixes their names, types, and default values. The resolver receives them already coerced. No raw pile of query parameters reaches the handler for this field, because the GraphQL layer parsed the document and coerced the arguments against the schema first.
Nullability does more than document a field. The markers decide how the runtime behaves when a value goes missing.
String means the completed value may be a string or null. String! means it is non-null. [Order] is a nullable list of nullable Order items. [Order!]! is a non-null list of non-null Order items. These markers are part of the runtime contract, and they decide how errors propagate.
type User {
displayName: String
orders: [Order!]!
}displayName can come back null and the parent User still returns. orders makes a stricter promise. If its resolver returns null, execution records an error and that null travels up to the nearest nullable parent field. So one missing nested list can take out a whole parent object, just because an author reached for a non-null wrapper too early. Choosing that wrapper looks like a one-character syntax decision, and it is really a schema design decision.
Every ! you add changes how far an error spreads, not just the type it sets. When a non-null field resolves to null or throws, the null cannot stop at that field. It moves upward until it reaches a nullable field, and it removes every sibling under that ancestor along the way. Wrapping a flaky nested field as [Order!]! means a single failed order load can null out the entire User in the response. Use non-null only where you can guarantee a value.
The schema also governs which input structures can reach application code. Input object fields can be required or optional, enum values pin down symbolic choices, and lists constrain repeated values. GraphQL validation rejects unknown input fields, which puts it ahead of many ad hoc JSON body parsers, though it still stops short of domain validation. A schema can declare email: String!, and application code still has to decide whether that string is deliverable, unique, verified, or allowed for the account.
Schema evolution is constant pressure. Adding output fields is usually safe, since old clients ignore fields they never selected. Removing a field breaks any client that still selects it. Tightening a nullable output field to non-null can break the server instead, if some resolver still returns null, while loosening non-null back to nullable can break clients whose generated code assumed a value was always there. A new optional argument is usually fine. A new required argument on an existing field breaks every operation that already calls it.
Enums hide a similar trap. Adding an enum value can break clients that generated an exhaustive switch over the old set. To the server this is an additive change, while the client suddenly receives a value its generated code never planned for. Versioning mechanics come later, but GraphQL schema review needs that compatibility instinct from day one.
Additive does not mean safe. Adding an enum value breaks any client whose generated code switches exhaustively over the old set, even though the server only added an option. Tightening a field from nullable to non-null, or adding a required argument to an existing field, breaks consumers the same way a deletion would. Gate enum, nullability, and argument changes in CI against checked-in client operations, not only against whether you removed anything.
Interfaces and unions exist as well. They let a field return one of several object types behind a shared contract, and they become useful once a schema has polymorphic domain results. The request path most Node teams debug first runs through object, scalar, enum, and input types, so those stay in focus here.
How you name a field is itself an API decision. orders tells the client it can ask a user for its orders. latestOrder says the server picks one order by a stable rule. orderSummary says the result is a view rather than the full order object. Those names carry compatibility weight, so renaming a field is a breaking change even when every resolver behind it still works.
Argument naming carries the same weight:
type User {
orders(first: Int, after: String): [Order!]!
}first and after read as cursor-style traversal and set an expectation of stable ordering. limit and offset would imply a different contract. GraphQL forces neither pagination model. The schema field and the resolver have to agree on one. A field that advertises after while its resolver runs unstable offset logic produces the exact bug Subchapter 04 warned about, now buried inside a selection set.
Schema files double as review artifacts for tooling that never runs at request time. Code generators read them, client build systems validate operations against them, documentation pages render them, and mock servers build fixtures from them. All of that tooling is part of why GraphQL schemas need clean descriptions and deprecation notes, even though the runtime executes fine without a word of prose.
Deprecation is the schema's compatibility marker. A field can stay live while the schema flags it deprecated and points clients at the replacement. Generated clients, documentation, and CI checks all read that one signal. The resolver still has to keep the old field working until consumers move off it, and a deprecated field that quietly starts returning different data is still a breaking change even with the schema untouched. So deprecating a field still leaves you maintaining it at runtime for as long as clients use it.
So GraphQL review happens on two levels at once. The first asks whether a field belongs in the public graph at all. The second asks how that field will be removed or changed later. Resource APIs hit the same compatibility problem through routes and representations, but GraphQL packs more of that pressure into one schema file, which is why teams should review schema changes as carefully as they review database migrations.
Selection Sets Choose the Response Fields
A query operation reads through the schema. It begins at the query root, usually named Query, and selects fields.
query UserOrders($id: ID!) {
user(id: $id) {
id
name
orders { id total status }
}
}The braces after user(id: $id) form a selection set, the fields requested from the current object type. That outer selection runs against Query. The nested selection after user runs against User, and the one inside orders runs against Order.
The response mirrors that selected structure:
{
"data": {
"user": {
"id": "42",
"name": "Ada",
"orders": []
}
}
}Internally the server may fetch much more. The response body carries only the selected fields. This is the real difference from a field projection parameter in a resource API, where projection is an optional query parameter bolted onto a route. With GraphQL the selection set is the operation body itself.
Nested fields are still real fields. orders on User is part of the schema. It can take arguments, run a resolver, fail, produce a list, or return null when its type allows null. The nesting you see in the query drives real typed field execution, with each level resolved against its own object type.
The current type shifts as the executor walks the selection set. At the top it is Query, after user(id: $id) it is User, and after orders it is Order. Validation follows the same walk. Asking for total under User fails when User has no total field, and asking for subfields under name fails because name is a scalar.
query BadShape {
user(id: "42") {
name { first }
}
}The validator rejects that before a single resolver runs. name returns a scalar, and scalar fields end the selection path. The server never calls Query.user, because the document is already invalid against the schema.
Aliases rename the response key while keeping the field:
query {
owner: user(id: "1") { name }
reviewer: user(id: "2") { name }
}The field is user in both cases, while the response keys become owner and reviewer. Aliases help when the same field appears more than once with different arguments. They also make logging and response inspection harder, since the client now controls the response key. The resolver still runs the field definition named user.
Variables keep runtime values out of the document text:
{
"operationName": "UserOrders",
"variables": { "id": "42" }
}The operation declares $id: ID!, and the request supplies the value. Validation confirms the variable is declared, used in legal positions, and compatible with the argument type. Variable coercion happens before execution.
Fragments and directives exist too, and real schemas use both heavily, but here they stay shallow. A fragment reuses a selection set. A directive changes execution or validation behavior at a declared location. Both of them feed into how selections get collected, and both become more relevant as the schema grows. Underneath all of it, execution still comes down to resolving fields.
Selection sets also change how over-fetching and under-fetching play out. A client asks for exactly the fields it needs along a schema path it already knows, which takes away the pressure to add one endpoint per screen. In exchange, it adds pressure to keep field names stable and to document what each field means. A vague field named status becomes a long-lived contract, and clients will come to depend on every value it ever returns.
The client picks the response structure, but the available field graph is fixed by the server. That is what separates GraphQL from open-ended querying. A client can select orders { total } only because the schema exposes orders on User and total on Order. Any database column or internal property left outside the field graph stays unreachable.
Mutations Are Write Operations
A mutation operation is the category for writes and other side effects. It starts at the mutation root, usually named Mutation.
type Mutation {
renameUser(input: RenameUserInput!): RenameUserPayload!
}The schema declares that the operation takes one structured input and returns a payload object. The exact payload is a design choice. Many APIs send back the changed object plus a few fields that let the client line up the response with the request it made.
mutation RenameUser($input: RenameUserInput!) {
renameUser(input: $input) {
user { id name }
clientMutationId
}
}The input object gets validated before the resolver runs. GraphQL validation checks the schema declaration, so required fields, known fields, compatible value types, and variable usage all clear first. Semantic validation still lives in application code. A perfectly valid name: "Ada" can still break a product rule or collide with an existing record.
Mutation root fields execute serially. If one operation selects two top-level mutation fields, the executor runs them in order, one after the other. Nested fields under each mutation payload follow normal field execution. That serial rule gives the server a defined order for the side-effecting top-level fields.
Idempotency from Subchapter 04 still holds. GraphQL only changes the operation envelope. Retry behavior, repeated client attempts, and stable write outcomes stay API behavior. Teams usually carry an idempotency key in an HTTP header or an input field, and the idempotency record and replay logic stay an API contract decision either way.
Error structure shifts too. A GraphQL response can carry data and errors at the same time. One field can fail while its siblings still return data, depending on nullability and server behavior. HTTP status codes turn into transport-level signals, while field and validation errors land in the GraphQL error list. That split is one reason GraphQL error contracts need their own discipline. If you reuse one vague error message across every resolver, clients get almost nothing stable to act on.
The payload a mutation returns is its own design decision, and teams often end up recreating resource-endpoint behavior here. A field named createOrder can return an Order straight back:
type Mutation {
createOrder(input: CreateOrderInput!): Order!
}It returns the new order directly and stops there. That leaves no obvious place for structured domain feedback beside the created object. A payload object gives the schema room for stable response fields:
type CreateOrderPayload {
order: Order
userErrors: [UserError!]!
}userErrors here is a stable structure for domain feedback. The team still has to sort which validation failures belong to GraphQL validation, which belong to domain validation, and which become top-level GraphQL errors. A missing required input field is GraphQL validation. A payment method declined by a provider belongs in the mutation's domain response or its error policy. When those categories get mixed at random, client retry logic becomes painful to write.
Idempotency keys raise the same placement question. A header keeps the key next to the HTTP infrastructure, and an input field keeps it visible inside the GraphQL operation. Either way the service has to compare repeated attempts, store the prior outcome, and catch conflicting payloads. The schema can expose the input field, but the idempotency behavior itself still lives in application state.
The Request Path Through a Node GraphQL Server
The HTTP server receives bytes first, and Chapter 10 covers that parsing path. By the time GraphQL code runs inside a typical Express, Fastify, or lower-level Node handler, the service already holds an HTTP request body that contains the GraphQL-over-HTTP parameters.
From there the GraphQL layer runs its own pipeline:
HTTP body -> parse -> validate -> select operation
operation -> execute fields -> complete values
completed values -> GraphQL response JSONParsing turns the GraphQL document string into an AST, a syntax tree for the operation text. Its nodes are operation definitions, field selections, arguments, variable definitions, fragments, and directives. A syntax error stops the request right here, before schema validation ever runs.
Next the validator checks that AST against the schema. It confirms fields exist on the current type, leaf fields end at scalars or enums, object fields carry nested selection sets, arguments exist and their values match the input types, required arguments are present, fragments apply to the current type, and variables are declared and used legally. Any validation error stops execution.
Validation runs before most resolver bugs can happen. A resolver can treat args.id as present when the schema says id: ID! and validation has already passed. It may still need to check whether that ID belongs to a record the caller can see, which is semantic validation and authorization. Keeping those layers separate cuts duplicate checks and makes bad requests fail in one consistent place.
Validation runs against the whole operation at once. A nested fragment that selects an invalid field three levels down fails the entire operation before execution starts. That gives clients fast feedback and gives the server a clean stopping point before any resolver runs.
Operation selection happens next. A document can contain multiple operations:
query UserCard { user(id: "42") { id name } }
query UserAudit { user(id: "42") { id status } }operationName tells the server which operation to execute. If a document holds multiple operations and no name is given, the request is ambiguous and fails before execution. A document with a single anonymous operation runs that one directly.
Then the executor picks a starting type from the operation category. A query starts at Query, a mutation at Mutation, a subscription at Subscription. Subscription transport and fanout details belong to Chapter 13.
Execution works field by field. Each selected field has a parent type and a field definition in the schema, and the executor looks up the resolver for it. A resolver is a function that produces the value for one schema field. In JavaScript GraphQL servers, that function receives four arguments, the parent object, the field arguments, the resolver context, and the field info.
function user(parent, args, context, info) {
return context.users.findById(args.id);
}parent is the value the previous resolver produced. At the root it is the root value the server supplies. args holds the field arguments after GraphQL coerced them. context is a per-request object the server builds. info describes the field, schema, operation, and the path selected to reach it.
The context object is the one value every resolver in the operation shares. It usually holds data-access objects, DataLoader instances, request metadata, and identity facts attached by earlier middleware. Authorization policy is Chapter 24's topic. What concerns us here is placement. If the checks are spread unevenly across resolvers, the API ends up behaving unevenly too.
The context gets built after the HTTP framework has parsed headers and any upstream middleware has attached its request facts. In Express or Fastify, the GraphQL integration takes the framework request object, builds a context from it, and hands that to the executor. The executor then passes the same object down to every resolver in the operation.
async function context(req) {
return {
requestId: req.id,
users: new UserStore(req.db),
viewer: req.user
};
}That function is part of the API runtime even though clients never see it. It decides which database handle resolvers use, which user identity facts are available, and which request-scoped caches exist. A thin context is easy to build a test around. Once it grows into a grab bag of services, it becomes a hidden service locator that every resolver secretly depends on.
Nested execution works through the parent value:
const resolvers = {
Query: { user: (_, args, ctx) => ctx.users.findById(args.id) },
User: { orders: (user, args, ctx) => ctx.orders.findForUser(user.id) }
};The Query.user resolver returns a user object. The executor then evaluates the selection set under user. When it reaches User.orders, it calls the orders resolver with that user object as parent. The resolver reads user.id and fetches orders for that user.
If a field has no custom resolver, many JavaScript GraphQL servers fall back to a default field resolver. It reads a property of the same name from the parent object, or calls it if that property is a function. Simple scalar fields often need no resolver code because of that fallback.
const user = { id: '42', name: 'Ada' };For User.id and User.name, the default resolver just reads user.id and user.name. User.orders is different, because the value usually sits behind another data-access call, so that field needs a real resolver.
Default resolvers are useful until object structures drift. If the database row has display_name and the schema field is displayName, the default resolver returns undefined. Depending on nullability, that can turn into null or a field error. A resolver can normalize the field:
const resolvers = {
User: {
displayName: user => user.display_name
}
};That code is small, but it is part of the contract. It keeps storage naming out of the schema. The schema can keep displayName even if the database column changes from display_name to profile_name, as long as the resolver preserves the field behavior.
Async behavior follows JavaScript promises. A resolver can return a value, a Promise for a value, an array, null, or throw an error. The executor waits for promised field values at the points where the execution algorithm needs them. Query fields that can run in parallel often do, mutation root fields run serially, and a nested field only executes once its parent value exists.
Value completion is the step that turns resolver output into a GraphQL result. A scalar or enum is serialized as a leaf value. An object type triggers execution of the nested selection set against that object. A list runs completion against each item. The awkward case is a non-null field that completes to null, because GraphQL then has to record an error and push that null upward until it reaches a nullable field. That push can strip out more of the response than the resolver author expected.
A concrete trace helps:
query {
user(id: "42") {
name
orders { total }
}
}The executor starts with the query root and calls Query.user. The returned user becomes the parent for User.name and User.orders. User.name resolves straight from the user object, while User.orders fetches order records. Each order then becomes the parent for Order.total. The final response JSON follows the field names the query selected. So the API contract a client reads is really this traversal, running once per request.
The same traversal also explains partial data. If User.orders fails while User.name succeeds, the response may contain the user name, an error entry for orders, and a null at whatever field or parent position nullability dictates. Clients have to handle a response in that state, and servers have to design nullability with these failure paths already in mind.
HTTP status code choices sit outside that field path. A malformed HTTP body can produce a 400, and an unsupported method can produce a 405. But a syntactically valid GraphQL operation that hits a field error can still come back as an HTTP success carrying GraphQL errors. Libraries differ on the status details, especially across legacy media types, so each team should pick a policy and test it.
Resolvers Are Ordinary Node Code
A GraphQL request looks declarative when you read it. The resolver execution behind it is ordinary Node code, and it has the same costs as any other Node code you write.
The schema can say User.orders: [Order!]!. That declaration tells the validator which selection sets are legal and tells the executor how to complete the result. The fetching itself is up to the resolver. It can hit a database, call another service, read a cache, compute a value, or just return data already present on the parent object.
Each resolver call sits inside the request's JavaScript execution. A synchronous resolver runs until it returns or throws. An async resolver returns a Promise, which the executor wires into the GraphQL execution algorithm. Once enough parent values are ready, sibling field resolvers can be scheduled without waiting on unrelated siblings. That lets GraphQL overlap independent async data access, and it also makes accidental fanout easy to create.
Field order in the response follows the selected fields closely enough for JSON readability, but resolver timing is an implementation detail unless the spec requires serial execution. From an API design view, query fields should be treated as side-effect-free. A query resolver that writes data creates behavior clients cannot predict from the operation category alone. The side-effect contract belongs to mutation root fields.
The four values passed to a resolver decide where state can leak.
parent is local to the traversal path. It should hold the object value for the current branch and nothing more. If one resolver mutates a parent object and a sibling resolver reads that mutation, the result starts depending on execution timing, which is fragile and hard to debug. Treat each return value as a field-completion value. It is not a place to stash shared state for other resolvers to read.
The parent value can also be smaller than the full schema type on purpose. A Query.user resolver might return only { id } and let nested fields load their own values. With careful batching that works fine. Without it, you have scattered one object load across many field resolvers. A steadier pattern is to return the fields that are cheap and stable on the parent, and reserve nested resolvers for relationships or expensive computed fields.
args is GraphQL-coerced input. Coercion handles schema-level input conversion and default values. Business validation belongs after that. If limit is an Int, GraphQL can reject a string where an integer is required. Application code decides whether limit: 5000 is acceptable for your service, preferably behind a small policy function rather than scattered conditionals.
context is the request-level container. Build it once per GraphQL request, and once upstream authentication has confirmed them, keep the request-scoped loaders, the data-access clients, and the verified identity facts on it. Process-global mutable context is the wrong place for user-specific state. A single Node process handles many overlapping requests at once, and shared mutable resolver state leaks data across them.
info is the low-level field execution metadata. It can tell a resolver which field is executing, which return type is expected, and which selection set appeared under the field. Some teams read info to build database projections. It works, but it also couples the resolver to GraphQL AST details, so every schema change can ripple into data-access code. If you do it, keep that code in one place and test it.
Projection from info is tempting:
function user(parent, args, ctx, info) {
const fields = selectedColumns(info);
return ctx.users.findById(args.id, fields);
}That resolver asks the database for only the selected columns. The idea is sound, and the danger is hidden coupling. Aliases, fragments, nested selections, default resolvers, and computed fields all change what "selected columns" actually means. A projection helper has to be schema-aware, well past simple string matching on field names.
Before you build SQL projections from info, account for aliases, fragments, inline fragments, nested selections, computed fields, and default resolvers. Every one of them changes what "the selected columns" actually are. String-matching top-level field names silently drops columns the moment a client aliases a field or pulls it in through a fragment. Use a schema-aware selection walker, keep it isolated, and test it against aliased and fragmented operations.
The executor collects field results into a response map. GraphQL's response format has a top-level data entry for successful field results and an errors entry for execution or validation errors. Execution errors include a path so the client can connect the error to a selected field. Nullability decides how much data remains next to that error.
Consider this schema:
type Query {
user(id: ID!): User
}And this object field:
type User {
name: String!
orders: [Order!]!
}If User.orders throws, the executor records an error for the orders path. Because orders is non-null, the completed User value can become null when User itself is nullable at its parent. So a single field failure can remove the parent object from data. Value completion is doing exactly what the schema's nullability declared.
Resolvers also control batching opportunities. The executor sees fields and calls the field resolver for each parent user. Batching knowledge lives in the resolver layer, request cache, or data-access method that performs a set-based load.
So a Node GraphQL server is running two contracts at the same time. The schema contract tells clients which operations and fields exist. The resolver contract tells the runtime how each of those fields becomes real work. A clean schema sitting on careless resolvers is still a slow API. Careful resolvers do not save clients from a messy schema either, the cost of that mess just shows up in client code instead of server latency.
Resolver placement affects code ownership too. A large schema often crosses product areas. An identity team may own the User type while an ordering team owns User.orders. GraphQL places those fields right next to each other in the client-facing schema. The resolver implementation can stay modular, but the public type is now shared between teams. Schema review needs clear ownership rules, otherwise every team keeps adding fields to the same object until it holds a pile of unrelated concerns.
The executor will call any field the schema exposes, with no judgment about whether it should. That is by design. Backend governance has to decide which fields belong, which ones need deprecation, and which ones should stay behind a resource endpoint or a separate service.
That tension shows up most clearly in the N+1 query problem.
N+1 Starts with a Normal Nested Query
N+1 is a data-access pattern. One query fetches a list of parent records, then one more query runs for each parent to pull its nested data. In GraphQL it usually slips in through nested field resolvers that each look harmless on their own.
query {
users {
id
orders { id total }
}
}The top-level users resolver might fetch 50 users. Then User.orders runs once per user. If that resolver calls SELECT * FROM orders WHERE user_id = ?, the request performs 1 query for users plus 50 queries for orders.
The resolver code usually looks clean:
const resolvers = {
Query: { users: (_, __, ctx) => ctx.db.users.findMany() },
User: { orders: (user, _, ctx) => ctx.db.orders.findByUserId(user.id) }
};Each resolver is reasonable on its own. The top-level field fetches users, and the nested field fetches orders for a single user. The problem only appears when the selection set asks for that nested field across a whole list.
This is how GraphQL hides database loops. There is no explicit for loop anywhere in the handler. The executor traversal supplies the loop for you, by calling the same field resolver once per parent item.
Adding another nested field can multiply the cost again:
query {
users {
orders { items { product { name } } }
}
}The exact number of backend calls depends on the resolver code, cache hits, and batching. The pattern underneath is steady though. Each list in the selection can multiply the resolver calls beneath it, which is how a compact-looking query can drive a large amount of backend work.
Logging one HTTP request as a single unit hides the useful detail. Once GraphQL is under load, you want resolver-level timing, database query counts, and operation names. Full tracing comes later. Local counters already catch a lot, like resolver calls per field, database calls per request, batch sizes, and the slowest resolver path.
DataLoader batches those per-field loads inside a single request. A DataLoader is a request-scoped loader with two jobs. It collects the individual .load(key) calls that happen in the same execution turn, then calls one batch function with all the collected keys. It also keeps a per-loader cache, so repeated loads of the same key during that request reuse the result.
DataLoader comes from the dataloader package:
import DataLoader from 'dataloader';That import gives the next snippets their constructor. The examples still assume a db object from your service layer.
Create loaders in context:
function createContext(db) {
return {
ordersByUserId: new DataLoader(ids => db.orders.findByUserIds(ids))
};
}Then use the loader in the field resolver:
const resolvers = {
User: {
orders: (user, _, ctx) => ctx.ordersByUserId.load(user.id)
}
};Now 50 User.orders resolver calls become 50 .load(user.id) calls. The loader batches those IDs and calls findByUserIds(ids) once for that batch window. The batch function returns results aligned to the requested keys, so each resolver receives the right value.
Request scope is the part to get right. A loader cache shared across requests can leak data between users if visibility differs by identity. Put loaders in resolver context, created per request, with access to that request's identity and data-access policy.
Build DataLoaders and any identity-scoped state fresh inside the per-request context, never as module-level singletons. A loader cache keyed only by record ID has no idea who is asking, so a process-global loader can hand one user data that was loaded under another user's permissions. The same rule covers any mutable value stored on a shared context. One Node process serves many overlapping requests at once.
The batch function also has a contract:
async function batchOrders(ids) {
const rows = await db.orders.findByUserIds(ids);
return ids.map(id => rows.filter(row => row.userId === id));
}The returned array lines up with the input key array. If ids[0] is "42", the value at result index 0 belongs to user "42". Missing rows produce an empty list or null according to the field contract.
That alignment rule is small and easy to break. Sorting the database results by creation time and returning them directly gives the wrong user the wrong orders. Group by key, then map back to the input order.
The array your batch function returns must be the same length as the input keys and ordered to match them position for position. Returning rows straight from the query, sorted by id, created_at, or whatever the database chose, hands user 42's orders to user 99. Build a lookup keyed by the load key, then call ids.map(id => byKey.get(id) ?? []) so every key gets its own value or an explicit empty result.
Error handling in a batch function needs the same care. When the whole backend call fails, the batch Promise rejects and every waiting field gets that failure. When a single key fails, many DataLoader implementations put an Error at that key's position in the result array, which lets the unrelated keys still resolve. Match that behavior to the field nullability and the error policy, because a non-null list item can turn one key's failure into a much larger null in the response.
Batch windows are small on purpose. DataLoader collects the loads that happen in the same execution turn, then dispatches the batch function. That fits GraphQL well, since sibling field resolvers usually run close together. The collection window stays inside a single request. Batching across requests would mix identity, visibility, and latency concerns that should stay separate.
Caching inside DataLoader is also request-scoped by default in the healthy pattern. If two fields in one operation both load user "42", the loader can reuse the same Promise, which avoids duplicate work and keeps object identity stable within the request. A long-lived process-global loader behaves differently. It can return stale data, leak user-specific visibility, and grow in memory, because nothing ties it to a single request.
Batching also changes where service limits belong. If the schema accepts users(limit: 500), batching might turn 500 nested loads into one large WHERE user_id IN (...) call. That is better than 500 calls, but it is still large. The resolver layer should enforce page sizes and list limits close to the fields that create fanout.
DataLoader cuts the number of round trips, but it does nothing about the size of the work. A batched query over 500 collected keys is still one very large IN (...) scan. Put explicit limit or page-size caps on every list field that can fan out, right at the field where the multiplication happens, so a single client document cannot force an unbounded batch.
DataLoader is a GraphQL-layer pattern. Query planning, indexes, and set-based access get covered in depth in the database chapter. At the GraphQL layer the point is simpler. Field resolvers run once per selected parent, so the batching has to happen in the resolver and the data-access layer.
The same pattern applies to users, products, permissions, prices, and any other nested field. It has limits too. Batching reduces round trips, but a query that asks for many nested lists can still do a lot of work even after every load is batched.
GraphQL lets clients compose nested reads, and the backend has to keep those compositions safe enough for normal traffic. DataLoader handles one common data-loading failure mode. It works alongside schema design, pagination, maximum list sizes, persisted operations, and the abuse controls that come later.
Introspection and Persisted Queries
Introspection is GraphQL's schema discovery system. The schema itself can be queried through special introspection fields, and tools use those results to build documentation, editors, type generation, and operation validation. In development that is genuinely useful.
Production needs a deliberate policy. Public introspection exposes the GraphQL contract to any caller who can reach the endpoint. One team might keep it open because the schema is meant for third-party developers, while another locks it down because the endpoint is private and clients are generated from a checked-in schema artifact. The right choice depends on the API's audience and threat model. Abuse controls and security hardening are covered in Chapter 25.
Persisted queries move the operation text out of the normal request path. A client sends an operation identifier, often a hash, and the server looks up the pre-registered GraphQL document. The server can validate and approve that document ahead of time, then accept compact requests at runtime.
Development workflows often lean on introspection. Editors autocomplete fields from it, code generators produce TypeScript types from the schema, and contract checks compare client operations against the latest schema. That tooling is one reason GraphQL can feel productive even when the runtime path is more involved than a resource endpoint.
Audience is the deciding factor. A public partner API may want introspection on, since outside developers need to discover the graph. An internal API serving a single deployed web client may do better with checked-in schema artifacts and persisted operations. Either way works, as long as someone actually chose the policy instead of inheriting it by default.
The runtime body can shrink to this:
{
"operationName": "UserOrders",
"variables": { "id": "42" },
"extensions": { "operationId": "user-orders-v3" }
}Persisted queries change how operations reach the server. The server has an allowlist of known documents. Unknown documents can be rejected before parse and validation cost. Caches and metrics can key off stable operation IDs instead of raw query text. Client releases become coupled to the persisted-query registry, so rollout order is something you plan for.
The exact extensions structure is a deployment policy. Apollo-style automatic persisted queries use persistedQuery with a version and sha256Hash, and they can perform a hash-miss retry where the client sends the full document. A strict allowlist skips that runtime registration path and accepts only IDs already present in the registry.
Persisted queries also make observability cleaner. Raw GraphQL text can carry aliases, whitespace differences, and argument literals when a client builds documents poorly. Operation names help with that, but names are optional in the GraphQL language. Persisted operation IDs give the platform a stable identifier to group on. Distributed tracing comes later. The point for now is that arbitrary query text is much harder to group than a stable operation ID.
Teams sometimes treat persisted queries as the entire safety model, when they are really just one control. Query cost, depth, authorization, and abuse controls are all separate topics, and they stay separate.
Persisted query rollout has a sharp failure mode. If a client ships an operation ID before the server has registered it, those requests fail before execution. If the server retires an ID while an old client still sends it, that client breaks. This is the same compatibility pressure from Subchapter 01, applied to an operation registry instead of route paths.
With a strict allowlist, an operation ID has to exist in the registry before any client that sends it ships. If the client goes out first, every request 400s before parsing. If you remove an ID while an old client still sends it, that client breaks instantly. Register new operations ahead of the client release, and retire old IDs only after telemetry shows no traffic still using them.
Subscriptions Stop at the Schema
A subscription is the GraphQL operation category for event streams. It starts at the subscription root type and lists the fields a client wants in each event payload.
subscription {
orderUpdated(id: "991") {
id
status
}
}The GraphQL part is the operation contract and the structure of each event payload. Transport is a separate layer. Many Node deployments carry subscriptions over WebSocket, and some use Server-Sent Events. Connection lifecycle, reconnects, heartbeat behavior, fanout, and backpressure for realtime clients all belong to Chapter 13.
That division is easy to underestimate. Subscription schema design looks small even though the runtime system behind it is large. A field named orderUpdated is only the entry point of the API contract. The server still needs an event source, per-client filtering, lifecycle cleanup, and a delivery story. GraphQL gives the stream a name, and the realtime chapter handles everything that actually runs the stream.
For now, treat subscriptions as a schema category whose runtime is deferred. The operation validates against the subscription root, and the selected fields describe each emitted payload. Everything else, keeping a connection alive, resuming after a disconnect, and distributing events across processes, waits for the realtime chapter.
Tradeoffs Against Resource APIs
GraphQL pays off when clients need flexible response structures over a connected domain model and the backend team is ready to govern the schema. It tends to cost more than it returns when the operation surface is small, when caching is mostly HTTP-native already, or when the team has not built up resolver discipline.
The contract surface is the first tradeoff. A resource API exposes many URLs and stable representations. GraphQL exposes a typed field graph behind a handful of HTTP endpoints. That can cut endpoint sprawl, and it can also hide a large API surface inside one schema.
Client coupling changes form. A REST-style client couples to routes, methods, status codes, and representation fields. A GraphQL client couples to schema fields, nullability, enum values, operation names, and resolver behavior that affects latency. Removing a field is a breaking change. So is moving a field from nullable to non-null, which can break clients and servers in different ways. So is adding a required input field to an existing mutation, which breaks every operation that already calls it.
Caching gets less automatic. Resource APIs can use URL, method, headers, validators, and cache-control behavior already covered around HTTP. GraphQL commonly sends many reads through POST, and different operation bodies hit the same URL. GET can help for query operations, persisted queries can help with stable IDs, and application-level caches can help behind resolvers. Still, GraphQL teams usually build more cache policy themselves.
Validation is richer at the schema layer. GraphQL can reject unknown fields, wrong argument types, missing required values, invalid enum values, and illegal selection sets before resolver code runs. That reduces a class of handler-level validation code. Semantic validation stays in application code, exactly as it did with JSON Schema and resource APIs.
Observability needs operation-level naming. A resource API gives you GET /orders/:id as a natural metrics key. With GraphQL you get /graphql and nothing else, unless the server pulls out operation names or persisted IDs. Resolver-level timing can help, though it can also produce noisy data. Tracing is covered in Chapter 29. The point for now is that a single HTTP route is too coarse to describe GraphQL operations.
Authorization placement is easier to get wrong. A resource endpoint usually keeps its checks near the handler. GraphQL spreads data access across field resolvers, so the checks scatter along with it. Some sit at operation entry points, some at the data-access layer, and some on individual fields. The policy model is the subject of Chapter 24. The GraphQL-specific risk is placement drift, where two paths to the same field end up running different checks.
Performance hides from the request text. A small-looking GraphQL operation can fan out through many resolvers, while a large one can be cheap when every field it touches is already cached or loaded. The text only shows you the structure. The actual cost is in the resolver implementation. Complexity limits and abuse controls are covered in later security chapters, but everyday API design still needs a working mental model of what the resolvers are doing.
Versioning pressure does not go away. GraphQL pushes you toward additive evolution, where you add fields, keep the old ones, mark some deprecated, and remove them only after clients have moved off. That helps, but it is still API evolution. Enum changes, nullability changes, argument changes, resolver behavior changes, error structure changes, and authorization changes can each break a client. Versioning mechanics are covered in Subchapter 07.
The real choice is about where your team wants the contract to live. If the domain maps cleanly onto resources, HTTP caching is doing real work, and clients are content with stable representations plus field projection, a resource API stays clean. If clients need many overlapping response structures, the data graph is genuinely connected, and the server team can invest in schema review, resolver batching, and operation observability, GraphQL becomes the stronger choice.
A GraphQL schema can make an API look smaller from the outside than it actually is. That only holds up when the team treats the resolver layer as runtime code that carries the contract, and gives it the same review the schema gets.