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.