Skip to content
C Codeloom
GraphQL

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.

·10 min read · By Yash Kesharwani
Intermediate 13 min read

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:

ScalarMeaning
Int32-bit signed integer
FloatDouble-precision number
StringUTF-8 text
Booleantrue or false
IDOpaque 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:

  1. parent — the object returned by the parent field (the root object for Query)
  2. args — the arguments the client passed
  3. context — request-scoped data (auth, database handles, loaders)
  4. 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 input object. 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, and Subscription are 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.