GraphQL API Design
GraphQL lets clients select the fields they need through one typed schema. This flexibility becomes an operational risk unless schema governance, resolver cost, authorization, and query complexity are controlled.
Quick Decision
| Need | GraphQL approach | Watch out |
|---|---|---|
| Different screens need different fields | One endpoint with a typed schema | Bound query cost |
| Command operation | Mutation | Define idempotency and errors |
| Live updates | Subscription | Measure connection and fan-out cost |
| Multiple domains | Federation or composition | Manage ownership and breaking changes |
Production Checklist
- Problem: Does GraphQL actually reduce endpoint growth or over-fetching?
- Solution: Are schema owner, resolver timeout, auth scope, complexity limit, and cache strategy clear?
- Trade-off: Client flexibility increases; query cost and observability become more complex.
- Failure mode: Handle partial data, resolver timeouts, downstream failures, and subscription disconnects.
- Measurement: Track operation name, query complexity, resolver latency, error paths, and active connections.
- Security/cost: Tie introspection, depth, alias, and batch-abuse controls to production policy.
Schema-First Design
The schema is the primary client/server contract. Types, fields, nullability, enums, inputs, and error behavior are designed together:
type Order {
id: ID!
status: OrderStatus!
items: [OrderItem!]!
}
type Query {
order(id: ID!): Order
orders(after: String, first: Int = 20): OrderConnection!
}
type Mutation {
cancelOrder(input: CancelOrderInput!): CancelOrderPayload!
}Adding a field is usually backward compatible; deleting a field, changing nullability, or changing its meaning may be a breaking change. With federation, each subgraph should own its fields and manage shared entity IDs through a versioned contract.
Resolvers, Mutations, and Subscriptions
A resolver does more than fetch data: it carries auth context, timeouts, batching, and error translation. Avoid long synchronous chains; use bounded parallelism and downstream deadlines.
Mutation results should include the processed resource state, a stable error code, and an idempotency result when applicable. For long work, return a job ID and direct the client to a status query or webhook flow.
Design subscriptions with connection identity, authorization, heartbeat, reconnect, backpressure, tenant isolation, and broker fan-out. A disconnect may mean a lost or duplicated event; document event IDs and resume behavior.
DataLoader and N+1
If a list resolver performs one downstream call per item, it creates N+1. DataLoader batches keys within one request scope. It must not become a global cache, or user/tenant isolation can break.
@BatchMapping
Map<String, List<OrderItem>> items(List<Order> orders) {
return itemService.findByOrderIds(
orders.stream().map(Order::getId).toList());
}Measure batch size, database IN limits, cache lifetime, and resolver timeout. Adding DataLoader alone does not fix a bad query plan.
Directives, Authorization, and Complexity
Directives can mark deprecations, field masking, tenant filtering, and policy metadata. Authorization must apply at sensitive field and object levels, not only at the root query.
Use depth limits, field cost, timeout, maximum aliases, pagination caps, and persisted-query allowlists together. Requiring operation names makes tracing and cost analysis useful.
Caching and Federation
Field-level cache, normalized client cache, response cache, and persisted query/APQ are different layers. Document mutation invalidation, user/tenant scope, and acceptable staleness.
Federation distributes schema ownership but adds network hops, cross-subgraph failures, composition checks, and deployment-order concerns. Without clear domain boundaries, federation creates a distributed monolith.
Outcome
GraphQL success is measured by schema evolution, resolver budgets, field-level authorization, N+1 control, and operation-level observability. It can coexist with REST; client needs and operational cost must drive the decision.
