GraphQL
Purpose
Design a GraphQL schema that models the domain rather than the database, and operate it without letting a single query take down the service.
When to Use
- Designing a GraphQL schema from scratch.
- Diagnosing slow queries or N+1 resolver behavior.
- Protecting a public GraphQL endpoint from expensive queries.
- Splitting a schema across services with federation.
Capabilities
- Schema design: types, interfaces, unions, connections, nullability.
- Resolver architecture and DataLoader batching.
- Query cost analysis, depth limiting, and persisted queries.
- Error handling that distinguishes partial failures from total ones.
- Federation and schema composition.
Inputs
- The domain model and the client's actual query patterns.
- The data sources behind each field.
- Whether the endpoint is public (untrusted queries) or internal.
Outputs
- A schema with deliberate nullability and stable field names.
- Resolvers that batch, with no N+1 on any documented query.
- Cost limits and a persisted-query allow-list for public endpoints.
Workflow
- Design for the client, not the tables — The schema is a product surface. If it mirrors your database, you have built a slower REST API with worse caching.
- Get nullability right early — A nullable field is a permanent client burden; a non-null field that later fails takes down the whole parent object. Non-null for genuine invariants only.
- Batch every relation — Every resolver that fetches by id gets a DataLoader. Without one,
orders { customer { name } } issues one query per order.
- Bound the cost — Depth limit, complexity limit, and pagination caps. Then persisted queries for first-party clients.
- Model errors explicitly — Expected failures (validation, not found) belong in the schema as union results; unexpected failures go to
errors.
Best Practices
- Never expose an unbounded list field. Use the Connection pattern with a
first/after cap.
DataLoader instances are per-request. A shared loader is a cache-poisoning bug across users.
- Changing a field from nullable to non-null is a breaking change for clients that handle null; the reverse is breaking for clients that do not. Get it right before launch.
- Do not version a GraphQL schema. Add fields, deprecate old ones with
@deprecated(reason:), and remove them once usage reaches zero.
- Instrument per-field resolver latency. The slow field is never the one you would guess.
- Introspection on a public production endpoint is a reconnaissance gift. Disable it or restrict it.
Examples
DataLoader eliminating an N+1:
// Without a loader: 1 query for orders, then N queries for customers.
const resolvers = {
Order: {
customer: (order, _args, ctx) => ctx.loaders.customer.load(order.customerId),
},
};
// The loader batches every customerId requested in the same tick into one query.
export function createLoaders(db: Db) {
return {
customer: new DataLoader<string, Customer>(async (ids) => {
const rows = await db.customers.findMany({ where: { id: { in: [...ids] } } });
const byId = new Map(rows.map((r) => [r.id, r]));
return ids.map((id) => byId.get(id) ?? new Error(`Customer ${id} not found`));
}),
};
}
Expected failures modeled in the schema:
union CreateOrderResult = Order | ValidationFailed | InsufficientInventory
type Mutation {
createOrder(input: CreateOrderInput!): CreateOrderResult!
}
The client must handle each outcome. Business failures no longer masquerade as transport errors.
Notes
- Query complexity limits must be tuned against real queries, not guessed. Log the complexity of every production query for a week before enforcing a ceiling.
- Federation solves an organizational problem (independent teams owning parts of the graph), not a technical one. If one team owns the whole schema, a single service is simpler.
- GraphQL over HTTP GET with persisted queries restores CDN caching, which naive POST-based GraphQL throws away.
1---2name: graphql3description: Use when designing or operating a GraphQL API. Covers schema design, resolver performance and DataLoader batching, query cost limiting, error handling, and federation.4---56# GraphQL78## Purpose910Design a GraphQL schema that models the domain rather than the database, and operate it without letting a single query take down the service.1112## When to Use1314- Designing a GraphQL schema from scratch.15- Diagnosing slow queries or N+1 resolver behavior.16- Protecting a public GraphQL endpoint from expensive queries.17- Splitting a schema across services with federation.1819## Capabilities2021- Schema design: types, interfaces, unions, connections, nullability.22- Resolver architecture and DataLoader batching.23- Query cost analysis, depth limiting, and persisted queries.24- Error handling that distinguishes partial failures from total ones.25- Federation and schema composition.2627## Inputs2829- The domain model and the client's actual query patterns.30- The data sources behind each field.31- Whether the endpoint is public (untrusted queries) or internal.3233## Outputs3435- A schema with deliberate nullability and stable field names.36- Resolvers that batch, with no N+1 on any documented query.37- Cost limits and a persisted-query allow-list for public endpoints.3839## Workflow40411. **Design for the client, not the tables** — The schema is a product surface. If it mirrors your database, you have built a slower REST API with worse caching.422. **Get nullability right early** — A nullable field is a permanent client burden; a non-null field that later fails takes down the whole parent object. Non-null for genuine invariants only.433. **Batch every relation** — Every resolver that fetches by id gets a DataLoader. Without one, `orders { customer { name } }` issues one query per order.444. **Bound the cost** — Depth limit, complexity limit, and pagination caps. Then persisted queries for first-party clients.455. **Model errors explicitly** — Expected failures (validation, not found) belong in the schema as union results; unexpected failures go to `errors`.4647## Best Practices4849- Never expose an unbounded list field. Use the Connection pattern with a `first`/`after` cap.50- `DataLoader` instances are per-request. A shared loader is a cache-poisoning bug across users.51- Changing a field from nullable to non-null is a breaking change for clients that handle null; the reverse is breaking for clients that do not. Get it right before launch.52- Do not version a GraphQL schema. Add fields, deprecate old ones with `@deprecated(reason:)`, and remove them once usage reaches zero.53- Instrument per-field resolver latency. The slow field is never the one you would guess.54- Introspection on a public production endpoint is a reconnaissance gift. Disable it or restrict it.5556## Examples5758**DataLoader eliminating an N+1:**5960```typescript61// Without a loader: 1 query for orders, then N queries for customers.62const resolvers = {63 Order: {64 customer: (order, _args, ctx) => ctx.loaders.customer.load(order.customerId),65 },66};6768// The loader batches every customerId requested in the same tick into one query.69export function createLoaders(db: Db) {70 return {71 customer: new DataLoader<string, Customer>(async (ids) => {72 const rows = await db.customers.findMany({ where: { id: { in: [...ids] } } });73 const byId = new Map(rows.map((r) => [r.id, r]));74 return ids.map((id) => byId.get(id) ?? new Error(`Customer ${id} not found`));75 }),76 };77}78```7980**Expected failures modeled in the schema:**8182```graphql83union CreateOrderResult = Order | ValidationFailed | InsufficientInventory8485type Mutation {86 createOrder(input: CreateOrderInput!): CreateOrderResult!87}88```8990The client must handle each outcome. Business failures no longer masquerade as transport errors.9192## Notes9394- Query complexity limits must be tuned against real queries, not guessed. Log the complexity of every production query for a week before enforcing a ceiling.95- Federation solves an organizational problem (independent teams owning parts of the graph), not a technical one. If one team owns the whole schema, a single service is simpler.96- GraphQL over HTTP GET with persisted queries restores CDN caching, which naive POST-based GraphQL throws away.