5 min read
On this page

Schema Design

The GraphQL schema is the contract between your API and every client that consumes it. It defines what data exists, how it relates, and what operations are allowed. A well-designed schema is intuitive to query, easy to evolve, and hard to misuse. A poorly designed schema becomes a source of confusion, performance problems, and breaking changes.

Get the schema right before building resolvers. Changing a resolver is a refactor. Changing a schema is a migration.

The Building Blocks

Types

Types are the core of a GraphQL schema. Every piece of data has a type:

type User {
  id: ID!
  email: String!
  name: String!
  role: UserRole!
  createdAt: DateTime!
  orders(first: Int, after: String): OrderConnection!
}

type Order {
  id: ID!
  status: OrderStatus!
  totalCents: Int!
  currency: String!
  items: [LineItem!]!
  customer: User!
  createdAt: DateTime!
}

type LineItem {
  id: ID!
  product: Product!
  quantity: Int!
  unitPriceCents: Int!
}

Each field has a name and a return type. The ! means non-nullable — the field is guaranteed to return a value (or the entire parent object errors). Without !, the field can return null.

Queries

Queries are read operations. They are the entry points for fetching data:

type Query {
  user(id: ID!): User
  users(first: Int, after: String, role: UserRole): UserConnection!
  order(id: ID!): Order
  viewer: User!
}

