GraphQL Schema Design Best Practices

GraphQL gives you a lot of flexibility in how you design your schema. That flexibility is also a trap — a poorly designed schema is hard to query efficiently, painful to evolve, and confusing for clients. This guide covers the fundamentals of GraphQL schema design: types, queries, mutations, subscriptions, the N+

TRY NANO BANANA FOR FREE

GraphQL Schema Design Best Practices

TRY NANO BANANA FOR FREE
Contents

GraphQL gives you a lot of flexibility in how you design your schema. That flexibility is also a trap — a poorly designed schema is hard to query efficiently, painful to evolve, and confusing for clients.

This guide covers the fundamentals of GraphQL schema design: types, queries, mutations, subscriptions, the N+1 problem and how DataLoader solves it, schema stitching, federation, and how to handle schema versioning.

The Basics: Types

Everything in GraphQL starts with types. Your schema is a graph of types connected by fields.

Scalar Types

GraphQL has five built-in scalars:

String   # UTF-8 string
Int      # 32-bit integer
Float    # Double-precision float
Boolean  # true or false
ID       # Unique identifier (serialized as String)

You can define custom scalars for things like dates:

scalar DateTime
scalar URL
scalar JSON

Object Types

Object types are the building blocks of your schema:

type Pet {
  id: ID!
  name: String!
  status: PetStatus!
  category: Category
  photoUrls: [String!]!
  tags: [Tag!]!
  createdAt: DateTime!
  updatedAt: DateTime!
}

type Category {
  id: ID!
  name: String!
}

type Tag {
  id: ID!
  name: String!
}

The ! means non-null. [String!]! means a non-null list of non-null strings.

Enums

Use enums for fields with a fixed set of values:

enum PetStatus {
  AVAILABLE
  PENDING
  SOLD
}

enum OrderStatus {
  PLACED
  APPROVED
  DELIVERED
}

Input Types

Input types are used for mutation arguments. Don't reuse output types as inputs:

input CreatePetInput {
  name: String!
  status: PetStatus!
  categoryId: ID
  photoUrls: [String!]
  tagIds: [ID!]
}

input UpdatePetInput {
  name: String
  status: PetStatus
  categoryId: ID
  photoUrls: [String!]
  tagIds: [ID!]
}

Interfaces and Unions

Interfaces define shared fields across types:

interface Node {
  id: ID!
}

interface Timestamped {
  createdAt: DateTime!
  updatedAt: DateTime!
}

type Pet implements Node & Timestamped {
  id: ID!
  name: String!
  createdAt: DateTime!
  updatedAt: DateTime!
}

type Order implements Node & Timestamped {
  id: ID!
  status: OrderStatus!
  createdAt: DateTime!
  updatedAt: DateTime!
}

Unions are for fields that can return different types:

union SearchResult = Pet | Order | User

type Query {
  search(query: String!): [SearchResult!]!
}

Clients use inline fragments to handle each type:

query {
  search(query: "fluffy") {
    ... on Pet {
      id
      name
      status
    }
    ... on Order {
      id
      status
    }
  }
}

Queries

Queries are read operations. Design them around how clients actually need data, not around your database schema.

Basic Queries

type Query {
  pet(id: ID!): Pet
  pets(status: PetStatus, limit: Int, offset: Int): PetConnection!
  order(id: ID!): Order
  orders(status: OrderStatus, limit: Int, offset: Int): OrderConnection!
}

Pagination With Connections

The Relay connection pattern is the standard for paginated lists:

