正在加载,请稍候…

GraphQL Best Practices: Schema Design, DataLoader, and Performance

Master GraphQL schema design, solve N+1 with DataLoader, implement pagination, authentication, rate limiting, and query complexity analysis for production APIs.

GraphQL Best Practices for Production

GraphQL offers flexibility but introduces unique challenges. This guide covers production-ready patterns.

Schema Design Principles

# Good: Use connections for lists (enables pagination)
type Query {
  users(first: Int, after: String, filter: UserFilter): UserConnection!
  user(id: ID!): User
}

type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

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

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

# Good: Use input types for mutations
type Mutation {
  createUser(input: CreateUserInput!): CreateUserPayload!
  updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
}

input CreateUserInput {
  name: String!
  email: String!
  role: UserRole = USER
}

type CreateUserPayload {
  user: User
  errors: [UserError!]!
}

type UserError {
  field: String
  message: String!
  code: UserErrorCode!
}

Solving N+1 with DataLoader

import DataLoader from 'dataloader';

// Without DataLoader - N+1 problem
// Querying 100 posts = 100 separate user queries!

// With DataLoader - batched queries
const userLoader = new DataLoader<string, User>(async (userIds) => {
  // Single query for all IDs
  const users = await db.users.findMany({
    where: { id: { in: userIds as string[] } },
  });
  
  // Return in same order as input IDs
  const userMap = new Map(users.map(u => [u.id, u]));
  return userIds.map(id => userMap.get(id) ?? new Error(`User ${id} not found`));
});

// In resolver
const resolvers = {
  Post: {
    author: (post, _, { loaders }) => {
      return loaders.user.load(post.authorId);
    },
    tags: (post, _, { loaders }) => {
      return loaders.tagsByPost.load(post.id);
    },
  },
};

// Context setup - new DataLoader per request!
function createContext({ req }) {
  return {
    db,
    loaders: {
      user: new DataLoader(batchUsers),
      tagsByPost: new DataLoader(batchTagsByPost),
    },
  };
}

Cursor-Based Pagination

async function resolveUsersConnection(
  parent: unknown,
  args: { first?: number; after?: string; filter?: UserFilter },
  ctx: Context,
) {
  const limit = Math.min(args.first ?? 20, 100);
  const cursor = args.after ? decodeCursor(args.after) : null;

  const users = await ctx.db.users.findMany({
    take: limit + 1, // Fetch one extra to check hasNextPage
    where: {
      ...(cursor && { id: { gt: cursor } }),
      ...buildFilterWhere(args.filter),
    },
    orderBy: { id: 'asc' },
  });

  const hasNextPage = users.length > limit;
  const edges = users.slice(0, limit).map(user => ({
    node: user,
    cursor: encodeCursor(user.id),
  }));

  return {
    edges,
    pageInfo: {
      hasNextPage,
      hasPreviousPage: !!cursor,
      startCursor: edges[0]?.cursor,
      endCursor: edges[edges.length - 1]?.cursor,
    },
    totalCount: () => ctx.db.users.count({ where: buildFilterWhere(args.filter) }),
  };
}

function encodeCursor(id: string): string {
  return Buffer.from(`cursor:${id}`).toString('base64');
}

function decodeCursor(cursor: string): string {
  const decoded = Buffer.from(cursor, 'base64').toString();
  return decoded.replace('cursor:', '');
}

Authentication and Authorization

// Schema directives for authorization
const typeDefs = gql`
  directive @auth(requires: Role = USER) on FIELD_DEFINITION

  enum Role { USER ADMIN }

  type Query {
    me: User @auth
    adminStats: Stats @auth(requires: ADMIN)
  }
`;

// Field-level authorization with shield
import { shield, rule, and, not } from 'graphql-shield';

const isAuthenticated = rule({ cache: 'contextual' })(
  async (parent, args, ctx) => {
    return !!ctx.user;
  }
);

const isAdmin = rule({ cache: 'contextual' })(
  async (parent, args, ctx) => {
    return ctx.user?.role === 'ADMIN';
  }
);

const isOwner = rule({ cache: 'strict' })(
  async (parent, args, ctx) => {
    const post = await ctx.loaders.post.load(args.id);
    return post.authorId === ctx.user?.id;
  }
);

export const permissions = shield({
  Query: {
    me: isAuthenticated,
    adminStats: isAdmin,
  },
  Mutation: {
    createPost: isAuthenticated,
    updatePost: and(isAuthenticated, isOwner),
    deletePost: and(isAuthenticated, or(isOwner, isAdmin)),
  },
});

Query Complexity and Depth Limiting

import { createComplexityLimitRule } from 'graphql-validation-complexity';
import depthLimit from 'graphql-depth-limit';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    depthLimit(10),  // Max query depth
    createComplexityLimitRule(1000, {
      scalarCost: 1,
      objectCost: 2,
      listFactor: 10,
      introspectionListFactor: 2,
    }),
  ],
});

// Custom complexity calculator
function calculateComplexity(args: Record<string, any>): number {
  const limit = args.first || args.limit || 10;
  return limit * 2; // Each item costs 2
}

Persisted Queries (Security + Performance)

import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';

// Client: automatically hash and send query IDs
const link = createPersistedQueryLink({ sha256 });
const client = new ApolloClient({ link, cache: new InMemoryCache() });

// Server: verify and lookup queries
const server = new ApolloServer({
  cache: new InMemoryLRUCache(),
  plugins: [
    ApolloServerPluginCacheControl({ defaultMaxAge: 5 }),
    {
      async requestDidStart({ request }) {
        if (request.extensions?.persistedQuery) {
          const { sha256Hash } = request.extensions.persistedQuery;
          // Verify against allowlist in production
        }
      },
    },
  ],
});

Summary

GraphQL production checklist:

  • Use connections for all list fields (enables pagination)
  • Use input types for all mutations
  • DataLoader for batching (solve N+1)
  • Cursor-based pagination (not offset)
  • Query depth and complexity limits
  • Persisted queries to prevent malicious queries
  • Field-level authorization with graphql-shield