Skip to main content

GraphQL Schema Design Best Practices

Good GraphQL schema design avoids performance traps, keeps the API evolvable, and makes client development predictable. This guide covers everything from type design and mutation patterns to the N+1 problem, DataLoader batching, and cursor pagination.

✓ Schema-first vs code-first ✓ N+1 & DataLoader ✓ Mutation payload pattern ✓ Relay cursor pagination

Schema Design: Types & Naming Conventions

# schema.graphql — SDL (schema definition language)

type Query {
  user(id: ID!): User
  users(first: Int, after: String, filter: UserFilter): UserConnection!
  post(slug: String!): Post
}

type User {
  id:        ID!
  email:     String!
  name:      String
  role:      UserRole!
  posts(first: Int, after: String): PostConnection!
  createdAt: DateTime!
  # Avoid exposing implementation details like passwordHash
}

enum UserRole {
  ADMIN
  EDITOR
  VIEWER
}

type Post {
  id:        ID!
  title:     String!
  slug:      String!
  body:      String!
  author:    User!       # <-- This is where N+1 happens
  tags:      [Tag!]!     # <-- And here for tag lists
  status:    PostStatus!
  publishedAt: DateTime
  createdAt:   DateTime!
}

enum PostStatus {
  DRAFT
  PUBLISHED
  ARCHIVED
}

scalar DateTime  # ISO-8601 string

input UserFilter {
  role: UserRole
  search: String
}

The N+1 Problem & DataLoader Solution

// PROBLEM — naive resolver causes N+1
const resolvers = {
  Post: {
    author: async (post) => {
      // Called once per post — 100 posts = 100 DB queries!
      return db.users.findUnique({ where: { id: post.authorId } });
    },
  },
};
// SOLUTION — DataLoader batches requests within one event loop tick
import DataLoader from 'dataloader';

// Create one loader per request (never share across requests!)
function createLoaders(db: PrismaClient) {
  return {
    userById: new DataLoader<string, User | null>(
      async (userIds) => {
        // Receives ALL IDs collected within one tick
        const users = await db.user.findMany({
          where: { id: { in: [...userIds] } },
        });

        // DataLoader requires results in same order as input keys
        const userMap = new Map(users.map(u => [u.id, u]));
        return userIds.map(id => userMap.get(id) ?? null);
      },
      {
        // Cache within the request lifecycle (default: true)
        cache: true,
        // Batch all loads in same event loop tick
        batchScheduleFn: (callback) => process.nextTick(callback),
      }
    ),

    tagsByPostId: new DataLoader<string, Tag[]>(
      async (postIds) => {
        const tags = await db.postTag.findMany({
          where: { postId: { in: [...postIds] } },
          include: { tag: true },
        });

        const tagsByPost = new Map<string, Tag[]>();
        for (const pt of tags) {
          if (!tagsByPost.has(pt.postId)) tagsByPost.set(pt.postId, []);
          tagsByPost.get(pt.postId)!.push(pt.tag);
        }
        return postIds.map(id => tagsByPost.get(id) ?? []);
      }
    ),
  };
}

// Pass loaders via context
const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: ({ req }) => ({
    db,
    loaders: createLoaders(db),  // fresh loaders per request
    user: req.user,
  }),
});

// Resolver using DataLoader — 100 posts = 1 batch query
const resolvers = {
  Post: {
    author: (post, _, { loaders }) =>
      loaders.userById.load(post.authorId),

    tags: (post, _, { loaders }) =>
      loaders.tagsByPostId.load(post.id),
  },
};

Mutation Payload Pattern

# SDL — mutation with payload type
type Mutation {
  createPost(input: CreatePostInput!): CreatePostPayload!
  updatePost(id: ID!, input: UpdatePostInput!): UpdatePostPayload!
  deletePost(id: ID!): DeletePostPayload!
  publishPost(id: ID!): PublishPostPayload!
}

input CreatePostInput {
  title:  String!
  body:   String!
  tags:   [String!]
  status: PostStatus = DRAFT
}

