GraphQL API Design Guideline
Internal guidelines for designing GraphQL APIs: schema structure, naming, mutation patterns, error handling, pagination, authentication, rate limiting, versioning, and federation rules with code examples.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
This document defines internal guidelines for designing GraphQL APIs at our organization. All teams building GraphQL services must follow these rules to ensure consistency, security, and performance across our API surface.
1. Schema Structure
1.1 Root Types
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!
}
1.2 Schema Organization Rules
- 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 multiple files
- Schema stitched or federated — never duplicated
2. Naming Rules
2.1 Naming Convention Table
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 - No abbreviations:
Usrinstead ofUser,Ordinstead ofOrder - No Hungarian notation:
strName,intCount - No REST-style names:
getUserById,POST /users
3. Query Design
3.1 Query Rules
- 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
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
}
4. Mutation Design
4.1 Mutation Rules
- 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
5. Error Handling
5.1 Error Categories
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
5.2 Error Rules
- Never expose stack traces or internal paths
- 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,
},
};
}
6. Authentication and Authorization
6.1 Authentication
// 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 };
}
},
});
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 is not authorized to see
- Authorization failures return
FORBIDDEN, not empty results
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)
}
7. Rate Limiting and Cost Control
7.1 Rate Limiting
// 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
8. Versioning and Evolution
8.1 Versioning Rules
- 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
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)
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
9. Federation Rules
9.1 Entity Design
- 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
# 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!
}
9.2 Federation Checklist
- 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
FAQ
Should I use subscriptions or webhooks for real-time updates?
Use subscriptions for user-facing real-time features (chat, notifications, live dashboards). Use webhooks for server-to-server integrations. Subscriptions maintain a persistent connection and scale differently than queries/mutations. If you have 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. Do not 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. Do not 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.
See Also
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, arguments, and enum values safely. Includes deprecation timeline, communication plan, usage tracking, removal criteria, and migration examples.
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.