Designing a GraphQL Schema That Won’t Trap You: Nullability, Connections and Mutations

A GraphQL schema is not code you can refactor quietly. It is a public contract, and once a client depends on a field, its shape and its nullability are promises you have to keep. The decisions you make in the first week are the ones you live with for years.

Most GraphQL tutorials teach you enough to return a list of users and stop. That is where the interesting problems begin, not where they end. The traps that hurt real teams are not syntax errors; they are design choices that compile perfectly, ship, and then quietly punish every consumer of the graph. Three of them matter more than the rest: where a field should be non-null, how you paginate, and what a mutation returns when something goes wrong. This is a working guide to all three, built with Apollo Server 4 on Node 22 and a schema-first SDL, with the reasoning for each convention rather than just the shape.

Nullability is a blast radius, not a preference

In GraphQL, a non-null field marked with ! is a guarantee. If a resolver for a non-null field returns null or throws, GraphQL does not just null that field. It propagates the error up to the nearest nullable parent and nulls the whole thing. Mark a field non-null carelessly and a single failing sub-resolver can wipe out an entire object, or an entire list entry, from the response.

Free · 4 minutes

If your most senior engineer left tomorrow, would anyone still understand the system?

Fourteen questions on documentation, dependencies, and the gap between how the architecture works and how many people know it. Banded finding on screen, full sheet by email.

The default should be the opposite of what feels natural. Make scalars and objects nullable unless you can genuinely promise the value will always be there. An id is a fair non-null. A displayName that depends on a downstream profile service is not — the day that service is slow, you want a null name, not a vanished user. The one place to be firmly non-null is list membership: a list of posts should be [Post!]!, meaning the list itself is always present and never contains null entries, because a null hiding inside a list is far harder for a client to reason about than an empty list.

The asymmetry that bites teams is this: loosening a field from non-null to nullable is a breaking change, because clients written against the guarantee will not handle the null. Tightening from nullable to non-null is safe. So when in doubt, start nullable. You can always promise more later; you cannot quietly promise less.

Paginate with connections, not offset and limit

Offset pagination — posts(limit: 20, offset: 40) — is the thing everyone reaches for and the thing that fails under load. Offsets drift when rows are inserted or deleted between requests, so users see duplicates or skip records, and OFFSET gets slower the deeper you page. The Relay Cursor Connections specification exists to solve exactly this, and adopting it early costs you almost nothing while retrofitting it later is a full migration.

The pattern has three layers. A Connection wraps the result and carries pageInfo. An Edge wraps each item alongside its cursor — an opaque token, not a row number — which lets you attach edge-specific metadata later without touching the node. PageInfo reports hasNextPage, hasPreviousPage, startCursor and endCursor. Clients page forward with first and after, backward with last and before. The cursor encodes a stable sort key, so pagination survives concurrent writes.

It is more boilerplate than offsets, and it is worth it. The moment your first mobile client wants stable infinite scroll, or you need to add a weight or addedAt field to the relationship between two nodes, you will be glad the edge exists.

Mutations return payloads, not exceptions

The single biggest schema mistake I see is treating expected business failures as GraphQL errors. If a user submits an email that is already taken, that is not an exceptional condition — it is an ordinary, predictable outcome the client needs to render nicely. Throwing it into the top-level errors array forces clients to string-match error messages and loses the partial data. Instead, every mutation takes a single input object and returns a dedicated payload object that carries a structured userErrors array alongside the result.

This keeps the transport layer for genuine faults — auth failures, timeouts, bugs — and moves recoverable, field-level validation into typed data the client can bind to form fields. The field path tells the client which input caused the problem; the message is display-ready. This is the same discipline that makes writes safe to retry, and it pairs naturally with idempotency keys on the mutations that must not run twice.

# Nullability, connections and mutation payloads in one schema

type Post {
  id: ID!
  title: String!
  # nullable: depends on a downstream profile service
  authorName: String
}

# Relay-style pagination
type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type PostEdge {
  cursor: String!
  node: Post!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

type Query {
  posts(first: Int, after: String, last: Int, before: String): PostConnection!
}

# One input object in, one payload object out
input CreatePostInput {
  title: String!
  body: String!
}

type UserError {
  # path to the offending input field, e.g. ["title"]
  field: [String!]!
  message: String!
}

type CreatePostPayload {
  post: Post
  userErrors: [UserError!]!
}

type Mutation {
  createPost(input: CreatePostInput!): CreatePostPayload!
}

The resolver returns the payload shape directly. Note that a validation failure is a normal return value, not a thrown error:

// resolvers.ts — Apollo Server 4, TypeScript 5
const resolvers = {
  Mutation: {
    async createPost(_parent, { input }, ctx) {
      const errors = [];
      if (input.title.trim().length === 0) {
        errors.push({ field: ["title"], message: "Title is required." });
      }
      if (errors.length > 0) {
        // recoverable: return the payload, do not throw
        return { post: null, userErrors: errors };
      }
      const post = await ctx.posts.create(input);
      return { post, userErrors: [] };
    },
  },
};

Version by addition, never by breakage

GraphQL has no /v2. The design intent is that a single evolving schema is versioned by addition: you add fields, add optional arguments, add types. You do not remove or rename what clients already use. When a field must go, you mark it @deprecated(reason: "use X"), leave it working, and watch field-level usage metrics until traffic drops to zero before removal. Introspection tooling and IDEs surface the deprecation to developers automatically.

The safe changes are additive; the breaking ones are removals, renames, type changes, and — as above — loosening non-null to nullable. The way to hold that line is not discipline, it is automation: run a schema-diff check in CI that fails the build on any breaking change, exactly the way a pre-commit and CI baseline catches the mistakes people otherwise merge. A contract that computers enforce survives staff turnover; a contract in a wiki does not.

These conventions compound. Consistent nullability, connection-based lists and payload-wrapped mutations give you a graph where a client author can predict the shape of a type they have never seen, which is the whole point of a typed API — the same reason a well-scoped API credential model pays for itself long after the initial build.

The schema is the most expensive thing in a GraphQL system to change and the cheapest thing to get right at the start. Spend the design time before the first client ships, because after that, every shortcut is a migration.

Build and rescue work

Hands-on delivery of this kind is handled by Sixteen Pillars Studio.

Free interactive tool

Website compliance checklist

What your site has to do, based on what it actually does

Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.

Can you trust the architecture you have?

Architecture diagrams rarely show the reality of how systems actually operate. An independent review establishes what is really there.