apollo-graphql-best-practices

安装量: 39
排名: #18455

安装

npx skills add https://github.com/maximepzv/skills --skill apollo-graphql-best-practices

Apollo GraphQL Best Practices When to Apply

Use this skill when:

Setting up a new Apollo Client or Apollo Server project Writing GraphQL queries, mutations, or subscriptions Implementing React components with useQuery, useMutation, or useLazyQuery Configuring cache policies, type policies, or pagination Designing GraphQL schemas or writing resolvers Handling GraphQL or network errors Optimizing GraphQL performance (N+1 queries, caching, batching) Reviewing or refactoring existing Apollo GraphQL code Apollo Client Client Setup

Configure ApolloClient with InMemoryCache and appropriate type policies:

import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client";

const client = new ApolloClient({ link: new HttpLink({ uri: "/graphql" }), cache: new InMemoryCache({ typePolicies: { Query: { fields: { // Define field policies for pagination, merging, etc. }, }, // Custom key fields for entity identification User: { keyFields: ["email"], // Use email instead of id }, }, }), });

Queries with useQuery import { useQuery, gql } from "@apollo/client";

const GET_DATA = gqlquery GetData($id: ID!) { item(id: $id) { id name } };

function Component({ id }: { id: string }) { const { data, loading, error } = useQuery(GET_DATA, { variables: { id }, fetchPolicy: "cache-first", // Default, use cache when available });

if (loading) return ; if (error) return ; return ; }

Mutations with useMutation

Update cache after mutations using update callback:

import { useMutation, gql } from "@apollo/client";

const ADD_ITEM = gqlmutation AddItem($input: ItemInput!) { addItem(input: $input) { id name } };

function AddItemForm() { const [addItem, { loading }] = useMutation(ADD_ITEM, { update(cache, { data: { addItem } }) { cache.modify({ fields: { items(existingItems = []) { const newItemRef = cache.writeFragment({ data: addItem, fragment: gqlfragment NewItem on Item { id name }, }); return [...existingItems, newItemRef]; }, }, }); }, // Or use refetchQueries for simpler cases // refetchQueries: [{ query: GET_ITEMS }], });

return (

{ e.preventDefault(); addItem({ variables: { input: { name: "New Item" } } }); }}>
); }

Error Handling

GraphQL can return partial data with errors (unlike REST where a single error fails the entire request). Use errorPolicy to control this behavior:

Policy Behavior none Treat any GraphQL error as a network error, discard data (default) ignore Ignore GraphQL errors, return only data all Return both data and errors, enabling partial data rendering import { useQuery } from "@apollo/client";

function Component() { const { data, error } = useQuery(QUERY, { errorPolicy: "all" // Receive partial data with errors });

if (error) { // Check if we have partial data to display if (data) { // Render partial data with error notification return (

); } // No data at all - show full error if (error.networkError) { return
Network error: {error.message}
; } return
Error: {error.graphQLErrors[0]?.message}
; }

return

{data?.field}
; }

Cache Type Policies

Configure cache normalization, custom identifiers, and field merging:

const cache = new InMemoryCache({ typePolicies: { Query: { fields: { // Offset-based pagination with proper merging items: { keyArgs: ["filter"], // Cache separately per filter value merge(existing = [], incoming, { args }) { const offset = args?.offset ?? 0; const merged = existing.slice(0); for (let i = 0; i < incoming.length; i++) { merged[offset + i] = incoming[i]; } return merged; }, }, }, }, // Custom cache key using different field than 'id' User: { keyFields: ["email"], // Use email as unique identifier }, // Entities without id field Token: { keyFields: false, // Treat as singleton (no normalization) }, // Composite key for join tables OrderItem: { keyFields: ["orderId", "productId"], }, }, });

Key concepts:

Cache normalization: Apollo stores objects in a flat lookup table using __typename:id as the cache key keyFields: Customize which fields identify an entity (default is id or _id) keyArgs: Control which arguments create separate cache entries merge: Define how to combine existing and incoming data (essential for pagination) Fetch Policies Policy Behavior cache-first Read cache, fetch if missing (default) cache-only Only read cache, never fetch network-only Always fetch, update cache no-cache Always fetch, don't cache cache-and-network Return cache immediately, then fetch Apollo Server Server Setup import { ApolloServer } from "@apollo/server"; import { startStandaloneServer } from "@apollo/server/standalone";

interface Context { user?: User; db: Database; }

const server = new ApolloServer({ typeDefs, resolvers, });

const { url } = await startStandaloneServer(server, { context: async ({ req }) => ({ user: await getUserFromToken(req.headers.authorization), db: await getDatabase(), }), listen: { port: 4000 }, });

Schema Design Principles Use non-nullable by default - Add ! unless field can legitimately be null Prefer specific types - Use ID! for identifiers, custom scalars for dates Design for the client - Structure schema around UI needs, not database schema Use input types for mutations - Group related arguments type Query { user(id: ID!): User users(filter: UserFilter, pagination: Pagination): UserConnection! }

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

input CreateUserInput { email: String! name: String! }

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

Resolvers const resolvers = { Query: { user: async (, { id }, context) => { return context.db.users.findById(id); }, }, Mutation: { createUser: async (, { input }, context) => { if (!context.user) { throw new GraphQLError("Not authenticated", { extensions: { code: "UNAUTHENTICATED" }, }); } const user = await context.db.users.create(input); return { user, errors: [] }; }, }, // Field resolvers for computed/related data User: { posts: (parent, _, context) => { return context.db.posts.findByUserId(parent.id); }, }, };

Error Handling

Throw GraphQLError with descriptive codes:

import { GraphQLError } from "graphql";

// In resolver if (!user) { throw new GraphQLError("User not found", { extensions: { code: "NOT_FOUND", argumentName: "id", }, }); }

// In context for auth errors context: async ({ req }) => { const user = await getUser(req); if (!user) { throw new GraphQLError("Authentication required", { extensions: { code: "UNAUTHENTICATED", http: { status: 401 }, }, }); } return { user }; };

Standard Error Codes Code Use Case UNAUTHENTICATED Missing or invalid authentication FORBIDDEN Authenticated but not authorized BAD_USER_INPUT Invalid argument values NOT_FOUND Requested resource doesn't exist INTERNAL_SERVER_ERROR Unexpected server errors Performance Tips Use DataLoader - Batch and cache database calls to avoid N+1 queries Implement pagination - Never return unbounded lists Use persisted queries - Reduce request size in production Enable APM - Use Apollo Studio for query performance monitoring Lazy load fragments - Split large queries with @defer directive Configure cache TTL - Set appropriate maxAge for cached responses Limit query depth - Prevent deeply nested queries that can cause performance issues Set query complexity limits - Assign costs to fields and reject overly complex queries Security Best Practices Query depth limiting - Prevent malicious deeply nested queries Query complexity analysis - Assign costs to fields, reject queries exceeding threshold Rate limiting - Throttle requests per client/IP Disable introspection in production - Hide schema from unauthorized users Input validation - Validate all user inputs in resolvers Field-level authorization - Check permissions in field resolvers, not just at query level // Example: formatError to hide internal errors in production const server = new ApolloServer({ typeDefs, resolvers, introspection: process.env.NODE_ENV !== "production", formatError: (error) => { // Log full error internally console.error(error);

// Don't expose internal errors to clients
if (error.extensions?.code === "INTERNAL_SERVER_ERROR") {
  return new GraphQLError("Internal server error", {
    extensions: { code: "INTERNAL_SERVER_ERROR" },
  });
}
return error;

}, });

Relay-Style Patterns Global IDs

Use base64-encoded type:uuid format for globally unique identifiers. This follows the Relay Global Object Identification Specification.

Benefits:

Enables efficient client-side caching and data refetching Allows node(id: ID!) query to fetch any entity by ID Type information embedded in ID prevents accidental cross-type queries Libraries like Relay can automatically generate pagination and refetch queries // utils/globalId.ts export type GlobalIdType = "user" | "request" | "classified" | "purchase";

export function encodeGlobalId(type: GlobalIdType, uuid: string): string { return Buffer.from(${type}:${uuid}).toString("base64"); }

export function decodeGlobalId(globalId: string): { type: string; uuid: string } { const decoded = Buffer.from(globalId, "base64").toString("utf-8"); const [type, uuid] = decoded.split(":");

if (!type || !uuid) { throw new Error("Invalid global ID format"); }

return { type, uuid }; }

// Examples encodeGlobalId("request", "abc-123"); // -> "cmVxdWVzdDphYmMtMTIz" decodeGlobalId("cmVxdWVzdDphYmMtMTIz"); // -> { type: 'request', uuid: 'abc-123' }

Node Interface

Implement the Node interface for unified entity fetching. This is a core pattern from the Relay specification that provides:

A standard way to refetch any object by its ID Efficient cache management for client libraries Type-safe polymorphic queries

schema/node.graphql

interface Node { id: ID! }

extend type Query { """ Fetch any entity by its global ID. Returns null if the ID is invalid or the entity doesn't exist. """ node(id: ID!): Node }

// resolvers/node.ts export const nodeResolvers = { Query: { async node(parent: any, { id }: { id: string }, context: GraphQLContext) { try { const { type, uuid } = decodeGlobalId(id);

    switch (type) {
      case "request":
        return context.loaders.request.load(uuid);
      case "classified":
        return context.loaders.classified.load(uuid);
      case "purchase":
        return context.loaders.purchase.load(uuid);
      default:
        return null;
    }
  } catch (error) {
    return null; // Silent failure for invalid IDs
  }
},

},

Node: { __resolveType(obj: any) { // Detect type based on unique fields or __typename if (obj.__typename) return obj.__typename; if ("condition" in obj && "priceMinimum" in obj) return "Request"; if ("platformConfigId" in obj && "visibilityStatus" in obj) return "Classified"; if ("buyerEmail" in obj || "shippingAddress" in obj) return "Purchase"; return null; }, }, };

Entity Type with Global ID type Request implements Node { id: ID! # Global ID (base64 encoded) title: String! description: String! # ... other fields }

// resolvers/request.ts Request: { // Encode database UUID to global ID id(request: any) { return encodeGlobalId("request", request.id); }, }

DataLoaders for Batching

Essential for efficient Node queries and avoiding N+1 problems. The N+1 problem occurs when fetching a list of items (1 query), then fetching related data for each item individually (N queries).

Key DataLoader principles:

Create fresh DataLoaders per request (never share across requests) Always return results in the same order as input keys Use caching within a single request to deduplicate identical fetches Set maxBatchSize to prevent overly large queries // dataloaders/request.loader.ts import DataLoader from "dataloader";

export function createRequestLoader(db: Database) { return new DataLoader( async (uuids) => { // Batch fetch all requested entities in a single query // e.g., SELECT * FROM requests WHERE id IN (uuid1, uuid2, ...) const results = await db.requests.findByIds([...uuids]);

  // CRITICAL: Return results in same order as input uuids
  // DataLoader requires 1:1 mapping between keys and results
  const resultMap = new Map(results.map((r) => [r.id, r]));
  return uuids.map((uuid) => resultMap.get(uuid) ?? null);
},
{
  cache: true,        // Cache within this request
  maxBatchSize: 100,  // Limit batch size for DB query performance
}

); }

// dataloaders/index.ts export interface Loaders { request: DataLoader; classified: DataLoader; purchase: DataLoader; }

export function createLoaders(db: Database): Loaders { return { request: createRequestLoader(db), classified: createClassifiedLoader(db), purchase: createPurchaseLoader(db), }; }

Context with DataLoaders

Create fresh DataLoaders per request:

// server.ts export interface GraphQLContext { user: User | null; permissions: Permissions; db: Database; loaders: Loaders; }

app.use( "/graphql", expressMiddleware(apolloServer, { context: async ({ req }): Promise => ({ user: (req as any).connectedUser ?? null, permissions: (req as any).connectedUserPermissions, db: database, loaders: createLoaders(database), // Fresh loaders per request }), }) );

Offset-Based Pagination (Connection Pattern)

schema/common.graphql

type PageInfo { total: Int! offset: Int! limit: Int! hasNextPage: Boolean! hasPreviousPage: Boolean! }

input PaginationInput { offset: Int = 0 limit: Int = 50 }

type RequestConnection { items: [Request!]! pageInfo: PageInfo! }

extend type Query { requests(pagination: PaginationInput): RequestConnection! }

// resolvers/request.ts async requests( parent: any, { pagination = {} }: { pagination?: { offset?: number; limit?: number } }, context: GraphQLContext ) { const { offset = 0, limit = 50 } = pagination;

const [items, total] = await Promise.all([ context.db.requests.findMany({ where: { deletedAt: null }, skip: offset, take: limit, orderBy: { createdAt: "asc" }, }), context.db.requests.count({ where: { deletedAt: null } }), ]);

return { items, pageInfo: { total, offset, limit, hasNextPage: offset + limit < total, hasPreviousPage: offset > 0, }, }; }

Silent Permission Handling (Queries)

For queries, return null instead of throwing errors:

async request(parent: any, { id }: { id: string }, context: GraphQLContext) { try { const { type, uuid } = decodeGlobalId(id);

// Wrong type - return null silently
if (type !== "request") {
  return null;
}

// No permission - return null silently
if (!context.permissions?.canViewRequest?.(uuid)) {
  return null;
}

const request = await context.loaders.request.load(uuid);
return request ?? null;

} catch (error) { console.error("Error fetching request:", error); return null; } }

Query Examples

Fetch single entity by global ID

query GetRequest { request(id: "cmVxdWVzdDphYmMxMjM=") { id title description } }

Using node query for any entity

query GetNode { node(id: "cmVxdWVzdDphYmMxMjM=") { id ... on Request { title price } ... on Classified { status url } } }

Paginated list

query ListRequests { requests(pagination: { offset: 0, limit: 10 }) { items { id title } pageInfo { total hasNextPage } } }

返回排行榜