GAP·MAP

GraphQL on the server

backend · API Designmiddlesenior~9 min read

covers schema design & nullability · resolver execution model · errors as data · per-field authorization · cursor connections · federation vs stitching

[ middle depth ]

How the executor walks your schema

A GraphQL server does not run one function per request. Each field is backed by a resolver, and the executor walks the query tree calling them. A resolver receives four arguments: obj (the value the parent field returned, unused on root Query fields), args (the field's arguments), context (per-request data you build once, such as the logged-in user and database handles), and info (schema and operation details, rarely needed).

function resolveHuman(obj, args, context, info) {
  return context.db.loadHumanByID(args.id); // returns a Promise; the executor awaits it
}

Once human returns a row, the executor resolves the fields selected on Human. You usually do not write those. Most libraries supply a default resolver: with no resolver for a field, it reads the property of the same name off the parent value. So a name column on the row satisfies name: String for free. Execution keeps descending until it reaches scalars or enums, which are the leaves.

Two consequences worth holding onto. Query fields can run concurrently, but mutation fields at the top level run one at a time, in order, because they cause side effects. And because every field resolves on its own, a field like Post.author runs once per post, which is where the N+1 problem comes from (last section).

What the exclamation mark actually costs you

Fields are nullable by default. String may return null; String! promises it never will. On a list, the two positions are independent: [Post!]! means the list is present and every element is present, while [Post!] allows the whole list to be null but no null elements, and [Post]! allows null elements inside a guaranteed list.

The ! is not a free correctness upgrade, because it changes what happens on failure. When a Non-Null field's resolver returns null or raises an error, the executor cannot write null there, so it discards the value and the error propagates to the nearest nullable parent. If that parent is also Non-Null, the null keeps climbing.

01error at author: User!02parent Post! is non-null03list [Post!]! is non-null04feed: [Post!]! is non-null05data becomes null
fig · A Non-Null field error bubbles to the root

That climb is the blast radius. Give a feed the type feed: [Post!]! with author: User!, and a single failed author lookup nulls the whole feed instead of one field. The response still returns HTTP 200 with a partial data and an errors entry carrying the path to the field that failed; GraphQL does not turn a field error into a 500.

Wrong "Marking everything ! makes the API stricter and cleaner." It couples the availability of unrelated fields: one flaky dependency can blank a large response. Right reserve ! for values that genuinely cannot be null (a primary key, a required column), and leave fields nullable where a resolver can legitimately fail or return nothing, so a failure stays local.

Cursor connections beat offset pagination

feed(offset, limit) looks fine until the data shifts. Insert a row while a user pages, and offset math skips or repeats items, and large offsets get slow. The GraphQL convention is cursor-based pagination, where the client passes an opaque cursor from the last item it saw:

query {
  feed(first: 20, after: "opaque-cursor") {
    edges { node { id title } cursor }
    pageInfo { hasNextPage endCursor }
  }
}

A connection has edges and pageInfo. Each edge wraps a node (the actual object) and a cursor (an opaque string, base64 by convention, whose format the client must not parse). pageInfo reports hasNextPage, hasPreviousPage, startCursor, and endCursor, so the client knows when to stop without a trailing empty request. The edge layer looks like indirection, but it gives you a place for data about the relationship rather than the node, for example a role on a membership edge.

Wrong "Cursors are just the offset with extra steps." An opaque cursor usually encodes a stable sort key (an id, a timestamp), so paging stays correct as rows are inserted and deleted; an offset counts from the start every time and drifts.

N+1 comes from per-field resolution

The Post.author resolver runs once per post. Load 100 posts, select author, and you fire 1 query for the list plus 100 for the authors. The fix is DataLoader: the resolver calls loader.load(post.authorId), and DataLoader coalesces the loads made in one tick into one batched query. Build a fresh loader per request and hang it on context, because its cache is a per-request memoizer, not a shared application cache. The batching contract (a values array the same length and order as the keys) and the cross-user leak from sharing an instance are worked through in the api-protocols module; the point here is that the fan-out is a direct product of the execution model above.

[ senior depth ]

Nullability is an availability decision

The middle note called ! a blast radius. Here is the exact rule from the spec's execution section, because interviewers probe it. When a field of a Non-Null type resolves to null or raises a field error, the executor raises an error at that response position and propagates it to the parent position. If the parent is nullable, the parent resolves to null and the error stops there. If the parent is also Non-Null, the error keeps propagating. A List that wraps a Non-Null element resolves to null entirely if any element would be null. And if every position from the failing field up to the root is Non-Null, the top-level data entry itself becomes null. One error is recorded per position, with a path.

So a schema is a set of coupling decisions. feed: [Post!]! with author: User! ties the visibility of the whole feed to the availability of the user-service: one failed author lookup nulls the list. Widen the type where a resolver can legitimately fail (author: User, or the element to [Post]!) and the failure stays local, so the rest of the response survives as partial data. The design default is nullable, with ! earned by values that cannot be null: a primary key, a non-null column, a field computed purely in-process. Treat ! on anything that crosses a network boundary as a claim you have to defend.

Modeling expected failures as data

Every GraphQL response can carry a top-level errors array alongside data. It is untyped by design: no schema shape, no strong typing, and a thrown error nulls the corresponding data position. That makes it a poor channel for failures the end user is meant to act on, because the client cannot branch on a structured type and loses any partial payload.

The pattern for expected failures is to model them as data. A mutation returns a union or a payload type whose members include the error cases, and the client selects on __typename:

union CheckoutResult =
    Order
  | InsufficientStockError
  | InvalidPaymentMethodError

type Mutation {
  checkout(input: CheckoutInput!): CheckoutResult!
}

Now InsufficientStockError can carry a message and the offending productId, typed and selectable. A common variant is a payload type with a userErrors: [UserError!]! field next to the result, which Shopify's Admin API uses. Reserve the top-level errors array for the unexpected: a downstream timeout, an unhandled exception, the 500 class you page on. The split is expected-and-actionable goes in data, unexpected-and-systemic goes in errors.

Wrong "Errors-as-data is dangerous because it puts error details in data where clients can read them." It models known failures as named types you design; what those types expose is your choice. Leaking a stack trace is a separate mistake, and it is just as wrong in the top-level array.

Authorization happens per field

REST authorizes a route. GraphQL has one endpoint and authorizes fields, because a single query can touch many types across many resolvers. Two rules follow.

Keep the authorization logic in the business logic layer that resolvers call, not inline in each resolver. If the rule lives in the resolver, you duplicate it at every entry point (a REST controller, a background job, a second resolver), and the copies drift, so users see different data depending on which path they hit. The resolver's job is to pass the caller's identity down. Put a fully hydrated user object on context during request setup (authentication), and let the business layer make the decision (authorization), so authentication stays swappable independent of authorization.

The per-field part bites on sensitive scalars. User.email on a type that is otherwise public still needs its own check: the owner or an admin may read it, others may not. A gate on the top-level user query does nothing here, because a client can reach email through post.author.email or any other path that returns a User. Field-level checks are the only place that closes.

Wrong "Auth is handled at the gateway, so resolvers do not need to care." A gateway can authenticate and rate-limit, but it does not know that email is restricted while name is public on the same object; that decision lives where the field resolves.

How a server bounds query cost

Counting requests does not bound load, because one /graphql request can be arbitrarily expensive: a cyclic schema (user -> posts -> author -> posts ...) lets a client nest a query deep enough to fan out into millions of resolver calls. Depth limiting, complexity or cost analysis against a per-field budget, and Shopify's computed-point charge-and-refund model are the standard cost mechanics, worked through in the api-protocols module. DataLoader bounds the database fan-out for a given shape, but it does not bound the shape itself, so those limits are a separate layer.

The server-authoring control worth its own note is the persisted-query allowlist: the server accepts only operations registered ahead of time, identified by a hash, and rejects anything else. Note the distinction interviewers test. Automatic Persisted Queries (APQ) are a performance optimization: a client that hits PersistedQueryNotFound simply registers the new query and it is accepted, so APQ adds no allowlist. A safelist of registered operations, uploaded from your own clients at build time, is the security feature; the server refuses operations that are not on it.

Federation vs stitching

One schema across many teams needs the graph split. Two approaches, and the survey-level difference is where the linking logic lives.

Schema stitching combines multiple schemas at a gateway, and you write the logic that merges types and resolves cross-schema links in the gateway itself. It works, but the gateway becomes a hand-maintained integration point that teams do not own, which gets error-prone as the graph grows.

Federation inverts that. Each team owns a subgraph, a normal GraphQL service that declares which types it contributes. A type becomes a shared entity by adding a @key directive naming the fields that identify it, so another subgraph can reference or extend that entity. A build step composes the subgraph schemas into one supergraph schema, and a router (the gateway) uses it to plan a query across subgraphs and stitch the pieces at runtime. The linking logic sits in the subgraphs, declared, rather than in a central gateway, written by hand. Declaring the linking logic in the subgraphs is why federation displaced stitching for large multi-team graphs; the trade is more moving parts and a composition step in your pipeline.

# in the users subgraph
type User @key(fields: "id") {
  id: ID!
  name: String!
}

# in the reviews subgraph, the same entity, extended
type User @key(fields: "id") {
  id: ID!
  reviews: [Review!]!
}

dig deeper

Primary sources behind these notes - the specs and official docs worth reading in full.

gapmap pro

You've read the GraphQL on the server notes. An interviewer will ask you to prove them.

Know you're ready. Don't hope.

The notes are free: every topic, senior depth. Pro is what turns reading into readiness you can prove:

  • Mock interviews on your topics

    An AI interviewer that pushes back with follow-ups and grades you honestly. Per module, fair-use unlimited.

  • Voice test interviews

    The dress rehearsal, out loud: questions from your map, a transcript, and a per-answer verdict.

  • A plan to your date

    Ready or not ready, per module, as the interview approaches. Recomposed whenever your goal changes.

  • A mentor inside every lesson

    Stuck on the senior notes? Ask, drill deeper, get re-quizzed on the spot.

Pro $20/mo · Pro+ $50/mo · every study note on the site stays free

builds on

more API Design

was this useful?