type PetConnection {
  edges: [PetEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type PetEdge {
  node: Pet!
  cursor: String!
}

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

Clients can paginate forward or backward:

# First page
query {
  pets(first: 10) {
    edges {
      node { id name status }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

# Next page
query {
  pets(first: 10, after: "cursor-from-previous-page") {
    edges {
      node { id name status }
    }
    pageInfo { hasNextPage endCursor }
  }
}

Filtering and Sorting

input PetFilter {
  status: PetStatus
  categoryId: ID
  tagIds: [ID!]
  nameContains: String
}

enum PetSortField {
  NAME
  CREATED_AT
  STATUS
}

enum SortDirection {
  ASC
  DESC
}

input PetSort {
  field: PetSortField!
  direction: SortDirection!
}

type Query {
  pets(
    filter: PetFilter
    sort: PetSort
    first: Int
    after: String
  ): PetConnection!
}

Mutations

Mutations change data. Follow a consistent pattern: take an input type, return a payload type.

type Mutation {
  createPet(input: CreatePetInput!): CreatePetPayload!
  updatePet(id: ID!, input: UpdatePetInput!): UpdatePetPayload!
  deletePet(id: ID!): DeletePetPayload!
  createOrder(input: CreateOrderInput!): CreateOrderPayload!
}

type CreatePetPayload {
  pet: Pet
  errors: [UserError!]!
}

type UpdatePetPayload {
  pet: Pet
  errors: [UserError!]!
}

type DeletePetPayload {
  deletedId: ID
  errors: [UserError!]!
}

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

The payload pattern lets you return both the result and any validation errors without using HTTP error codes:

mutation {
  createPet(input: {
    name: "Fluffy",
    status: AVAILABLE
  }) {
    pet {
      id
      name
    }
    errors {
      field
      message
    }
  }
}

If there are errors, pet is null and errors has content. If it succeeds, pet has the new pet and errors is empty.

Resolver Implementation

const resolvers = {
  Mutation: {
    createPet: async (_, { input }, { db, user }) => {
      // Authorization
      if (!user) {
        return {
          pet: null,
          errors: [{ code: 'UNAUTHORIZED', message: 'You must be logged in' }]
        };
      }

      // Validation
      const errors = validateCreatePetInput(input);
      if (errors.length > 0) {
        return { pet: null, errors };
      }

      // Create the pet
      const pet = await db.pets.create({
        name: input.name,
        status: input.status,
        categoryId: input.categoryId
      });

      return { pet, errors: [] };
    }
  }
};

Subscriptions

Subscriptions push real-time updates to clients over WebSocket.

type Subscription {
  petStatusChanged(petId: ID!): PetStatusChangedEvent!
  orderUpdated(orderId: ID!): OrderUpdatedEvent!
  newPetAvailable(categoryId: ID): Pet!
}

type PetStatusChangedEvent {
  pet: Pet!
  previousStatus: PetStatus!
  newStatus: PetStatus!
  changedAt: DateTime!
}

Server implementation with Apollo Server and Redis pub/sub:

const { RedisPubSub } = require('graphql-redis-subscriptions');
const pubsub = new RedisPubSub();

const resolvers = {
  Subscription: {
    petStatusChanged: {
      subscribe: (_, { petId }) => {
        return pubsub.asyncIterator(`PET_STATUS_CHANGED:${petId}`);
      }
    },
    newPetAvailable: {
      subscribe: (_, { categoryId }) => {
        const channel = categoryId
          ? `NEW_PET:${categoryId}`
          : 'NEW_PET';
        return pubsub.asyncIterator(channel);
      }
    }
  },
  Mutation: {
    updatePet: async (_, { id, input }, { db }) => {
      const oldPet = await db.pets.findById(id);
      const pet = await db.pets.update(id, input);

      // Publish the event
      if (oldPet.status !== pet.status) {
        await pubsub.publish(`PET_STATUS_CHANGED:${id}`, {
          petStatusChanged: {
            pet,
            previousStatus: oldPet.status,
            newStatus: pet.status,
            changedAt: new Date().toISOString()
          }
        });
      }

      return { pet, errors: [] };
    }
  }
};

Client subscription:

const { gql, useSubscription } = require('@apollo/client');

const PET_STATUS_SUBSCRIPTION = gql`
  subscription OnPetStatusChanged($petId: ID!) {
    petStatusChanged(petId: $petId) {
      pet { id name status }
      previousStatus
      newStatus
      changedAt
    }
  }
`;

function PetStatusWatcher({ petId }) {
  const { data, loading } = useSubscription(PET_STATUS_SUBSCRIPTION, {
    variables: { petId }
  });

  if (loading) return <p>Watching for updates...</p>;

  return (
    <p>
      Status changed from {data.petStatusChanged.previousStatus}
      to {data.petStatusChanged.newStatus}
    </p>
  );
}

The N+1 Problem

The N+1 problem is the most common performance issue in GraphQL. It happens when resolving a list of items triggers one database query per item.

query {
  pets(first: 10) {
    edges {
      node {
        id
        name
        category {  # This triggers a DB query for each pet
          name
        }
      }
    }
  }
}

Without DataLoader, this runs 11 queries: 1 to get the pets, then 1 per pet to get its category. With 100 pets, that's 101 queries.

DataLoader to the Rescue

DataLoader batches and caches database calls:

const DataLoader = require('dataloader');

// Create a DataLoader for categories
const categoryLoader = new DataLoader(async (categoryIds) => {
  // One query for all category IDs
  const categories = await db.query(
    'SELECT * FROM categories WHERE id = ANY($1)',
    [categoryIds]
  );

  // Return in the same order as the input IDs
  return categoryIds.map(id =>
    categories.find(c => c.id === id) || null
  );
});

const resolvers = {
  Pet: {
    category: (pet, _, { loaders }) => {
      if (!pet.categoryId) return null;
      return loaders.category.load(pet.categoryId);
    }
  }
};

Now instead of 10 separate queries, DataLoader batches them into one:

-- Without DataLoader: 10 queries
SELECT * FROM categories WHERE id = 1;
SELECT * FROM categories WHERE id = 2;
-- ... 8 more

-- With DataLoader: 1 query
SELECT * FROM categories WHERE id = ANY(ARRAY[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);

Setting Up Loaders in Context

Create fresh DataLoader instances per request (important for cache isolation):

const { ApolloServer } = require('@apollo/server');

const server = new ApolloServer({ typeDefs, resolvers });

app.use('/graphql', expressMiddleware(server, {
  context: async ({ req }) => ({
    user: await getUser(req.headers.authorization),
    db,
    loaders: {
      category: new DataLoader(async (ids) => {
        const categories = await db.categories.findByIds(ids);
        return ids.map(id => categories.find(c => c.id === id));
      }),
      tag: new DataLoader(async (ids) => {
        const tags = await db.tags.findByIds(ids);
        return ids.map(id => tags.find(t => t.id === id));
      }),
      petsByCategory: new DataLoader(async (categoryIds) => {
        const pets = await db.pets.findByCategoryIds(categoryIds);
        return categoryIds.map(id => pets.filter(p => p.categoryId === id));
      })
    }
  })
}));

Schema Stitching

Schema stitching combines multiple GraphQL schemas into one. Useful when you have separate services with their own schemas.

const { stitchSchemas } = require('@graphql-tools/stitch');
const { buildSchema } = require('graphql');

const petSchema = buildSchema(`
  type Pet {
    id: ID!
    name: String!
    status: String!
  }
  type Query {
    pet(id: ID!): Pet
  }
`);

const orderSchema = buildSchema(`
  type Order {
    id: ID!
    petId: ID!
    status: String!
  }
  type Query {
    order(id: ID!): Order
  }
`);

// Stitch them together and add cross-schema links
const stitchedSchema = stitchSchemas({
  subschemas: [
    { schema: petSchema },
    { schema: orderSchema }
  ],
  typeDefs: `
    extend type Order {
      pet: Pet
    }
  `,
  resolvers: {
    Order: {
      pet: {
        selectionSet: '{ petId }',
        resolve(order, _, context, info) {
          return delegateToSchema({
            schema: petSchema,
            operation: 'query',
            fieldName: 'pet',
            args: { id: order.petId },
            context,
            info
          });
        }
      }
    }
  }
});

Federation

Apollo Federation is the production-grade approach to splitting a GraphQL API across multiple services. Each service owns part of the schema and can extend types from other services.

Pet Service

# pet-service/schema.graphql
type Pet @key(fields: "id") {
  id: ID!
  name: String!
  status: PetStatus!
  category: Category
}

type Category {
  id: ID!
  name: String!
}

enum PetStatus {
  AVAILABLE
  PENDING
  SOLD
}

type Query {
  pet(id: ID!): Pet
  pets(status: PetStatus): [Pet!]!
}

Order Service

# order-service/schema.graphql
type Order @key(fields: "id") {
  id: ID!
  status: OrderStatus!
  quantity: Int!
  pet: Pet!  # Reference to Pet from pet-service
}

# Extend Pet from pet-service
extend type Pet @key(fields: "id") {
  id: ID! @external
  orders: [Order!]!  # Add orders to Pet
}

enum OrderStatus {
  PLACED
  APPROVED
  DELIVERED
}

type Query {
  order(id: ID!): Order
}

Order Service Resolver

const resolvers = {
  Pet: {
    // Resolve the orders field added to Pet
    orders: async (pet, _, { db }) => {
      return db.orders.findByPetId(pet.id);
    },
    // Reference resolver - fetches a Pet by its key
    __resolveReference: async (pet, { db }) => {
      return db.pets.findById(pet.id);
    }
  }
};

The Apollo Router (or Gateway) combines these into a single endpoint that clients query normally.

Schema Versioning

GraphQL doesn't have built-in versioning like REST. The recommended approach is to evolve the schema without breaking changes.

Deprecate Fields Instead of Removing Them

type Pet {
  id: ID!
  name: String!
  # @deprecated tells clients this field will be removed
  petId: ID @deprecated(reason: "Use id instead. Will be removed in 2025.")
  status: PetStatus!
}

Clients using deprecated fields will see warnings in their IDE and in introspection tools.

Add New Fields, Don't Change Existing Ones

type Pet {
  id: ID!
  name: String!
  # Old field - keep for backwards compatibility
  category: String @deprecated(reason: "Use categoryObject instead")
  # New field with richer data
  categoryObject: Category
}

Use Feature Flags for Experimental Fields

type Pet {
  id: ID!
  name: String!
  # Only available to beta users
  aiDescription: String
}
const resolvers = {
  Pet: {
    aiDescription: async (pet, _, { user }) => {
      if (!user.betaFeatures.includes('ai-descriptions')) {
        return null;
      }
      return generateAIDescription(pet);
    }
  }
};

PetStore API: Complete Schema Example

Putting it all together:

scalar DateTime

type Query {
  pet(id: ID!): Pet
  pets(filter: PetFilter, sort: PetSort, first: Int, after: String): PetConnection!
  order(id: ID!): Order
  search(query: String!): [SearchResult!]!
}

type Mutation {
  createPet(input: CreatePetInput!): CreatePetPayload!
  updatePet(id: ID!, input: UpdatePetInput!): UpdatePetPayload!
  deletePet(id: ID!): DeletePetPayload!
  createOrder(input: CreateOrderInput!): CreateOrderPayload!
}

type Subscription {
  petStatusChanged(petId: ID!): PetStatusChangedEvent!
  newPetAvailable(categoryId: ID): Pet!
}

type Pet implements Node {
  id: ID!
  name: String!
  status: PetStatus!
  category: Category
  tags: [Tag!]!
  photoUrls: [String!]!
  orders: [Order!]!
  createdAt: DateTime!
  updatedAt: DateTime!
}

type Order implements Node {
  id: ID!
  pet: Pet!
  quantity: Int!
  shipDate: DateTime
  status: OrderStatus!
  complete: Boolean!
  createdAt: DateTime!
}

interface Node {
  id: ID!
}

union SearchResult = Pet | Order

enum PetStatus { AVAILABLE PENDING SOLD }
enum OrderStatus { PLACED APPROVED DELIVERED }

type PetConnection {
  edges: [PetEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type PetEdge {
  node: Pet!
  cursor: String!
}

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

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

type CreatePetPayload {
  pet: Pet
  errors: [UserError!]!
}

input CreatePetInput {
  name: String!
  status: PetStatus!
  categoryId: ID
  tagIds: [ID!]
  photoUrls: [String!]
}

Summary

Good GraphQL schema design comes down to a few principles:

  • Model your schema around client needs, not your database
  • Use the connection pattern for paginated lists
  • Follow the mutation payload pattern for consistent error handling
  • Always use DataLoader to batch database calls and avoid N+1 queries
  • Use subscriptions for real-time updates, not polling
  • Deprecate fields before removing them
  • Use federation when you need to split across services

The schema is a contract with your clients. Design it carefully upfront, because changing it later — while possible — requires coordination and care.