GraphQL API Design Guidelines
Internal GraphQL API design guidelines template with schema structure, naming, mutations, pagination, auth, rate limiting, versioning, and federation rules.
Overview
This document is a copy-paste template for internal GraphQL API design guidelines. It defines the rules all teams building GraphQL services follow — schema structure, naming, query and mutation design, error handling, auth, rate limiting, versioning, and federation — so that every API in the organization looks and behaves the same way.
Use it as a starting point for your own design document: copy the rules that fit, adjust the numbers to your scale, and delete what doesn’t apply. For the background and reasoning behind schema design decisions, read the Complete Guide to GraphQL Schema Design first — this document is the enforcement layer, not the tutorial.
Two things make this worth standardizing early. First, GraphQL clients couple to the schema harder than REST clients couple to endpoints: field names, nullability, and pagination shapes all become part of your public contract. Second, most GraphQL mistakes are expensive to undo because there’s no versioning escape hatch — you evolve the schema in place or you break clients.
I’ve watched teams postpone this document until the third service shipped, and by then the cleanup cost more than the writing would have: three naming conventions, two pagination styles, and a deprecation nobody wanted to own. Write the rules while there’s still one schema to apply them to.
1. Schema Structure
1.1 Root Types
Every schema declares the three root types explicitly, even when subscriptions aren’t used yet. Keeping subscription in the schema skeleton means you don’t restructure files later, and it documents that real-time operations are a first-class part of the API.
schema {
query: Query
mutation: Mutation
subscription: Subscription
}
type Query {
# Single resource by ID
user(id: ID!): User
node(id: ID!): Node
# List with pagination
users(first: Int, after: String, filter: UserFilter): UserConnection!
}
type Mutation {
# CRUD operations
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(input: UpdateUserInput!): UpdateUserPayload!
deleteUser(input: DeleteUserInput!): DeleteUserPayload!
}
type Subscription {
userUpdated(id: ID!): UserUpdatePayload!
}
The node(id: ID!) field is the Relay convention for refetching any object exposing the Node interface. It’s optional, but if you use Apollo Client or Relay on the frontend you’ll want it — normalized caches use it to update objects in place.
1.2 Schema Organization Rules
One file per domain keeps merge conflicts rare and ownership obvious. When users.graphql and orders.graphql both grow to a thousand lines, splitting by domain is what lets two teams work without blocking each other.
- One schema file per domain (e.g.,
users.graphql,orders.graphql) - Shared types in
shared.graphql - Custom scalars in
scalars.graphql - No type defined in more than one file
- Schema stitched or federated — never duplicated
Duplicated types are the failure mode to watch for. If Money or Address ends up defined in three files, stitching tools will either pick one silently or fail composition. Keep shared types in shared.graphql and import them. I’ve debugged the “two Money types, one wrong” flavor of this bug twice; both times it surfaced as a client reading a price that silently swapped currency fields.
2. Naming Rules
2.1 Naming Convention Table
Naming consistency is cheap to enforce at authoring time and expensive to retrofit — renaming a field is a breaking change that goes through the deprecation cycle in section 8. Most schema arguments I’ve mediated were really naming arguments wearing a technical costume, so this table exists to end them before they start.
Element | Convention | Example
─────────────────┼───────────────────┼──────────────────
Type | PascalCase | ProductOrder
Interface | PascalCase | Node, Ownable
Union | PascalCase | SearchResult
Enum | PascalCase | OrderStatus
Enum value | UPPER_SNAKE_CASE | PENDING, SHIPPED
Field | camelCase | createdAt
Argument | camelCase | pageSize
Input type | {Type}Input | CreateUserInput
Payload type | {Verb}{Type}Payload | CreateUserPayload
Connection | {Type}Connection | UserConnection
Edge | {Type}Edge | UserEdge
Filter input | {Type}Filter | UserFilter
Sort input | {Type}Sort | UserSort
Directive | camelCase | @deprecated
Custom scalar | PascalCase | DateTime, URL
2.2 Forbidden Names
- No generic names:
Status,Type,Data,Result,Info— they collide the moment two domains need the same concept, and they force clients intoorderStatusvspaymentStatusambiguity anyway. - No abbreviations:
Usrinstead ofUser,Ordinstead ofOrder. - No Hungarian notation:
strName,intCount. The type system already carries this information. - No REST-style names:
getUserById,POST /users. GraphQL fields describe data, not operations.
The generic-name ban matters more than it looks. A Status enum in shared.graphql seems convenient until billing needs PaymentStatus and shipping needs ShipmentStatus — then you’re either deprecating the shared enum or living with orderStatus: Status where nobody knows which state machine it belongs to.
3. Query Design
3.1 Query Rules
Queries must stay read-only. Nothing enforces this at the protocol level — a resolver can do anything — but it’s the rule that keeps caching, persisted queries, and CDN strategies viable. The moment a query writes data, you can’t safely replay it and you can’t cache it.
- Queries must be read-only — no side effects
- Single resource queries accept
id: ID!argument - List queries use Connection pattern with
first,after,last,before - List queries accept a
filterargument for filtering - List queries accept a
sortargument for ordering - No query returns more than 100 items per page
- No nested queries deeper than 10 levels
The 10-level depth limit exists because depth is the cheapest way to build an abusive query: user { friends { friends { friends { ... } } } } compounds exponentially. Ten levels is generous for legitimate UIs — most real queries stay under six.
type Query {
# Single resource
product(id: ID!): Product
productBySlug(slug: String!): Product
# List with full pagination
products(
first: Int = 20
after: String
filter: ProductFilter
sort: ProductSort
): ProductConnection!
}
input ProductFilter {
category: ProductCategory
priceMin: Float
priceMax: Float
inStock: Boolean
search: String
}
input ProductSort {
field: ProductSortField!
direction: SortDirection!
}
enum ProductSortField {
NAME
PRICE
CREATED_AT
POPULARITY
}
enum SortDirection {
ASC
DESC
}
Cursor-based Connection pagination is the standard for a reason: offset pagination breaks when rows are inserted while a client is paginating, and it forces the database to count and discard rows on every request. Cursors key the next page off the last row seen, so inserts don’t shift results and the database seeks instead of scanning.
The trade-off is that cursors don’t support “jump to page 47”. If your admin UI needs arbitrary page jumps, expose offset pagination on a separate field for internal use only — don’t make it the public contract.
4. Mutation Design
4.1 Mutation Rules
Every mutation takes a single input argument and returns a payload type. The single-input convention isn’t aesthetic — it’s what lets you add optional fields later without breaking clients. createUser(name: String!, email: String!) becomes a breaking change the day you add phone, while createUser(input: CreateUserInput!) absorbs it for free.
- All mutations take a single
inputargument - All mutations return a payload type (not the entity directly)
- Payload types include the mutated entity and error array
- Mutation names start with a verb:
create,update,delete,archive,restore - Bulk mutations use plural:
createUsers,deleteProducts - No partial success — mutations are all-or-nothing
type Mutation {
createOrder(input: CreateOrderInput!): CreateOrderPayload!
updateOrder(input: UpdateOrderInput!): UpdateOrderPayload!
cancelOrder(input: CancelOrderInput!): CancelOrderPayload!
archiveOrder(input: ArchiveOrderInput!): ArchiveOrderPayload!
}
input CreateOrderInput {
customerId: ID!
items: [OrderItemInput!]!
shippingAddressId: ID!
notes: String
}
type CreateOrderPayload {
order: Order
userErrors: [UserError!]!
}
type UserError {
field: String
message: String!
code: UserErrorCode!
}
enum UserErrorCode {
INVALID_INPUT
NOT_FOUND
UNAUTHORIZED
FORBIDDEN
CONFLICT
RATE_LIMITED
}
4.2 Mutation Payload Rules
- Payload type name:
{Verb}{Type}Payload - Payload includes the mutated entity (nullable — null on error)
- Payload includes
userErrors: [UserError!]!(empty array on success) - Payload may include related fields (e.g.,
orderCountaftercreateOrder) - No generic
success: Booleanfield — presence of entity indicates success
The userErrors pattern handles the errors that GraphQL’s top-level errors array can’t: expected, business-level failures the user can fix. A top-level error means “something went wrong”; a userError means “your input was rejected, here’s which field and why”. Clients render userErrors inline on the form that submitted the mutation — that’s why field is in there.
success: Boolean is forbidden because it adds a state the client has to check without carrying information. order != null already tells you the mutation succeeded, and userErrors being empty confirms it. Two signals for the same fact invite them to disagree — and in practice they do disagree, the first time a resolver throws after partially writing.
5. Error Handling
5.1 Error Categories
Error codes are part of the API contract — clients switch on them, so they must be stable and documented like field names. Renaming an error code is the same breaking change as renaming a field, and teams forget that more often than you’d expect.
Category | Code | HTTP equivalent
──────────────────┼───────────────────┼──────────────────
Validation | INVALID_INPUT | 400
Authentication | UNAUTHENTICATED | 401
Authorization | FORBIDDEN | 403
Not found | NOT_FOUND | 404
Conflict | CONFLICT | 409
Rate limited | RATE_LIMITED | 429
Internal | INTERNAL_ERROR | 500
UNAUTHENTICATED and FORBIDDEN are deliberately distinct: the first means “we can’t identify you” (client can retry with credentials), the second means “we know you and the answer is no” (retrying won’t help).
5.2 Error Rules
- Never expose stack traces or internal paths — not even to admins. Attackers read error messages too.
- Field-level errors include the
fieldpath - Error messages are user-friendly, not developer-facing
- Error codes are stable and documented
- Rate limit errors include
retryAfterin the error extension - Internal errors are logged with correlation IDs
- No error message contains PII or secrets
// Error formatting
function formatError(error) {
return {
message: error.message,
extensions: {
code: error.extensions?.code || 'INTERNAL_ERROR',
field: error.extensions?.field,
retryAfter: error.extensions?.retryAfter,
correlationId: context.correlationId,
},
};
}
The extensions object is where machine-readable detail lives — keep message human-readable and put anything a client might branch on (code, field, retryAfter) in extensions. Correlation IDs are what let a user report “it failed” and your team find the exact log line without a timestamp hunt. The first time a user pasted a correlation ID into a support ticket and we found the failing resolver in seconds, this rule paid for itself.
6. Authentication and Authorization
6.1 Authentication
Authentication happens at the transport layer, before GraphQL sees the request. The context factory verifies the token once and hands every resolver a user object — resolvers shouldn’t parse headers themselves.
// Authentication via JWT in Authorization header
const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ req }) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return { user: null };
try {
const user = jwt.verify(token, process.env.JWT_SECRET);
return { user, loaders: createLoaders() };
} catch {
return { user: null };
}
},
});
A failed token returns user: null, not an error — the request proceeds and authorization rules decide what an anonymous user can see. This keeps public fields working without a special “unauthenticated” code path. I’ve also watched a team debug “works on my machine” token issues for an afternoon because two resolvers parsed the header slightly differently — the context factory exists so that bug can’t happen twice.
6.2 Authorization Rules
- Every field resolver checks authorization
- Use
@authdirective or middleware for field-level auth - Public fields explicitly marked as public
- Admin-only fields check role before resolving
- No field returns data the user isn’t authorized to see
- Authorization failures return
FORBIDDEN, not empty results
Field-level auth matters more in GraphQL than REST because one query can touch a dozen resources. A user who can read User.orders shouldn’t automatically read User.email — the schema makes that distinction explicit with directives rather than burying it in resolver logic. For a working implementation of directive-based auth, see the @auth directive recipe.
directive @auth(requires: Role!) on FIELD_DEFINITION
enum Role {
ADMIN
MANAGER
USER
PUBLIC
}
type User {
id: ID!
email: String! @auth(requires: ADMIN)
orders: [Order!]! @auth(requires: USER)
auditLog: [AuditEntry!]! @auth(requires: ADMIN)
}
Returning FORBIDDEN instead of empty results is deliberate: an empty orders list and a forbidden orders list look identical to a client, and silently returning [] teaches users to distrust the data. Explicit errors make authorization bugs visible instead of hiding them as missing data.
7. Rate Limiting and Cost Control
7.1 Rate Limiting
Request-counting rate limits are necessary but not sufficient for GraphQL — a single request can be arbitrarily expensive. You need both a request limit and a cost model (section 7.2).
I learned this the embarrassing way: our first “100 requests/minute” limit did nothing when a scraping bot discovered it could nest users { posts { comments { ... } } } and get the whole database in fifty requests. Counting requests is counting envelopes; cost analysis is weighing what’s inside them.
// Per-user rate limiting
import { RateLimiterMemory } from 'rate-limiter-flexible';
const rateLimiter = new RateLimiterMemory({
points: 100, // 100 requests
duration: 60, // per 60 seconds
});
// In context middleware
async function checkRateLimit(user) {
try {
await rateLimiter.consume(user.id);
} catch {
throw new GraphQLError('Rate limit exceeded', {
extensions: { code: 'RATE_LIMITED', retryAfter: 60 },
});
}
}
7.2 Cost Control Rules
- Maximum query depth: 10
- Maximum query complexity: 1000
- Maximum aliases per field: 5
- Rate limit: 100 requests per minute per user
- Mutation rate limit: 30 per minute per user
- Subscription limit: 10 concurrent per user
- File upload size limit: 10MB
- Response size limit: 5MB
Complexity scoring assigns a cost to each field (lists cost more, often children × fieldCost) and rejects queries over budget before execution. It’s what stops users { orders { items { product { reviews { author { ... } } } } } } from becoming a free database dump — a query like that’s still one HTTP request, but it can fan out to thousands of resolver calls.
The alias limit closes a specific bypass: a: users { ... } b: users { ... } c: users { ... } runs the expensive field three times in one request while looking like one query. Five aliases per field covers legitimate use (comparing two filtered views) without opening the door to multiplication.
Tune these numbers to your traffic. A public API for third-party developers needs tighter limits than an internal API behind your own frontend, and expensive fields (search, aggregations) deserve their own higher cost weight. Start strict and loosen on evidence: it’s far easier to raise a limit after an angry ticket than to lower one after a 3 a.m. incident.
8. Versioning and Evolution
8.1 Versioning Rules
GraphQL’s answer to versioning is that you don’t version — you evolve. A /v2/graphql endpoint means maintaining two schemas forever; an additive schema means old clients keep working while new clients use new fields.
- No URL-based versioning (no
/v2/graphql) - Schema evolution is additive only
- Breaking changes go through deprecation process (min 6 months)
- New fields can be added without versioning
- Field removal requires zero usage for 30 days
- Type changes require new type creation + deprecation
The “zero usage for 30 days” rule only works if you measure field usage — which is why schema analytics (Apollo Studio, GraphQL Inspector, or your gateway logs) are a prerequisite for safe evolution, not an optional extra. Without usage data, “is anyone still querying this field?” is unanswerable.
8.2 Allowed Changes (Non-Breaking)
Adding:
- New fields to existing types
- New types
- New enum values (with documentation)
- New optional arguments
- New interfaces
Changing:
- Field description
- Directive arguments (additive)
One caveat on enum values: adding a value is technically non-breaking, but a client that switches exhaustively on OrderStatus will hit an unhandled case when BACKORDERED appears. Document the enum’s expected growth in its description so client developers write a default branch.
8.3 Forbidden Changes (Breaking)
Removing:
- Fields
- Types
- Enum values
- Arguments
Changing:
- Field type (String → Int)
- Non-null to nullable (acceptable but discouraged)
- Nullable to non-null (breaking)
- Argument defaults
- Argument types
Nullable-to-non-null is the subtle one: it changes what the server promises, and any client that handled null now has dead code, but worse — existing stored queries that relied on the field being nullable can now receive errors where they got data. Make fields non-null at creation or leave them nullable forever.
For the full removal process — deprecation period, usage measurement, and client notification — use the deprecation policy template.
9. Federation Rules
9.1 Entity Design
Federation splits one schema across several services behind a gateway. It’s worth the operational cost when team boundaries, deployment cycles, or scaling needs diverge — and it’s overkill when they don’t.
- Each subgraph owns its domain types
- Entity types use
@key(fields: "id")for cross-subgraph references __resolveReferenceimplemented for all entities- No subgraph extends more than 3 types from other subgraphs
- Shared scalars and enums defined in a shared package
The 3-extension limit keeps subgraph boundaries honest. A subgraph that extends five types from other teams isn’t a subgraph — it’s the old monolith with extra network hops and harder debugging. Every time I’ve seen that limit broken, the real story was two teams sharing a database and calling it federation.
# Products subgraph
type Product @key(fields: "id") {
id: ID!
name: String!
price: Float!
categoryId: ID!
}
# Reviews subgraph — extends Product
type Product @key(fields: "id") @extends {
id: ID! @external
reviews: [Review!]!
averageRating: Float!
}
For the operational details — gateway setup, composition checks, and migrating a monolith — see the Complete Guide to GraphQL Federation and the federated entity pattern.
9.2 Federation Checklist
Run this checklist before enabling a new subgraph in the gateway, and re-run it on every schema composition change. The schema owner and the platform team both sign off.
- Entity keys are globally unique
- No circular subgraph dependencies
- Custom scalars shared via npm package
- Composed schema validated with
rover - Subgraph health monitored independently
- Gateway can route queries when one subgraph is down
How to Adopt These Guidelines
A design document nobody enforces becomes archaeology. The adoption path that works:
- Publish it as a living document. These rules live in the repo next to the schema, versioned with it — not in a wiki that drifts. Changes go through the same PR review as code.
- Automate what’s automatable. Naming conventions, deprecated-field usage, and breaking changes are mechanical: enforce them with
eslint-plugin-graphql, schema linting in CI, and composition checks (rover subgraph check, GraphQL Inspector) on every pull request. Humans review judgment calls; linters catch the rest. - Review with a checklist. Every schema PR runs through the GraphQL schema review checklist — it turns these rules into a per-change pass/fail instead of a document people remember vaguely.
- Grandfather existing violations explicitly. Existing APIs that break a rule get a tracked exception with a fix-by date, not silent tolerance. Otherwise the exception count grows until the guideline means nothing.
- Onboard new teams with the template. The federation onboarding template covers the subgraph-specific ramp-up for teams joining an existing gateway.
- Revisit the numbers quarterly. Limits that made sense at 10K daily queries rot silently at 1M. Put a recurring review on the calendar — the guideline version that shipped in January shouldn’t still be the one guarding traffic in December.
See Also
Frequently Asked Questions
Should I use subscriptions or webhooks for real-time updates?
Use subscriptions for user-facing real-time work (chat, notifications, live dashboards). Use webhooks for server-to-server integrations. Subscriptions maintain a persistent connection and scale differently than queries/mutations. If you're running more than 1000 concurrent subscriptions per server, consider a dedicated subscription endpoint or external pub/sub system (Redis, NATS). Webhooks are more reliable for integrations because they retry on failure.
How do I handle file uploads in GraphQL?
Use the graphql-upload package with the multipart request specification. Define a custom Upload scalar. Enforce file size limits (10MB default). Validate file types server-side. Store files in object storage (S3, GCS) and return the URL in the mutation payload. Don't base64-encode files in mutations — it bloats the request and breaks proxies.
What is the right page size for list queries?
Default to 20 items per page. Maximum 100. For expensive queries (joins, aggregations), reduce the maximum to 50. Expose the default and maximum in your schema documentation. Let clients request smaller pages but not larger ones. For admin dashboards that need bulk data, provide a separate export endpoint rather than allowing large pages.
How do I test GraphQL resolvers?
Unit test resolvers in isolation by mocking the data layer. Test the parent argument, args, context, and info. Integration test the full schema with a test database. Test edge cases: null returns, error codes, authorization failures, rate limiting. Use snapshot testing for complex query responses. Test subscriptions by connecting a test client and verifying event delivery.
When should I split a monolithic schema into federation?
Split when teams need independent deployment cycles, when the schema exceeds 200 types, when different domains have different scaling requirements, or when team boundaries map to domain boundaries. Don't split prematurely — federation adds operational complexity (gateway, composition, cross-subgraph debugging). Start with a modular monolith and split when the pain of coupling exceeds the pain of federation.
Related Resources
GraphQL Schema Review Checklist
Checklist for reviewing GraphQL schemas: naming conventions, type design, pagination, error handling, security, performance, deprecation, and federation readiness with code examples and validation rules.
DocGraphQL Deprecation Policy Template
Policy template for deprecating GraphQL fields, types, and enum values without breaking clients, with timelines, usage tracking, and migration examples.
GuideComplete Guide to GraphQL Schema Design
Design GraphQL schemas for evolution, performance, and maintainability. Covers type design, connections, mutations, error handling, deprecation, and schema-first vs code-first workflows.
GuideComplete Guide to GraphQL Federation
Build unified GraphQL APIs across multiple services with Apollo Federation. Covers subgraphs, supergraph composition, entity resolution, and gateway deployment.
DocGraphQL Federation Onboarding Template
Template for onboarding a service to a federated GraphQL graph: subgraph setup, entity definitions, resolver configuration, gateway integration, testing, deployment, and monitoring with code examples.