The viewer pattern (used by GitHub's GraphQL API) returns the currently authenticated user without requiring an ID. It is a useful convention for "me" endpoints.

Mutations

Mutations are write operations. They change state and return the result:

type Mutation {
  createUser(input: CreateUserInput!): CreateUserPayload!
  updateUser(input: UpdateUserInput!): UpdateUserPayload!
  deleteUser(id: ID!): DeleteUserPayload!
  cancelOrder(input: CancelOrderInput!): CancelOrderPayload!
}

Subscriptions

Subscriptions are real-time operations. The server pushes updates to the client:

type Subscription {
  orderStatusChanged(orderId: ID!): Order!
  newMessage(channelId: ID!): Message!
}

Subscriptions require WebSocket support and add operational complexity. Use them only when polling is insufficient.

Schema-First vs Code-First

Schema-First

Write the .graphql schema file first, then implement resolvers that match it. The schema is the design artifact — reviewable, diffable, and understandable without reading code.

# schema.graphql — written first, reviewed by the team
type Query {
  product(id: ID!): Product
  products(category: String, first: Int, after: String): ProductConnection!
}

type Product {
  id: ID!
  name: String!
  description: String!
  priceCents: Int!
  category: String!
  inStock: Boolean!
}

Tools like Apollo Server and graphql-tools load the schema from .graphql files and wire resolvers to it.

Code-First

Define the schema in code using a library like Nexus (TypeScript), Strawberry (Python), or gqlgen (Go). The schema is generated from type definitions in your programming language.

Code-first gives you autocompletion and type checking in your editor, but the schema is harder to review in pull requests because it is spread across code files rather than concentrated in one .graphql file.

Recommendation: schema-first for teams where the schema is a design artifact reviewed by multiple stakeholders. Code-first for teams where the backend developers are the primary schema designers and want tight integration with their type system.

Naming Conventions

Fields: camelCase

GraphQL convention is camelCase for field names:

type User {
  id: ID!
  firstName: String!
  lastName: String!
  emailAddress: String!
  createdAt: DateTime!
}

Types: PascalCase

Type names are PascalCase. Be specific — User, not Data. OrderConnection, not Connection.

Enums: SCREAMING_SNAKE_CASE

enum OrderStatus {
  PENDING
  CONFIRMED
  SHIPPED
  DELIVERED
  CANCELED
}

enum UserRole {
  ADMIN
  MEMBER
  VIEWER
}

Mutations: verbNoun

Name mutations as actions: createUser, updateOrder, cancelSubscription. The verb describes what happens; the noun describes what it happens to.

Nullable vs Non-Nullable Fields

The ! suffix means a field will never return null. Be deliberate about this choice:

type User {
  id: ID!           # Always exists — non-nullable
  email: String!    # Required for all users — non-nullable
  name: String!     # Required — non-nullable
  phone: String     # Optional — nullable
  avatarUrl: String # May not have an avatar — nullable
}

Default to non-nullable. If a field can always be resolved, make it non-nullable. This gives clients stronger guarantees and simpler code (no null checks).

Make fields nullable when:

  • The data genuinely may not exist (phone number, avatar)
  • The field depends on an external service that might fail (you want to return partial data rather than erroring the whole object)
  • You might remove the field in the future (nullable fields can be deprecated and returned as null)

GitHub's GraphQL API uses nullable fields for data that may not be set:

type Repository {
  name: String!          # Always has a name
  description: String    # May not have a description
  homepageUrl: URI       # May not have a homepage
  licenseInfo: License   # May not have a license
}

Input Types for Mutations

Use dedicated input types for mutation arguments. Do not reuse output types as inputs:

input CreateUserInput {
  email: String!
  name: String!
  role: UserRole
}

input UpdateUserInput {
  id: ID!
  email: String
  name: String
  role: UserRole
}

type Mutation {
  createUser(input: CreateUserInput!): CreateUserPayload!
  updateUser(input: UpdateUserInput!): UpdateUserPayload!
}

Notice that CreateUserInput has email and name as required, but UpdateUserInput has them as optional (only update the fields that are provided).

Mutation Payloads

Return a payload type from mutations, not the raw resource. The payload can include the resource, errors, and metadata:

type CreateUserPayload {
  user: User
  errors: [UserError!]!
}

type UserError {
  field: String!
  message: String!
  code: ErrorCode!
}

enum ErrorCode {
  INVALID_EMAIL
  DUPLICATE_EMAIL
  NAME_TOO_LONG
  UNAUTHORIZED
}

This pattern (used by Shopify's GraphQL API) allows mutations to return domain-specific errors alongside the result, without relying on GraphQL-level errors for business logic.

Connections & Pagination

The Relay connection pattern is the standard for paginated lists in GraphQL:

type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type UserEdge {
  node: User!
  cursor: String!
}

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

Queried like:

query {
  users(first: 10, after: "cursor_abc") {
    edges {
      node {
        id
        name
        email
      }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
    totalCount
  }
}

This is more verbose than a simple list, but it supports cursor-based pagination, which is more reliable than offset-based pagination for real-time data.

Common Pitfalls

  • Exposing database schema — naming types and fields after database tables and columns instead of domain concepts. The schema should reflect the API consumer's mental model, not the storage layer.
  • Everything nullable — making all fields nullable "just in case." This pushes null-handling complexity to every client. Default to non-nullable and make fields nullable only with a specific reason.
  • Reusing types for input and output — using the same User type for both query results and mutation inputs. Input types have different requirements (some fields are required for creation, optional for update, and absent from input entirely like id or createdAt).
  • Giant mutation arguments — passing 15 arguments to a mutation instead of grouping them in an input type. Input types are easier to document, validate, and evolve.
  • No pagination on lists — returning unbounded lists. Any field that returns a list of items that could grow should use pagination (connections or simple first/after arguments).
  • Stringly-typed fields — using String for fields that have a fixed set of values. Use enums: OrderStatus instead of String, Currency instead of String.
  • Breaking changes without deprecation — removing or renaming fields without the @deprecated directive. Deprecate first, monitor usage, then remove.

Key Takeaways

  • The schema is the contract. Design it before building resolvers. Review schema changes with the same rigor as API endpoint changes.
  • Use non-nullable (!) by default. Nullable fields should have an explicit reason.
  • Use input types for mutations and payload types for mutation responses. Do not reuse output types as inputs.
  • Use enums for fields with fixed values. They provide type safety and self-documentation.
  • Follow naming conventions: camelCase for fields, PascalCase for types, SCREAMING_SNAKE_CASE for enum values, verbNoun for mutations.
  • Schema-first design is better for cross-team collaboration. Code-first is better for tight backend integration. Choose based on who reviews the schema.
  • Paginate all list fields using the connection pattern or simple cursor arguments. Unbounded lists are a performance and reliability risk.