# Payload type — always has errors array + affected resource
type CreatePostPayload {
  post:   Post          # null on failure
  errors: [UserError!]! # empty on success
}

type UserError {
  field:   String       # null if not field-specific
  message: String!
  code:    String!
}
// Resolver — return errors as data, never throw for expected failures
const resolvers = {
  Mutation: {
    createPost: async (_, { input }, { db, user }) => {
      // Validation
      const errors: UserError[] = [];
      if (!input.title.trim()) {
        errors.push({ field: 'title', message: 'Title is required', code: 'REQUIRED' });
      }
      if (input.title.length > 200) {
        errors.push({ field: 'title', message: 'Title too long (max 200)', code: 'TOO_LONG' });
      }
      if (errors.length > 0) return { post: null, errors };

      // Create
      const post = await db.post.create({
        data: { ...input, authorId: user.id },
      });
      return { post, errors: [] };
    },
  },
};

Relay Cursor Pagination

# Connection types (Relay spec)
type PostConnection {
  edges:      [PostEdge!]!
  pageInfo:   PageInfo!
  totalCount: Int!
}

type PostEdge {
  node:   Post!
  cursor: String!
}

type PageInfo {
  hasNextPage:     Boolean!
  hasPreviousPage: Boolean!
  startCursor:     String
  endCursor:       String
}
// Resolver — cursor pagination with Prisma
async function postsConnection(
  _: unknown,
  args: { first?: number; after?: string },
  { db }: Context
) {
  const limit   = args.first ?? 20;
  const afterId = args.after
    ? Buffer.from(args.after, 'base64').toString('utf8')
    : undefined;

  const posts = await db.post.findMany({
    take:    limit + 1,  // fetch one extra to detect hasNextPage
    cursor:  afterId ? { id: afterId } : undefined,
    skip:    afterId ? 1 : 0,
    orderBy: { createdAt: 'desc' },
  });

  const hasMore = posts.length > limit;
  const items   = hasMore ? posts.slice(0, limit) : posts;

  const edges = items.map(post => ({
    cursor: Buffer.from(post.id).toString('base64'),
    node:   post,
  }));

  return {
    edges,
    totalCount: await db.post.count(),
    pageInfo: {
      hasNextPage:     hasMore,
      hasPreviousPage: !!args.after,
      startCursor:     edges[0]?.cursor ?? null,
      endCursor:       edges[edges.length - 1]?.cursor ?? null,
    },
  };
}

Query Protection: Depth & Complexity

// Protect against malicious deeply-nested queries
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    depthLimit(6),                        // max 6 levels deep
    createComplexityLimitRule(1000, {     // max complexity score 1000
      scalarCost: 1,
      objectCost: 2,
      listFactor: 10,                     // lists cost 10x more
    }),
  ],
});

// Persisted queries (production hardening)
// Client sends: { "extensions": { "persistedQuery": { "sha256Hash": "abc123..." } } }
// Server looks up hash in approved query registry
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';

Schema-First vs Code-First Comparison

Aspect Schema-First (SDL) Code-First (TypeGraphQL / Pothos)
Schema location.graphql filesGenerated from decorators/builders
Type safetyNeeds codegen (graphql-code-generator)Native TypeScript types
Design workflowDesign SDL first, then implementCode and schema evolve together
Schema drift riskPossible (SDL vs resolvers)Impossible (same source)
Best toolsApollo Server, graphql-yogaTypeGraphQL, Pothos, Nexus
Team fitAPI-first teams, design reviewsTypeScript-heavy teams

Frequently Asked Questions

What is the N+1 problem in GraphQL and why does it happen?▼

The N+1 problem occurs when resolving a list field triggers one database query per item. For example, querying posts &#123; author &#123; name &#125; &#125; for 100 posts runs 1 query to fetch posts, then 100 individual queries to fetch each author — 101 queries total. It happens because GraphQL resolvers are called independently for each item in a list. The solution is DataLoader, a batching library that collects all IDs requested within a single tick of the event loop and issues one batch query. With DataLoader, 100 author lookups collapse into a single SELECT WHERE id IN (1, 2, ..., 100).

