# Patterns — Designing GraphQL APIs ## Contents - Connection SDL (Relay style) - Mutation with union result - DataLoader implementation - Depth and complexity limits - Deprecation flow - Gotchas ## Connection SDL (Relay style) ```graphql type Query { orders(first: Int = 20, after: String, status: OrderStatus): OrderConnection! } type OrderConnection { edges: [OrderEdge!]! pageInfo: PageInfo! totalCount: Int # nullable: expensive, resolve only when asked } type OrderEdge { node: Order! cursor: String! } type PageInfo { hasNextPage: Boolean! endCursor: String } ``` Clamp `first` to a max of 100 in the resolver (same reasoning as REST page limits); reject negative values with a validation error. ## Mutation with union result ```graphql input CreateOrderInput { customerId: ID! items: [OrderItemInput!]! } union CreateOrderResult = CreateOrderSuccess | ValidationError | OutOfStockError type CreateOrderSuccess { order: Order! } type ValidationError { fields: [FieldError!]! } type OutOfStockError { itemIds: [ID!]!, message: String! } type Mutation { createOrder(input: CreateOrderInput!): CreateOrderResult! } ``` Client handles each case via `... on` fragments; the top-level `errors` array stays reserved for genuine faults (auth failure, internal error). ## DataLoader implementation ```python # Python (aiodataloader); same shape in JS's dataloader package. from aiodataloader import DataLoader class CustomerLoader(DataLoader): async def batch_load_fn(self, ids): rows = await db.fetch( "SELECT * FROM customers WHERE id = ANY($1)", ids) by_id = {r["id"]: r for r in rows} # Must return results in the SAME ORDER as ids, None for misses. return [by_id.get(i) for i in ids] # Create ONE loader per request (in context), never a module-level singleton — # its cache would leak data across users. async def resolve_customer(order, info): return await info.context["customer_loader"].load(order["customer_id"]) ``` ## Depth and complexity limits ```javascript // graphql-depth-limit + graphql-query-complexity (npm) validationRules: [ depthLimit(10), // pathological nesting stops here createComplexityRule({ maximumComplexity: 1000, // ~1 point per field listFactor: 10, // lists multiply child cost onComplete: (c) => log.info({ complexity: c }), }), ] ``` Return the budget in the rejection message so clients can adapt: `"Query complexity 2140 exceeds maximum 1000"`. ## Deprecation flow ```graphql type Order { total: Money! amount: Int @deprecated(reason: "Use total; amount is cents-only and removed after 2027-01-01.") } ``` 1. Add the replacement field. 2. Deprecate with a reason that names the replacement and a date. 3. Monitor field usage (most gateways report it). 4. Remove only when usage is zero or the date passes. ## Gotchas - **Non-null cascade:** an error in `Order.customer: Customer!` nulls the entire `order` — with `Customer` (nullable) only the field nulls. This is why rule 2 defaults to nullable. - **DataLoader order contract:** `batch_load_fn` must return exactly len(ids) results in input order; returning a dict or short list corrupts unrelated resolvers silently. - **Enums over booleans:** `status: OrderStatus` beats `isActive/isArchived` boolean pairs that can contradict each other. - **Introspection in production:** disable for public APIs unless the API is deliberately open; it enumerates your entire attack surface. - **`totalCount` on large tables** is a full COUNT(*) per query — make it nullable and resolve lazily, or return an estimate and say so. - **Cursor stability:** cursors must encode the ORDER BY key, not row position, or pagination skips/duplicates under concurrent writes.