GraphQL Schema Design

This section documents the current GraphQL specification (October 2021), plus the working draft for @defer/@stream/@oneOf, and the GraphQL-over-HTTP specification, as published at graphql.org and spec.graphql.org — which are the references these pages are written and verified against — and, for the integration pages, against Spring for GraphQL (2.0.x), Strawberry, Ariadne, and the client docs for Apollo Client / urql / Relay. No specific patch version is pinned. Some surfaces (Apollo Router/managed federation, graphql-ws internals, the Relay compiler internals, GraalVM native) are linked rather than documented in depth.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, as GraphQL tooling iterates quickly.

A schema is an API’s contract with every client that will ever query it, so the choices made while designing one — how it is authored, how strict its nullability is, and how it is expected to grow — outlive any single release far more than the resolvers behind it. This page collects those choices as a set of conventions rather than language features.

Schema-first vs. code-first

Two workflows produce the same runtime schema, and the difference is where the source of truth for the type system lives:

Approach How it works

Schema-first

The SDL is written by hand (or generated from a design tool) as .graphql/.graphqls files, checked into source control, and resolvers are wired up afterward to satisfy it. The schema is reviewable in a pull request independently of any implementation language.

Code-first

The schema is derived from the host language’s own types and decorators/annotations (for example, Strawberry’s Python dataclasses or a builder API), and the SDL is generated as a build artifact. The schema can never drift from the code that implements it, at the cost of the schema no longer being the reviewable, language-neutral artifact.

Schema-first is the more common convention for polyglot teams and for schemas that are designed collaboratively before implementation exists (see Schema & type system for the SDL syntax itself), which is why every example on this page is written as SDL regardless of which workflow produces it at build time. Python: FastAPI + Strawberry and Python: Ariadne cover the code-first and schema-first ends of that same trade-off for one specific stack. Neither approach is prescribed by the specification itself — see graphql.org/learn/schema-design for the foundation’s own framing of schema design as independent of implementation language.

A deliberate nullability strategy

Schema & type system already covers what the ! modifier means at the type level; schema design is about deciding, deliberately and consistently, when to reach for it rather than defaulting one way. Two failure modes sit on either side of that decision:

  • Over-using !. A field marked non-null promises every client that it will never be null — which also means that if its resolver ever fails, GraphQL’s null-propagation rule (see Response & error handling) walks the failure up to the nearest nullable ancestor, discarding an entire sibling subtree of otherwise-successful data just because one non-null field could not be resolved.

  • Under-using !. Making everything nullable "to be safe" pushes every null-check onto every client, for every field, forever — defeating one of the reasons to have a typed schema at all.

A workable default: mark a field non-null only when its absence would make the rest of the response meaningless anyway (an object’s own id, a list field that should return [] rather than null when empty), and leave anything that depends on an external system, a downstream service, or optional data nullable so a partial failure stays partial:

type Order {
  id: ID!                 # always present if the Order resolved at all
  items: [OrderItem!]!    # empty list rather than null when there are no items
  shippingAddress: Address # optional: not every order ships physically
  trackingStatus: TrackingStatus # depends on a downstream carrier API -- may fail independently
}

Note the difference between [OrderItem!]! (a non-null list of non-null items — never null, never containing a null element, at worst an empty array) and a looser [OrderItem] (the list itself, and each element, may independently be null). Pick the tightest shape that still tolerates the failures you actually expect from that field’s own resolver, and see graphql.org/learn/best-practices (Nullability section) for the foundation’s own guidance on this trade-off.

Designing for evolution without versioning