What is the difference between schema-first and code-first GraphQL?▼

Schema-first means you write the .graphql SDL file manually, then implement resolvers that satisfy the schema. This makes the schema a design artifact that can be reviewed before implementation and shared across teams. Code-first means you define the schema programmatically using TypeScript classes and decorators (TypeGraphQL, Pothos) or resolver builders — the SDL is generated from code. Code-first eliminates the schema-resolver sync problem (where the SDL and resolvers drift apart) and gives type safety. Most production teams prefer code-first for better TypeScript integration, but schema-first remains valuable for design-first workflows.

How should I design GraphQL mutations for complex operations?▼

Use the mutation payload pattern: every mutation returns a dedicated payload type (not the raw entity) that includes the affected resource, user errors array, and any side-effect data. Example: createUser returns CreateUserPayload &#123; user: User, errors: [UserError!]! &#125;. Always return errors as data (field in the payload) rather than throwing top-level GraphQL errors — this lets clients handle both success and expected failures in one type-safe response. Use input types (not individual arguments) for all mutations with more than 2 fields so you can evolve the input without breaking changes.

How does cursor-based pagination work in GraphQL?▼

The Relay Cursor Connections spec is the standard. Each paginated field returns a Connection type: &#123; edges: [&#123; cursor: String!, node: T &#125;], pageInfo: &#123; hasNextPage, hasPreviousPage, startCursor, endCursor &#125; &#125;. Clients pass first: 10, after: cursor to page forward, or last: 10, before: cursor to page backward. The cursor encodes the position (usually base64 of the row ID or a composite sort key) and is opaque to clients. Avoid offset pagination for large datasets — cursor pagination is O(log N) with an index on the sort key, while OFFSET scans and discards rows.

What are GraphQL subscriptions and when should I use them?▼

Subscriptions deliver real-time updates over a persistent WebSocket connection. The client sends a subscription operation and the server pushes new data whenever the subscribed event occurs. Use subscriptions for: live collaboration features, chat messages, order status updates, real-time dashboards. Implement with graphql-ws (the modern standard, replacing subscriptions-transport-ws) over WebSocket. For simple one-way server push, consider GraphQL over SSE instead — it's stateless and works through HTTP/2 multiplexing. Avoid subscriptions for polling-style updates that could use a regular query with client-side polling.

How do I prevent overly complex or expensive GraphQL queries?▼

Defense in depth: (1) Query depth limiting — reject queries nested beyond a configured depth (typically 5–7) using graphql-depth-limit. (2) Query complexity analysis — assign cost scores to fields (list fields cost more than scalar fields) and reject queries exceeding a budget using graphql-query-complexity. (3) Query whitelisting (persisted queries) — only allow pre-approved query IDs from your known clients, making arbitrary query attacks impossible. (4) Field-level timeouts — use GraphQL execution timeout middleware. (5) Rate limiting on the operation level, not just IP. Most production APIs use at least depth limiting and complexity analysis together.

Share this tool: X / Twitter LinkedIn
✓ 60,000+ developers use DevPlaybook ★★★★★ 4.8 / 5 on Gumroad ✓ MIT licensed · Instant download ✓ No login required

Related Products

on Gumroad

You Might Also Need

More Free Tools

Enjoying this tool? Get more like it.

Weekly developer tool guides, productivity playbooks & AI tricks. Free. No spam.

DevPlaybook Pro 7 days free

Unlock AI-Powered Dev Tools

  • ⚡ AI Code Review, Doc Generator & SQL Builder
  • ⚡ All premium templates & early access
  • ⚡ Member discounts on Gumroad products
Start Free Trial →

$9/mo after trial · Cancel anytime

Want the full toolkit?

Get DevPlaybook Pro

Every template, guide, boilerplate, and automation script in one bundle. 13 premium products — grab them all at once and save big.

$79
$241 separately Save $162 (67% off)
Get DevPlaybook Pro — $79 →

MIT licensed · Instant download · No subscription

See what's included → Browse all deals →