正在加载,请稍候…

GraphQL Schema Design: Best Practices and Real-World Patterns

Design robust GraphQL schemas that scale. Learn naming conventions, pagination patterns, error handling, N+1 solutions with DataLoader, and schema evolution strategies.

GraphQL Schema Design Best Practices

Schema Naming Conventions

# Types: PascalCase
type User {
  id: ID!
  email: String!
  createdAt: DateTime!    # camelCase fields
  profilePhoto: URL       # descriptive names
}

# Input types: PascalCase + Input suffix
input CreateUserInput {
  email: String!
  name: String!
  role: UserRole = USER   # use enums with defaults
}

# Enums: ALL_CAPS values
enum UserRole {
  ADMIN
  USER
  MODERATOR
}

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

# Payload types: separate from domain types
type CreateUserPayload {
  user: User
  errors: [UserError!]!
}

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

Pagination (Cursor-based)

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

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

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

type Query {
  users(
    first: Int
    after: String
    last: Int
    before: String
    filter: UserFilter
    orderBy: UserOrderBy
  ): UserConnection!
}

N+1 Problem with DataLoader

import DataLoader from 'dataloader';

// Without DataLoader: N+1 queries
// For 100 posts, fetches author 100 separate times

// With DataLoader: batch + cache
const userLoader = new DataLoader<string, User>(async (userIds) => {
  const users = await db.query(
    'SELECT * FROM users WHERE id = ANY($1)',
    [userIds]
  );
  // Return users in same order as userIds
  const userMap = new Map(users.map(u => [u.id, u]));
  return userIds.map(id => userMap.get(id) ?? new Error(`User ${id} not found`));
});

// Resolvers
const resolvers = {
  Post: {
    author: (post: Post) => userLoader.load(post.authorId),
    // Single query per batch, not per post
  },
};

Error Handling

// Consistent error format
const resolvers = {
  Mutation: {
    createUser: async (_: unknown, { input }: { input: CreateUserInput }) => {
      try {
        const validation = validateCreateUser(input);
        if (!validation.ok) {
          return {
            user: null,
            errors: validation.errors.map(e => ({
              field: e.field,
              message: e.message,
              code: 'VALIDATION_ERROR',
            })),
          };
        }

        const user = await userService.create(input);
        return { user, errors: [] };
      } catch (err) {
        if (err instanceof UniqueConstraintError) {
          return {
            user: null,
            errors: [{ field: 'email', message: 'Email already in use', code: 'DUPLICATE_EMAIL' }],
          };
        }
        throw err; // Let global error handler deal with unexpected errors
      }
    },
  },
};

Subscriptions

type Subscription {
  orderUpdated(orderId: ID!): OrderUpdatedPayload!
  newMessage(channelId: ID!): Message!
}
const resolvers = {
  Subscription: {
    orderUpdated: {
      subscribe: (_, { orderId }) =>
        pubsub.asyncIterator(`ORDER_UPDATED_${orderId}`),
      resolve: (payload: OrderUpdatedPayload) => payload,
    },
  },
  Mutation: {
    updateOrder: async (_, { id, input }) => {
      const order = await orderService.update(id, input);
      await pubsub.publish(`ORDER_UPDATED_${id}`, { orderUpdated: order });
      return { order, errors: [] };
    },
  },
};

Schema Evolution

# Deprecate old fields, add new ones
type User {
  id: ID!
  # Old field
  fullName: String @deprecated(reason: "Use 'name' instead")
  # New field
  name: String!
}

Good GraphQL schema design makes the API self-documenting and easy to evolve.