Designing GraphQL Schema for Web Applications
A good GraphQL schema is a contract between frontend and backend that lasts for years. A poorly designed schema accumulates workarounds within three months: broken n+1 queries, fields with unclear semantics, type UserOrError instead of proper error handling. Design starts with usage questions, not by mapping database tables.
Design Principles
Schema is product-oriented, not storage-oriented. REST endpoints often mirror database structure. In GraphQL—the opposite: first determine what the UI needs, then design types.
Nodes and Edges via Relay specification. For medium-sized and larger projects, adopt Relay-compatible structure from the start—it standardizes pagination and global IDs.
Nullable by default. A nullable field is better than non-null that breaks the entire query on one null value. Use non-null (!) only where you guarantee a value at the business logic level.
Base Types
type Query {
node(id: ID!): Node
product(id: ID!): Product
products(filter: ProductFilter, page: PaginationInput): ProductConnection!
order(id: ID!): Order
viewer: User # current authenticated user
}
type Mutation {
createOrder(input: CreateOrderInput!): CreateOrderPayload!
updateOrderStatus(input: UpdateOrderStatusInput!): UpdateOrderStatusPayload!
addToCart(input: AddToCartInput!): AddToCartPayload!
}
type Subscription {
orderStatusUpdated(orderId: ID!): OrderStatusEvent!
}
interface Node {
id: ID!
}
Payload Pattern for Mutations
Never return a bare object type from a mutation. Use a payload wrapper:
type CreateOrderPayload {
order: Order # null on error
userErrors: [UserError!]! # always an array, empty on success
}
type UserError {
field: [String!] # path to error field, e.g. ["items", "0", "quantity"]
message: String!
code: OrderErrorCode
}
enum OrderErrorCode {
INSUFFICIENT_STOCK
INVALID_ADDRESS
PAYMENT_DECLINED
PRODUCT_UNAVAILABLE
}
Difference between userErrors and GraphQL errors (errors): userErrors are predictable business errors the client should handle. GraphQL errors are unforeseen situations (exceptions, network errors).
Pagination: Cursor-Based
type ProductConnection {
edges: [ProductEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type ProductEdge {
node: Product!
cursor: String! # opaque base64 cursor
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
input PaginationInput {
first: Int
after: String
last: Int
before: String
}
Filtering and Sorting
input ProductFilter {
categoryIds: [ID!]
priceMin: Decimal
priceMax: Decimal
inStock: Boolean
search: String
tags: [String!]
}
enum ProductSortField {
PRICE
CREATED_AT
POPULARITY
NAME
}
enum SortDirection {
ASC
DESC
}
input ProductSort {
field: ProductSortField!
direction: SortDirection!
}
Versioning and Deprecation
GraphQL isn't versioned via URL (/v2/graphql). Instead—continuous evolution:
type Product {
id: ID!
name: String!
# Deprecated field—kept for compatibility
price: Decimal @deprecated(reason: "Use `pricing.basePrice` instead")
pricing: ProductPricing!
variants: [ProductVariant!]!
categories: [Category!]!
# Debug metadata
_debug: ProductDebugInfo @deprecated(reason: "Debug only, remove before production")
}
type ProductPricing {
basePrice: Decimal!
salePrice: Decimal
currency: CurrencyCode!
isOnSale: Boolean!
discountPercent: Int
}
Directives
Custom directives for cross-cutting concerns:
directive @auth(requires: Role = USER) on FIELD_DEFINITION
directive @rateLimit(max: Int!, window: String!) on FIELD_DEFINITION
directive @cacheControl(maxAge: Int, scope: CacheControlScope) on FIELD_DEFINITION | OBJECT
enum Role { ADMIN MANAGER USER GUEST }
enum CacheControlScope { PUBLIC PRIVATE }
type Order {
id: ID!
status: OrderStatus!
items: [OrderItem!]!
customer: User! @auth(requires: MANAGER)
internalNotes: String @auth(requires: ADMIN)
}
type Query {
products: ProductConnection! @cacheControl(maxAge: 300, scope: PUBLIC)
dashboard: DashboardStats! @auth(requires: MANAGER) @rateLimit(max: 60, window: "1m")
}
Complete Entity Schema Example
type Order implements Node {
id: ID!
number: String! # human-readable number, e.g. "ORD-2024-001234"
status: OrderStatus!
createdAt: DateTime!
updatedAt: DateTime!
completedAt: DateTime
customer: User!
shippingAddress: Address!
billingAddress: Address
items: [OrderItem!]!
itemsCount: Int!
subtotal: Money!
shippingCost: Money!
discount: Money!
total: Money!
payment: Payment
shipment: Shipment
timeline: [OrderTimelineEvent!]! # status change history
}
scalar DateTime
scalar Decimal
type Money {
amount: Decimal!
currency: CurrencyCode!
formatted: String! # "1,234.56 USD"—for direct display
}
Schema Splitting by Domain
For large projects, split schema into files by domain and merge on load:
schema/
base.graphql # Query, Mutation, Subscription, Node
products.graphql
orders.graphql
users.graphql
payments.graphql
scalars.graphql # DateTime, Decimal, Money, CurrencyCode
directives.graphql
Timelines
Schema design for mid-scale application (5–10 entities): 3–5 days. With review, field documentation, and coordination with frontend team: 1 week.







