正在加载,请稍候…

GraphQL API Design Best Practices: Schema-First Development in 2026

Design production GraphQL APIs — schema design patterns, N+1 problem with DataLoader, pagination strategies, subscriptions, error handling, rate limiting, and persisted queries.

GraphQL API Design Principles

A well-designed GraphQL API is self-documenting, efficient, and a joy to use. These are the patterns that separate good APIs from great ones.

Schema-First Design

Define your schema before writing resolvers. This forces you to think about the consumer experience.

# schema.graphql
type Query {
  user(id: ID!): User
  users(filter: UserFilter, pagination: PaginationInput): UserConnection!
  post(id: ID!): Post
}

type Mutation {
  createUser(input: CreateUserInput!): CreateUserPayload!
  updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
  deleteUser(id: ID!): DeleteUserPayload!
}

type Subscription {
  userCreated: User!
  postPublished(authorId: ID): Post!
}

type User {
  id: ID!
  name: String!
  email: String!
  posts(first: Int, after: String): PostConnection!
  createdAt: DateTime!
}

# Relay-style pagination
type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type UserEdge {
  node: User!
  cursor: String!
}

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

Solving N+1 with DataLoader

The N+1 problem: fetching 10 posts → 10 separate author queries.

import DataLoader from 'dataloader';

// Create DataLoader per request (NOT globally — prevents cross-request caching)
function createLoaders(db) {
  return {
    userById: new DataLoader(async (ids) => {
      const users = await db.users.findMany({
        where: { id: { in: ids } },
      });
      
      // Must return array in same order as ids
      const userMap = new Map(users.map(u => [u.id, u]));
      return ids.map(id => userMap.get(id) ?? null);
    }),
    
    postsByAuthorId: new DataLoader(async (authorIds) => {
      const posts = await db.posts.findMany({
        where: { authorId: { in: authorIds } },
      });
      
      const grouped = authorIds.map(id =>
        posts.filter(p => p.authorId === id)
      );
      return grouped;
    }),
  };
}

// In Apollo Server context
const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: ({ req }) => ({
    db,
    loaders: createLoaders(db),
    userId: getAuthUserId(req),
  }),
});

Resolver Pattern

const resolvers = {
  Query: {
    user: async (_, { id }, { loaders }) => {
      return loaders.userById.load(id);
    },
    
    users: async (_, { filter, pagination }, { db }) => {
      const { first = 20, after } = pagination ?? {};
      const cursor = after ? decodeCursor(after) : null;
      
      const items = await db.users.findMany({
        where: buildFilter(filter),
        take: first + 1,
        cursor: cursor ? { id: cursor } : undefined,
        orderBy: { createdAt: 'desc' },
      });
      
      const hasNextPage = items.length > first;
      const nodes = hasNextPage ? items.slice(0, -1) : items;
      
      return {
        edges: nodes.map(node => ({
          node,
          cursor: encodeCursor(node.id),
        })),
        pageInfo: {
          hasNextPage,
          hasPreviousPage: !!after,
          startCursor: nodes[0] ? encodeCursor(nodes[0].id) : null,
          endCursor: nodes.at(-1) ? encodeCursor(nodes.at(-1).id) : null,
        },
        totalCount: await db.users.count({ where: buildFilter(filter) }),
      };
    },
  },
  
  User: {
    posts: async (user, { first = 10 }, { loaders }) => {
      return loaders.postsByAuthorId.load(user.id);
    },
  },
  
  Mutation: {
    createUser: async (_, { input }, { db, userId }) => {
      if (!userId) throw new AuthenticationError('Not authenticated');
      
      const user = await db.users.create({ data: input });
      return { user, success: true };
    },
  },
};

Error Handling Patterns

import { GraphQLError } from 'graphql';

// Domain-specific errors
class NotFoundError extends GraphQLError {
  constructor(resource, id) {
    super(`${resource} ${id} not found`, {
      extensions: { code: 'NOT_FOUND', resource, id },
    });
  }
}

class AuthenticationError extends GraphQLError {
  constructor(message = 'Not authenticated') {
    super(message, {
      extensions: { code: 'UNAUTHENTICATED' },
    });
  }
}

class ForbiddenError extends GraphQLError {
  constructor(message = 'Forbidden') {
    super(message, {
      extensions: { code: 'FORBIDDEN' },
    });
  }
}

// Global error formatter
const server = new ApolloServer({
  formatError: (formattedError, error) => {
    // Don't expose internal errors in production
    if (process.env.NODE_ENV === 'production') {
      if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
        return new GraphQLError('Internal server error');
      }
    }
    return formattedError;
  },
});

Subscriptions with WebSockets

import { createClient } from 'graphql-ws';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';

const wsServer = new WebSocketServer({ port: 4001 });
const serverCleanup = useServer({ schema }, wsServer);

// Resolver
const resolvers = {
  Subscription: {
    postPublished: {
      subscribe: async function* (_, { authorId }, { pubsub }) {
        for await (const event of pubsub.subscribe('POST_PUBLISHED')) {
          if (!authorId || event.authorId === authorId) {
            yield { postPublished: event };
          }
        }
      },
    },
  },
};

Rate Limiting with Cost Analysis

import { createComplexityLimitRule } from 'graphql-validation-complexity';

const server = new ApolloServer({
  validationRules: [
    createComplexityLimitRule(1000, {
      onCost: (cost, document) => {
        console.log('Query cost:', cost);
      },
      createError: (max, actual) =>
        new GraphQLError(`Query cost ${actual} exceeds maximum ${max}`),
    }),
  ],
});

Persisted Queries

import { ApolloServerPluginCacheControl } from '@apollo/server/plugin/cacheControl';
import { InMemoryLRUCache } from '@apollo/utils.keyvaluecache';

const server = new ApolloServer({
  plugins: [ApolloServerPluginCacheControl({ defaultMaxAge: 5 })],
  cache: new InMemoryLRUCache({ maxSize: Math.pow(2, 20) * 30 }),
  persistedQueries: {
    cache: new InMemoryLRUCache(),
  },
});

Schema Design Rules

  1. Nullable by default, non-null when certain — avoid breaking clients
  2. Use interfaces for polymorphisminterface Node { id: ID! }
  3. Input types for mutations — not individual args
  4. Payload types for mutations — allows adding fields later
  5. Relay pagination — cursors > offset for large datasets
  6. Enums for finite setsenum PostStatus { DRAFT PUBLISHED ARCHIVED }