GraphQL Schema Design
The schema is the contract of a GraphQL API. It declares every type, field, argument, query, and mutation a client can use, and the server guarantees responses match it. Because clients select exactly the fields they need and tooling generates types from the schema, a well-designed schema is a pleasure to use for years. A poorly designed one leaks database tables, forces awkward client code, and becomes hard to change without breaking someone.
GraphQL APIs are usually evolved continuously rather than versioned, so design decisions like nullability, mutation shapes, and error modeling matter up front. This page covers the principles that make schemas intuitive, safe, and evolvable.
TL;DR
- Model the schema around client use cases and the domain, not your database tables.
- Be deliberate with nullability: nullable fields tolerate partial failure; non-null (
!) is a promise that's hard to take back. - Give each mutation a single input type and a payload type, with room for errors and future fields.
- Use interfaces and unions for polymorphism, and a
Nodeinterface with global IDs for refetching. - Model expected errors as data (result unions or error fields); reserve top-level
errorsfor unexpected failures. - Evolve, don't version: add fields freely, deprecate with
@deprecated, and track field usage before removing anything.
Quick Example
Clients handle each outcome with type-safe branches instead of parsing error strings.
Core Concepts
Schema-First vs Code-First
- Schema-first: write SDL (
.graphqlfiles) and implement resolvers to match (Apollo Server, graphql-tools, gqlgen in Go). The contract is explicit and reviewable. - Code-first: define types in code and generate the schema (Pothos, Nexus, TypeGraphQL, Strawberry, Hot Chocolate). You get stronger type safety between schema and implementation and less duplication.
Both are fine. What matters is that the generated or written schema is reviewed as the API contract, ideally with schema diffs in pull requests.
Design From Use Cases
A schema mirroring your database (user_id foreign keys, join tables as types) forces clients to understand your storage. Instead:
- Expose relationships as fields (
order.customer, notorder.customerId). - Name things in domain language clients understand.
- Add purpose-built fields when clients need them (
order.canBeCancelled: Boolean!) rather than making them reimplement business rules.
Nullability
In GraphQL, if a non-null field fails to resolve, the null propagates up to the nearest nullable parent, potentially wiping out a large part of the response. Guidelines:
- Make IDs, required scalar attributes, and enums non-null.
- Make fields that call other services or can fail independently nullable, so partial data still renders.
- List fields are often
[Item!]!: the list always exists and contains no null entries. - Arguments and inputs should be non-null when required. Changing an input from nullable to non-null is a breaking change, and so is changing an output from non-null to nullable.
Mutations: Input and Payload
A consistent mutation shape makes APIs predictable and evolvable:
- One input argument:
createOrder(input: CreateOrderInput!). You can add optional input fields later without changing the signature. - A dedicated payload or result type: return the modified object (so clients update caches) plus any errors or metadata, never a bare
Boolean. - Verb-first, specific names:
cancelOrder,addItemToCart, rather than a genericupdateOrderwith every field optional.
Interfaces, Unions, and Global IDs
- Interfaces share fields across types (
interface Node { id: ID! },interface Timestamped). - Unions represent "one of these unrelated types", as in search results or mutation outcomes.
- The
Nodeinterface with globally unique IDs plus anode(id: ID!)query lets clients refetch any object, which Relay-style clients rely on for caching.
Errors as Data
GraphQL's top-level errors array is untyped and easy for clients to ignore. For expected business errors (validation failures, not found, permission denied for a specific action, conflicts), return them in the schema, either as result unions like the example above, or as a userErrors: [UserError!]! field on payloads. Reserve top-level errors for unexpected failures such as bugs and outages.
Evolving a Schema
GraphQL APIs typically avoid /v2 versions. Because clients request fields explicitly, you can add new fields and types without affecting anyone.
Use a schema registry (Apollo GraphOS, Hive, Inigo) or schema-diff checks in CI to detect breaking changes, and field-usage analytics to confirm a deprecated field is unused before removing it. See API versioning.
Best Practices
Use Custom Scalars for Meaningful Types
DateTime, URL, Email, Decimal, and Money types communicate intent and validate input, unlike stringly-typed fields.
Paginate Every Unbounded List
Any list that can grow (orders, comments, items) needs pagination arguments from day one. Retrofitting pagination is a breaking change. See GraphQL pagination.
Document Everything in the Schema
Descriptions ("""…""") appear in GraphiQL, IDEs, and generated docs. Document units, formats, nullability semantics, and side effects.
Keep Naming Consistent
Use camelCase fields, PascalCase types, and SCREAMING_SNAKE_CASE enum values; name inputs XInput and payloads XPayload or XResult; and give booleans a prefix: isActive, hasShipped, canEdit.
Common Mistakes
Exposing Database Structure
Generic "Update Everything" Mutations
updateUser(input: UpdateUserInput!) with 30 optional fields hides business rules, makes authorization per field complicated, and is hard to evolve. Prefer task-oriented mutations like changeEmail and updateShippingAddress.
Non-Null Everywhere
Marking every field ! looks tidy, but one failing resolver can null out an entire query result, and relaxing non-null later is a breaking change. Be deliberate.
FAQ
Should GraphQL schemas be versioned?
Generally no. The common practice is continuous evolution: add fields, deprecate old ones, monitor usage, and remove only when unused. Some public APIs publish dated schema versions for stability guarantees, but URL-versioned GraphQL APIs are rare.
How should I return validation errors?
As data in the schema: either a union of success and error types, or a userErrors list on the mutation payload with field paths and messages. This makes error handling explicit and type-checked in clients, rather than hidden in the generic top-level errors array.
Schema-first or code-first?
Schema-first suits teams that design APIs collaboratively and want the SDL as the reviewed source of truth. Code-first suits teams that want compile-time guarantees that resolvers match the schema, with no duplication. Many mature teams use code-first but still review the generated SDL diff in PRs.
How big should a GraphQL schema be?
As big as the domain requires. Large companies run schemas with thousands of types, often composed from many services with GraphQL federation. Size matters less than consistency of naming, patterns, and ownership.
Related Topics
- GraphQL — The query language overview
- GraphQL Pagination — Connections and cursors
- GraphQL Security — Protecting a flexible API
- GraphQL Federation — Composing schemas across services
- API Design — General API design principles
- REST vs GraphQL — When GraphQL is the right choice