A REST API commonly evolves by shipping /v2/…​ alongside /v1/…​. GraphQL schemas are not versioned that way in practice — the convention is a single, continuously evolving schema, kept backward compatible through additive change:

  • Add, don’t change. A new field, type, or enum value can be added to a schema at any time without breaking an existing client, because a client only ever receives the fields it explicitly selected in its query. Existing queries keep working exactly as before.

  • Deprecate, don’t delete. A field that is no longer the preferred way to get some data is marked @deprecated rather than removed outright, so existing clients keep functioning while new clients (and tooling that surfaces deprecation warnings, such as GraphiQL’s schema explorer) are steered toward the replacement:

    type Product {
      name: String!
      price: Float! @deprecated(reason: "Use priceInCents for currency-safe arithmetic.")
      priceInCents: Int!
    }

    Directives covers @deprecated’s full argument shape and where clients and tools surface its `reason string.

  • Avoid breaking renames. Renaming or removing a field, changing its type incompatibly, or tightening a nullable field to non-null are all breaking changes for any client still selecting it — there is no server-side way to know which clients depend on a given field before removing it. Add the new field under its intended name, deprecate the old one, and give clients a real migration window (tracked through the monitoring approach in Security & demand control, which covers observing which deprecated fields are still in active use) before ever deleting a deprecated field.

The before/after below is the same Product type evolving twice without ever breaking a client that queried it at either point in its history:

# v0: original shape
type Product {
  name: String!
  price: Float!
}

# v1: priceInCents added, price deprecated -- both fields still resolve
type Product {
  name: String!
  price: Float! @deprecated(reason: "Use priceInCents for currency-safe arithmetic.")
  priceInCents: Int!
}

# v2: price finally removed, once usage monitoring shows no client still selects it
type Product {
  name: String!
  priceInCents: Int!
}

This additive discipline is why a GraphQL schema is often described as having no versions at all, only a continuously growing (and selectively deprecated) present — see graphql.org/learn/best-practices (Versioning section) for the foundation’s own statement of this convention.

Naming conventions

Consistency in naming is what makes a large schema navigable without constantly consulting documentation. The conventions below are widely followed across the GraphQL ecosystem rather than enforced by the specification itself:

Element Convention

Types, interfaces, unions, input objects

PascalCase (Product, SearchResult, CreateOrderInput)

Fields and arguments

camelCase (publishedYear, first, orderBy)

Enum values

SCREAMING_SNAKE_CASE (PENDING, SHIPPED, CANCELLED)

Mutation fields

a verbNoun imperative naming the action, not the payload (createOrder, not orderCreation or newOrder)

Input object types for a mutation

<MutationName>Input, PascalCase (createOrder takes a CreateOrderInput)

Payload/result object types for a mutation

<MutationName>Payload, PascalCase (createOrder returns a CreateOrderPayload)

input CreateOrderInput {
  customerId: ID!
  items: [OrderItemInput!]!
}

type CreateOrderPayload {
  order: Order
  errors: [OrderError!]!
}

type Mutation {
  createOrder(input: CreateOrderInput!): CreateOrderPayload!
}

Mutation-payload conventions

The <MutationName>Input / <MutationName>Payload naming above pairs with a payload shape convention that Mutations already develops in full — a single input argument wrapping every scalar parameter, and a payload object (rather than a bare scalar or the mutated type itself) so new fields such as a structured errors list can be added to the payload later without a breaking change to the mutation’s return type. Schema design’s contribution to that convention is naming it consistently and applying it to every mutation in the schema, even ones that feel simple enough today to return a bare type — because retrofitting a payload wrapper onto a mutation that originally returned Order! directly is itself a breaking change once clients depend on the unwrapped shape.

Pagination-field conventions

A schema exposing a list that can grow without bound (search results, an order’s history, a user’s notifications) should commit to a pagination shape from the start rather than retrofitting one once a list gets too large for the previous shape to handle without a breaking change. The Relay-style connection pattern is the de facto standard for this:

type Query {
  products(first: Int, after: String): ProductConnection!
}

type ProductConnection {
  edges: [ProductEdge!]!
  pageInfo: PageInfo!
}

type ProductEdge {
  cursor: String!
  node: Product!
}

Pagination covers the connection type shape, cursor semantics, and PageInfo fields in full — schema design’s role is committing to <Field>Connection / <Field>Edge naming consistently across every paginated field in the schema, so a client that has learned to paginate one connection can paginate any other by the same pattern.

Not leaking storage shape into the graph

A schema should describe the domain an API exposes, not the tables, columns, or internal service boundaries behind it. A few concrete tells that a schema has started mirroring its storage layer instead of its domain:

  • A foreign-key-shaped field (authorId: ID! on Book) exposed instead of the related object itself (author: Author!), forcing every client to issue a second query just to follow a relationship GraphQL is meant to resolve server-side in one round trip.

  • A field named after a database column (created_at, is_deleted) rather than the domain concept it represents (createdAt, and simply omitting soft-deleted records from query results rather than exposing the deletion flag at all).

  • A type that exists only because two backing services happen to be split that way, forcing clients to know and navigate an internal architectural seam that has nothing to do with the domain.

# Leaks storage/service shape
type Book {
  id: ID!
  author_id: ID!
  is_deleted: Boolean!
}

# Describes the domain instead
type Book {
  id: ID!
  author: Author!
}

Keeping the graph aligned with the domain rather than the storage layer is also what makes the additive evolution described earlier actually work in practice — a domain-shaped schema tends to grow by adding new domain concepts, while a storage-shaped schema tends to break every time the underlying storage is refactored, even when nothing about the domain itself changed.

Schema ownership and governance at scale

A schema owned by a single small team rarely needs process beyond code review. As more teams contribute types and fields to the same graph — whether in one monolithic schema or composed from several subgraphs, see Federation — a few governance practices keep the graph coherent instead of accumulating naming drift, duplicate concepts, and conflicting conventions from whichever team touched it last:

  • A schema review step (a linter, a required reviewer group, or both) that checks new or changed fields against the naming and nullability conventions above before merge, rather than relying on every contributor to remember them.

  • A single, discoverable place recording which team owns which type or field, so a consumer with a question (or a proposed breaking change) knows who to ask.

  • A deprecation policy with a stated minimum window (for example, "deprecated fields stay resolvable for at least two quarters") backed by the usage monitoring mentioned earlier, so "deprecated" has a predictable, trusted meaning across the whole graph rather than being a synonym for "already removed in spirit."

None of this is mandated by the specification — it is organizational practice that becomes necessary once a schema has more than one team contributing to it, and Best Practices covers the ecosystem’s broader consensus on schema stewardship.

  • Schema & type system — the SDL syntax and type-system primitives this page assumes as background.

  • Directives — @deprecated and the other built-in directives used to evolve a schema in place.

  • Mutations — the full input/payload convention referenced above.

  • Pagination — the connection pattern referenced above, in depth.

  • Security & demand control — monitoring field usage before removing a deprecated field.

  • Federation — schema composition and ownership once a graph spans more than one service.