GraphQL Schema Design
|
This section documents the current GraphQL specification (October 2021), plus the working draft for
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 |
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 benull— which also means that if its resolver ever fails, GraphQL’s null-propagation rule (seeResponse & 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
@deprecatedrather 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! }Directivescovers@deprecated’s full argument shape and where clients and tools surface its `reasonstring. -
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 |
|
Fields and arguments |
|
Enum values |
|
Mutation fields |
a |
Input object types for a mutation |
|
Payload/result object types for a mutation |
|
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!onBook) 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.
Related pages
-
Schema & type system— the SDL syntax and type-system primitives this page assumes as background. -
Directives—@deprecatedand 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.