GraphQL Schemas and Resolvers
A practical guide to GraphQL schemas — SDL, scalar types, Query and Mutation roots, custom object types, resolver functions, and how to avoid the N+1 problem with DataLoader.
What you'll learn
- ✓How the Schema Definition Language (SDL) works
- ✓The built-in scalar types and how to define custom types
- ✓The role of Query, Mutation, and Subscription root types
- ✓How resolver functions turn a schema into running code
- ✓What the N+1 problem is and how DataLoader fixes it
Prerequisites
- •A rough idea of GraphQL — see What Is GraphQL?
- •Comfort with JavaScript and async/await
A GraphQL server is two halves bolted together: a schema that declares what the API can do, and a set of resolvers that implement each field. Once you internalise that, every server library — Apollo, Yoga, Mercurius — feels like a variation on the same shape.
This post walks through both halves with runnable examples.
The schema is the contract
The schema lives in SDL — the Schema Definition Language. It is a small, human-readable language for describing types and their fields:
# A minimal schema
type Query {
hello: String
}
This declares one type (Query) with one field (hello) that returns a String. A client can run { hello } and the server will be expected to produce a string. Nothing else.
A real schema is bigger but follows the same pattern.
Scalar types
GraphQL ships with five built-in scalars:
| Scalar | Meaning |
|---|---|
Int | 32-bit signed integer |
Float | Double-precision number |
String | UTF-8 text |
Boolean | true or false |
ID | Opaque unique identifier — serialised as a string |
ID is intentionally separate from String. It tells clients “this is an identifier, don’t try to parse or display it.”
You can also define custom scalars for things the built-ins don’t cover:
# Declared in the schema; implemented in code
scalar DateTime
scalar Email
scalar JSON
A custom scalar needs a resolver that defines how it is serialised, parsed from JSON, and parsed from literal values. Libraries like graphql-scalars provide these out of the box.
Object types
Most of your schema will be object types — structured shapes with named fields:
# A product in a small e-commerce API
type Product {
id: ID!
name: String!
description: String
price: Float!
inStock: Boolean!
tags: [String!]!
}
The ! marks a field as non-null. The list syntax [String!]! means a non-null list of non-null strings — both the list and every element are guaranteed.
Nullability is part of your contract. Once a field is non-null, removing the ! is a breaking change.
Root types
Three special object types are the entry points of every schema:
# Reads
type Query {
product(id: ID!): Product
products(limit: Int = 20, offset: Int = 0): [Product!]!
}
# Writes
type Mutation {
createProduct(input: CreateProductInput!): Product!
deleteProduct(id: ID!): Boolean!
}
# Real-time
type Subscription {
productAdded: Product!
}
A schema must declare a Query type. Mutation and Subscription are optional.
Input types
When a mutation takes structured arguments, use an input type rather than a long list of parameters:
# Input types are like object types, but for arguments
input CreateProductInput {
name: String!
description: String
price: Float!
tags: [String!] = []
}
Input types can have default values (= []), can be nested, but cannot have resolvers — they are pure data.
Enums
When a field has a fixed set of values, an enum communicates that:
# Stronger than String — clients see the allowed values
enum OrderStatus {
PENDING
PAID
SHIPPED
DELIVERED
CANCELLED
}
type Order {
id: ID!
status: OrderStatus!
}
Enums show up in autocomplete and are validated by the server. Changing an enum’s values is a breaking change; adding values is not (for outputs).
Interfaces and unions
When several types share fields, an interface captures the common shape:
# Anything with an id and a createdAt
interface Node {
id: ID!
createdAt: DateTime!
}
type Post implements Node {
id: ID!
createdAt: DateTime!
title: String!
}
type Comment implements Node {
id: ID!
createdAt: DateTime!
body: String!
}
When several types share nothing but a return slot, a union marks them as alternatives:
# Search returns either kind
union SearchResult = Post | Comment
type Query {
search(q: String!): [SearchResult!]!
}
Clients use inline fragments to pick fields per variant:
{
search(q: "hello") {
__typename
... on Post { title }
... on Comment { body }
}
}
Resolvers: the implementation
A resolver is a function that produces the value for a field. In JavaScript-based servers, resolvers are organised as a nested object that mirrors the schema:
// Apollo-style resolvers
const resolvers = {
Query: {
// (parent, args, context, info)
product: (_, { id }, { db }) => db.products.findById(id),
products: (_, { limit, offset }, { db }) => db.products.list(limit, offset),
},
Mutation: {
createProduct: (_, { input }, { db }) => db.products.create(input),
},
// Field-level resolvers fire when a Product field is selected
Product: {
// Only computed if the client asks for it
inStock: (product, _, { inventory }) => inventory.has(product.id),
},
};
Every resolver receives four arguments:
parent— the object returned by the parent field (the root object forQuery)args— the arguments the client passedcontext— request-scoped data (auth, database handles, loaders)info— the field’s execution info, including the selection set
You will use parent, args, and context constantly; info only for advanced cases.
A minimal runnable server
Using graphql-yoga (the same shape works in Apollo):
// server.js
import { createServer } from 'node:http';
import { createYoga, createSchema } from 'graphql-yoga';
// 1. The schema in SDL
const typeDefs = /* GraphQL */ `
type Product {
id: ID!
name: String!
price: Float!
}
type Query {
product(id: ID!): Product
products: [Product!]!
}
`;
// 2. Some throwaway data
const products = [
{ id: '1', name: 'Notebook', price: 4.5 },
{ id: '2', name: 'Pen', price: 1.2 },
];
// 3. Resolvers
const resolvers = {
Query: {
product: (_, { id }) => products.find(p => p.id === id),
products: () => products,
},
};
// 4. Wire them together and serve over HTTP
const yoga = createYoga({ schema: createSchema({ typeDefs, resolvers }) });
createServer(yoga).listen(4000, () => {
console.log('GraphQL ready at http://localhost:4000/graphql');
});
Open http://localhost:4000/graphql and you get an interactive playground. Run:
{
products { id name price }
}
You’ll see your two products, exactly as shaped by the query.
Try it yourself. Extend the server above with a Mutation.createProduct(input: CreateProductInput!): Product! field. Define the input type in SDL, push the new product onto the products array, and return it. Then run a mutation from the playground and verify the next { products } query includes it.
Resolvers can be async
Real resolvers talk to databases, caches, and other services. They are almost always async:
const resolvers = {
Query: {
user: async (_, { id }, { db }) => {
return await db.users.findById(id);
},
},
User: {
// Fires once per User in the parent result
posts: async (user, _, { db }) => {
return await db.posts.findByAuthor(user.id);
},
},
};
GraphQL will await every resolver. The execution engine handles parallelism for you — sibling fields run concurrently.
The N+1 problem
Look closer at that last example. Suppose a client runs:
{
users { # 1 query: SELECT * FROM users
name
posts { # N queries: SELECT * FROM posts WHERE author = ?
title
}
}
}
If users returns 100 rows, the User.posts resolver fires 100 times — each one a separate database query. That’s 101 queries for what should be 2. This is the N+1 problem, and it’s the single most common GraphQL performance issue.
DataLoader, briefly
The standard fix is the DataLoader pattern. A loader collects all the keys requested during one tick of the event loop, then issues a single batched query:
import DataLoader from 'dataloader';
// One loader per request, stored on context
function createPostsByAuthorLoader(db) {
return new DataLoader(async (authorIds) => {
// One query for all authors at once
const posts = await db.posts.findByAuthors(authorIds);
// Return results in the same order as the input keys
return authorIds.map(id => posts.filter(p => p.authorId === id));
});
}
// In your server setup
const yoga = createYoga({
schema,
context: ({ request }) => ({
db,
loaders: { postsByAuthor: createPostsByAuthorLoader(db) },
}),
});
// In the resolver
const resolvers = {
User: {
posts: (user, _, { loaders }) => loaders.postsByAuthor.load(user.id),
},
};
Now those 100 separate queries become 1. Two principles to remember:
- One loader per request. Loaders cache for the lifetime of the request; sharing them across requests leaks data between users.
- Return results in the input order. DataLoader matches results to keys positionally.
We will cover DataLoader in depth in a follow-up post.
Try it yourself. Without writing code, sketch how many SQL queries a naive resolver would issue for this query against a 50-user dataset where each user has 10 orders:
{
users {
name
orders { total items { name } }
}
}(Answer: 1 for users, 50 for orders, then 500 for items — 551 in total. With batched loaders: 3.)
Schema design tips
A few rules that age well:
- Use nouns for types, verbs for mutations.
User,Order,createUser,cancelOrder. - Pass mutation arguments as a single
inputobject. Easier to evolve. - Return the affected object from mutations. Saves a follow-up query.
- Be conservative with non-null. Adding
!later is breaking; removing it is fine. - Deprecate fields with
@deprecated(reason: "...")rather than removing them outright. - Paginate lists with cursors once you outgrow
limit/offset— the Relay connection spec is the de-facto standard.
Schemas evolve, they don’t version
REST APIs version with /v2. GraphQL APIs evolve in place:
type User {
name: String!
email: String! @deprecated(reason: "Use contactEmail")
contactEmail: String!
}
Old clients still get email; new ones use contactEmail. Tools like Apollo Studio and GraphQL Inspector can compare two schemas and flag breaking changes in CI.
Recap
You now know:
- The schema is the typed contract; it lives in SDL
- Scalars, objects, inputs, enums, interfaces, and unions are the building blocks
Query,Mutation, andSubscriptionare the root entry points- Resolvers are functions of
(parent, args, context, info)that produce each field’s value - Sibling resolvers run in parallel; nested resolvers trigger the N+1 problem
- DataLoader batches per-request requests into one round trip
Next steps
The natural next step is to put a real database behind your resolvers, add authentication via context, and learn the Relay-style pagination convention. From the API-design side, you may also enjoy REST API Design: Practical Best Practices — many of the same principles (consistency, naming, evolution) apply, just expressed differently.
Questions or feedback? Email codeloomdevv@gmail.com.