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.")
}- Add the replacement field. 2. Deprecate with a reason that names the replacement and a date. 3. Monitor field usage (most gateways report it).
- Remove only when usage is zero or the date passes.
Gotchas
- Non-null cascade: an error in
Order.customer: Customer!nulls the entireorder— withCustomer(nullable) only the field nulls. This is why rule 2 defaults to nullable. - DataLoader order contract:
batch_load_fnmust return exactly len(ids) results in input order; returning a dict or short list corrupts unrelated resolvers silently. - Enums over booleans:
status: OrderStatusbeatsisActive/isArchivedboolean 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.
totalCounton 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.