GraphQL Schema Design
When to Use
Use this skill when:
- User is designing a new GraphQL API from scratch and needs schema structure guidance -- type definitions, query/mutation/subscription organization, and naming conventions
- User is migrating a REST API to GraphQL and needs to understand how to translate resource-based endpoints into a graph-based type system
- User is experiencing N+1 query problems, over-fetching, or under-fetching in an existing GraphQL schema and needs structural redesign
- User needs to model complex domain relationships -- bidirectional associations, polymorphic types, recursive structures, or union/interface hierarchies
- User is building a federated GraphQL architecture across multiple microservices and needs schema composition guidance
- User asks how to version or evolve a GraphQL schema without breaking existing clients
- User needs to design mutations with proper input types, error handling patterns, and optimistic UI support
- User is designing a schema for pagination, filtering, and sorting at scale and needs a consistent, extensible pattern
Do NOT use this skill when:
- User needs GraphQL resolver implementation guidance -- this skill covers schema structure, not resolver logic or DataLoader setup
- User needs GraphQL authentication/authorization middleware configuration -- check the backend-security skills
- User needs GraphQL client-side query writing or fragment composition -- this skill is server-schema design only
- User is asking about REST API design without GraphQL involvement -- check API design skills in the sibling subcategory
- User needs GraphQL subscription infrastructure setup (WebSocket servers, pub/sub brokers) -- that is an infrastructure concern beyond schema design
- User needs GraphQL performance monitoring, APM tooling, or query complexity analysis at runtime -- check observability skills
- User is asking about gRPC or OpenAPI/Swagger schema design -- separate skill domains
Process
1. Identify the Domain Model and Access Patterns
Before writing a single type definition, map the domain thoroughly.
- List all core business entities and draw their relationships -- identify which are nodes in a graph (independent entities with identity) vs. value objects (embedded data without identity)
- Distinguish ownership relationships (a
Post belongs to exactly one User) from association relationships (a Post has many Tags that exist independently)
- Catalog the top 10-15 read access patterns the UI actually needs -- screens, components, and dashboards drive schema shape more than the database schema does
- Catalog write operations grouped by actor, frequency, and atomicity requirements -- a single checkout mutation may span inventory, payment, and order creation
- Identify which entities are queried by ID most often vs. by collection with filtering -- entities queried by ID are candidates for the Node interface; filterable collections need pagination and filter input types
- Note cardinality: one-to-one, one-to-many, and many-to-many relationships have distinct modeling strategies in GraphQL
- Flag any data that is computed or derived (aggregates, calculated fields) -- these need special attention to avoid expensive resolver chains
2. Define the Type System Foundation
Establish scalar, enum, interface, and union types before building object types.
- Start with custom scalars for domain primitives:
DateTime (ISO-8601 strings), URL, EmailAddress, UUID, JSON, Decimal (for currency/precise numbers) -- never use raw String for typed values the client will parse
- Define enums for all closed sets of values:
OrderStatus, UserRole, PaymentMethod -- use SCREAMING_SNAKE_CASE for enum values per GraphQL spec convention
- Design interfaces for shared contracts across multiple types. The
Node interface (id: ID!) is the single most important interface -- implement it on every top-level entity to enable generic caching and refetching in clients like Apollo and Relay
- Use the
Error interface pattern to create a structured error type hierarchy: define a base UserError interface with message: String! and code: String!, then create domain-specific error types implementing it
- Define union types for truly polymorphic responses where types share no fields -- use
SearchResult = User | Post | Product rather than a single SearchResult object with nullable fields
- Create
Connection and Edge types following the Relay Cursor Connections specification -- even if not using Relay, this pattern provides a battle-tested pagination contract: UserConnection, UserEdge, PageInfo
- Establish
XxxInput naming convention for all input types used in mutations -- never reuse output object types as mutation inputs because their field requirements differ
3. Design the Query Root
Structure the Query type to be discoverable, consistent, and client-friendly.
- Use singular/plural naming rigorously:
user(id: ID!): User for single-entity lookup, users(filter: UserFilterInput, pagination: PaginationInput): UserConnection! for collection queries
- Never return nullable connections -- a collection query that finds zero results should return an empty connection, not null. Use
UserConnection! not UserConnection
- Group related fields semantically and document them with triple-quoted SDL descriptions -- these appear in GraphQL Playground, GraphiQL, and schema documentation tools
- Implement the Node interface lookup at the root:
node(id: ID!): Node enables clients to refetch any entity by global ID without knowing its type in advance
- Avoid deeply nested required arguments -- a query like
organizationUsersByRole(orgId: ID!, role: UserRole!, status: UserStatus) should use a single filter input type
- Design filter input types to be additive:
UserFilterInput { id: ID, email: String, role: UserRole, status: UserStatus, createdAfter: DateTime } -- all fields optional, combined with AND semantics by default
- Add
orderBy: [UserOrderByInput!] as a separate argument from filtering -- this keeps filtering and sorting concerns independent and allows multiple sort keys
- Consider search vs. filter: full-text search (
query: String) is a different pattern from structured filtering and should be a separate field or argument
4. Design Mutations
Mutations require the most careful design because they represent state changes with side effects.
- Follow the single-responsibility principle strictly: one mutation per logical operation.
createUser, updateUser, deleteUser are separate mutations -- never a generic mutateUser operation
- Use namespaced mutation naming for large schemas:
userCreate, userUpdate, userDelete groups all user mutations alphabetically in tooling. Alternatively use the Mutation.user: UserMutations nested object pattern (note: this breaks introspection-based tooling in some clients -- document the tradeoff)
- Every mutation takes exactly one input object argument:
createUser(input: CreateUserInput!): CreateUserPayload! -- this is the most future-proof signature because new fields can be added to the input without changing the mutation signature
- Every mutation returns a payload type, never a raw object type:
CreateUserPayload { user: User, errors: [UserError!]! } -- the payload wrapper allows adding fields like clientMutationId (Relay), warnings, and side-effect data without a breaking change
- Always include an
errors field in the mutation payload -- use the union/interface error pattern so clients can distinguish between ValidationError, AuthorizationError, NotFoundError, and ConflictError with typed fields
- Design optimistic UI support: ensure the returned payload contains all fields the UI needs to update local state without a separate refetch -- if creating a post, return the full
Post type with resolved author, category, and tag data
- Mark destructive mutations with a
DeleteXxxPayload { deletedId: ID!, success: Boolean! } pattern -- returning the deleted ID enables cache eviction in Apollo Client and similar
- Use
upsert mutations sparingly and only when the create-vs-update distinction is genuinely irrelevant to the caller -- document the idempotency semantics explicitly
5. Model Relationships and Connections
Graph relationships are the core value proposition of GraphQL -- design them carefully.
- Default to returning full nested objects rather than foreign key IDs --
post { author { id name avatarUrl } } is the GraphQL idiom; returning authorId: ID! defeats the purpose and forces the client to make additional queries
- For large one-to-many relationships (more than ~20 items), always paginate using cursor-based connections. Offset pagination (
skip/limit) has correctness issues with real-time data and does not scale -- prefer opaque cursor strings
- The Relay Cursor Connection spec fields:
edges { node cursor } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } -- implement all four pageInfo fields even if bidirectional pagination is not immediately needed
- For many-to-many relationships with edge data (a
User enrolled in a Course with an enrolledAt date and progress percentage), model the join as an explicit Enrollment type with its own connection
- Avoid circular reference problems by designing which side "owns" the traversal --
User.posts is fine; adding Post.author.posts in a single query should be handled by query depth limiting in the server, not prevented in schema design
- Use
@deprecated(reason: "Use newFieldName instead") directive on old fields rather than removing them -- GraphQL's deprecation mechanism is the proper version evolution tool
- For highly connected data (social graphs, org hierarchies), add
depth and limit arguments to recursive fields: user { followers(depth: 2, limit: 100) { ... } } to prevent unbounded traversal
6. Apply Naming Conventions and Documentation
Consistent naming is not cosmetic -- it determines how discoverable and maintainable the schema is.
- Types: PascalCase (
UserProfile, OrderLineItem) -- never abbreviate unless the abbreviation is universal in the domain (SKU, URL, ID are fine; Usr and Ord are not)
- Fields: camelCase (
firstName, createdAt, isActive) -- Boolean fields should use is, has, or can prefix: isVerified, hasChildren, canEdit
- Input types:
[Verb][Noun]Input for mutations (CreateOrderInput, UpdateUserProfileInput), [Noun]FilterInput for queries (ProductFilterInput)
- Payload types:
[Verb][Noun]Payload (CreateOrderPayload, UpdateUserPayload)
- Enum values: SCREAMING_SNAKE_CASE (
PENDING_REVIEW, OUT_OF_STOCK)
- Connection types:
[Noun]Connection and [Noun]Edge (ProductConnection, ProductEdge)
- Write SDL descriptions for every type, every field with non-obvious semantics, and every argument. Use the triple-quote format:
"""Returns the 10 most recent orders. Use the connection args for pagination beyond 10."""
- Document nullability intent: if a field is nullable because it is genuinely optional vs. because it might fail to load vs. because it is computed async, note this in the description
7. Validate Schema Design Decisions
Before finalizing, run the schema through a validation checklist.
- Check for N+1 risk: any field that requires a database query per parent object is an N+1 risk. Identify these fields and ensure they are addressed in the resolver layer with DataLoader batching -- the schema design should not be changed to hide this, but the field should be documented
- Verify all mutations have error payloads and all queries have non-nullable connection returns
- Run the schema through a linter --
graphql-schema-linter enforces naming conventions, deprecation requirements, and description coverage automatically
- Check query depth -- define a maximum allowed query depth (typically 7-10 levels) and verify no legitimate client query requires more than this
- Simulate the top 5 client queries against the schema using a tool like
graphql-inspector -- verify the field paths are intuitive and require no unnecessary traversal
- Validate that no input type has required fields that should be optional, and no output type has non-null fields that could realistically be absent
- Check for breaking changes before any schema deployment using
graphql-inspector diff -- adding fields and types is non-breaking; removing fields, changing argument types, and changing nullability from nullable to non-null are all breaking
8. Document Schema Evolution Strategy
Schema design is not a one-time event -- establish the governance model upfront.
- Commit to semantic versioning via schema changelog, not URL versioning (
/graphql/v2) -- GraphQL evolves gracefully through deprecation and field addition
- Establish a deprecation policy: deprecated fields must remain in the schema for a minimum of 30-90 days (set based on your release cadence and client deployment speed)
- Use the
@deprecated directive immediately when introducing a replacement: oldField: String @deprecated(reason: "Use newField which returns a typed object") -- never remove first, deprecate first
- Set up
graphql-inspector in CI to automatically block pull requests that introduce breaking changes to the public schema
- Maintain a schema changelog (SCHEMA_CHANGELOG.md) alongside the code with entries for every added type, added field, deprecated field, and reason for deprecation
- Consider schema contracts for large teams: use a schema registry (Apollo Schema Registry or open-source Hive) to track schema versions, validate composition in federated setups, and enforce review gates
Output Format
## GraphQL Schema Design Review
### Domain Model Summary
- Core entities: [list with brief description]
- Key relationships: [entity → entity with cardinality]
- Primary access patterns: [top 5 queries driving design]
- Write operations: [top 5 mutations driving design]
### Schema Design Decisions
| Decision | Choice | Rationale | Trade-offs |
|----------|--------|-----------|------------|
| Pagination style | Cursor (Relay spec) / Offset | [reason] | [trade-off] |
| Error handling | Payload union / errors array | [reason] | [trade-off] |
| Nullability policy | Strict non-null / permissive | [reason] | [trade-off] |
| ID strategy | Global ID (Node) / local ID | [reason] | [trade-off] |
| Naming convention | Standard camelCase/PascalCase | [reason] | [trade-off] |
### Schema Structure
#### Custom Scalars
[List with justification for each non-standard scalar]
#### Interfaces
[List with the contract each interface enforces]
#### Type Definitions (SDL)
[Full SDL for all types, organized as:]
-- Scalars and Enums
-- Interfaces
-- Core object types (most important entities first)
-- Connection types
-- Input types (query filters, mutation inputs)
-- Payload types (mutation returns)
-- Query root
-- Mutation root
#### Relationship Map
[Diagram or table showing type → type traversal paths]
### Validation Results
| Check | Status | Notes |
|-------|--------|-------|
| Naming conventions | ✅ / ⚠️ / ❌ | [details] |
| All mutations have payload types | ✅ / ⚠️ / ❌ | [details] |
| All collections paginated | ✅ / ⚠️ / ❌ | [details] |
| N+1 risks documented | ✅ / ⚠️ / ❌ | [details] |
| Breaking change check | ✅ / ⚠️ / ❌ | [details] |
| Description coverage | ✅ / ⚠️ / ❌ | [details] |
### Evolution Notes
- Fields marked for deprecation: [list]
- Planned additions: [list]
- Breaking changes required (with migration plan): [list]
Rules
NEVER remove a field without first deprecating it for at least 30 days. Field removal is a breaking change for all clients even if no client currently uses the field -- unknown clients, generated SDKs, and cached queries all depend on introspection stability. Use @deprecated(reason: "...") and remove only after confirming zero usage via query analytics.
NEVER change a field from nullable to non-null without treating it as a breaking change. Making a field non-null (String → String!) breaks any client that handles null values for that field. The reverse (non-null to nullable) is safe. When in doubt, start nullable and tighten later only after all clients are verified.
ALWAYS put the errors field on every mutation payload. A mutation that returns createUser: User! has no mechanism to communicate domain errors (email already taken, validation failure) without throwing GraphQL errors, which forces clients to parse error extensions. The errors: [UserError!]! field on a payload type is the correct pattern.
NEVER use generic types as mutation inputs. Reusing output types as input types (createUser(user: User)) breaks immediately when the output type adds computed fields, relations, or server-generated fields like id and createdAt. Always create dedicated CreateUserInput, UpdateUserInput types with exactly the fields the client should provide.
ALWAYS paginate collections that could exceed 20-30 items in production. A field returning [Post!]! with no pagination argument will eventually return thousands of objects, causing memory and latency problems on both server and client. The only exception is truly bounded collections (a user's 2FA recovery codes, a product's fixed set of images with a hard cap of 10).
NEVER design around a specific client's current data shape. Schema design based on "this is what the React component tree looks like today" creates a brittle, client-coupled schema that cannot serve mobile apps, partner integrations, or internal tooling. Design around the domain model; let clients compose what they need.
ALWAYS implement the Node interface on top-level entities. The node(id: ID!): Node root query with global IDs (base64-encoded TypeName:localId) is not just a Relay pattern -- it enables any client to efficiently refetch stale data, powers cache normalization in Apollo Client, and provides a universal entity lookup mechanism.
NEVER put business logic in the schema design itself. Schema defines structure and contracts, not computation rules. Avoid temptations like adding authorization flags directly as scalar fields (canUserEdit: Boolean! computed per-viewer) unless this is a deliberate, documented design choice -- these fields are expensive (one resolver call per object in a list) and couple the schema to viewer-specific state.
ALWAYS distinguish between null-because-absent and null-because-failed. A nullable field thumbnailUrl: String could mean "this product has no thumbnail" (intentional) or "the CDN URL resolver failed" (error). Use the @semanticNonNull pattern in documentation, or model the error case explicitly with a union type. Clients cannot distinguish these cases without schema-level clarity.
NEVER add a field to the schema until you have a resolver implementation plan. Underfetching is a client problem; an unresolvable field is a server contract violation. If a field is added to the schema but the resolver returns null due to unimplemented logic, clients build against a broken contract. Coordinate schema additions with resolver work, or gate new fields behind a feature flag in schema stitching.
Edge Cases
Migrating a REST API to GraphQL Incrementally
When wrapping existing REST endpoints in a GraphQL schema, resist the urge to mirror the REST resource structure 1:1. REST resources are organized around CRUD operations on individual entities; GraphQL schemas should be organized around how clients actually traverse the data graph. A REST endpoint GET /users/{id}/orders?status=COMPLETED becomes user(id: ID!) { orders(filter: { status: COMPLETED }) { ... } } -- the nesting reflects ownership, not endpoint hierarchy. Use schema stitching or a thin gateway layer so the GraphQL schema can evolve independently of the underlying REST endpoints. Start with read-only query types wrapping existing GET endpoints, then introduce mutations only once the query layer is stable.
Polymorphic Types: Interface vs. Union
The choice between interface and union is often confused. Use an interface when the polymorphic types share a meaningful common contract that clients will query against -- SearchResult types that are all Node implementors with id and title fields should be an interface, because clients can write a fragment that applies to all of them. Use a union when the types are genuinely heterogeneous with no shared fields -- PaymentMethod = CreditCard | BankTransfer | CryptoCurrency should be a union because there is no meaningful shared contract. The practical test: if you find yourself adding shared fields to a union type, convert it to an interface. Inline fragments (... on CreditCard { cardNumber lastFour }) work identically on both.
Multi-Tenancy and Viewer-Scoped Data
In multi-tenant SaaS applications, some data is global (product catalog, public user profiles) and some is viewer-scoped (the current user's orders, their organization's settings). Avoid adding currentUser: User fields directly on other types (order { currentUserCanApprove: Boolean! }) because this couples the schema to the authenticated context and makes the schema unusable for batch processing, admin tools, or unauthenticated access. Instead, put viewer-specific capabilities on a dedicated viewer: Viewer root query type that contains the authenticated user's context. The Viewer type holds orders, permissions, organization -- all scoped to the authenticated session -- while global types remain free of viewer context.
Schema Federation Across Microservices
In Apollo Federation or schema stitching architectures, each subgraph owns a slice of the overall schema. The key design rules are: every entity that is referenced across subgraph boundaries must implement @key with a stable, queryable field (almost always the primary id); subgraphs should extend types only to add fields they own the data for; and no subgraph should extend a type just to add computed fields that belong in the consuming application layer. The main failure mode in federated schemas is ownership ambiguity -- two teams both adding fields to the User type without coordination causes type conflicts at composition time. Establish a schema ownership registry (a simple table in a wiki is sufficient for teams under 20) that maps each type and field to an owning team.
Recursive and Hierarchical Data Structures
Organizational hierarchies, category trees, comment threads, and file systems are common recursive structures. Do not model these with unbounded recursive types (Category { children: [Category!]! }). Instead, add depth control arguments (children(depth: Int = 1): [Category!]!) and document the maximum supported depth. For very deep or very large trees (org charts with 10,000 nodes), consider a flattened list representation at the schema level (organization { allDepartments: [Department!]! } with parentId on each Department) and let the client reconstruct the tree -- this approach is more efficient for large datasets and avoids the query complexity explosion of deeply nested traversals.
Schema Design for Real-Time Subscriptions
Subscriptions share the type system with queries and mutations, but their design requirements differ. A subscription payload should contain the minimum data needed to update the client's local state -- it should not be a full entity dump. Design subscription types to include the event type (UserUpdatedEvent { eventType: UserEventType!, user: User!, changedFields: [String!]! }), not just the current state. This allows clients to apply optimistic updates correctly and distinguish create/update/delete events. Never model a subscription to return a connection type -- subscriptions push individual events, not paginated collections. For filtered subscriptions (onOrderUpdated(orderId: ID!)), document the filter semantics precisely and limit the number of filterable dimensions to prevent server-side subscription fan-out.
Large Schema Governance (50+ Types)
When a schema grows beyond 50 types, tooling and process become as important as design patterns. Split the SDL across multiple files organized by domain (user, product, order, payment) and merge them at build time. Use graphql-schema-linter with a configuration file that enforces description coverage (at least 80% of public fields documented), naming conventions, and deprecation format. Establish a schema review process: every PR that adds or modifies schema definitions requires review by the schema owner (typically a platform or API team member) in addition to code review. Track field usage with GraphQL observability tools to identify candidates for deprecation -- fields with zero usage over 90 days should be deprecated proactively.
Example
Input: "We're building an e-commerce platform with a team of 8 engineers. We have Products with variants (size/color), a Cart and Checkout flow, Orders with line items, and User accounts with addresses. We need to support both a web storefront and a mobile app as clients. What should our GraphQL schema look like?"
Output:
GraphQL Schema Design Review -- E-Commerce Platform
Domain Model Summary
- Core entities: User, Product, ProductVariant, Cart, CartItem, Order, OrderLineItem, Address, Category
- Key relationships:
- User → [Address] (one-to-many, user owns addresses)
- User → [Order] (one-to-many, user owns orders)
- User → Cart (one-to-one, user has one active cart)
- Product → [ProductVariant] (one-to-many, bounded -- max ~50 variants per product)
- Product → [Category] (many-to-many via ProductCategoryEdge)
- Cart → [CartItem] (one-to-many, CartItem references ProductVariant)
- Order → [OrderLineItem] (one-to-many, immutable snapshot of purchase)
- Primary access patterns:
- Product listing with filtering by category, price range, availability (paginated)
- Product detail page with all variants, images, and descriptions
- Cart retrieval with current prices and availability status
- Order history for authenticated user (paginated, filterable by status)
- Checkout flow: validate cart → create order → process payment
- Write operations:
- Add/update/remove cart items
- Create order from cart (checkout)
- Update user profile and addresses
- Apply discount codes to cart
- Cancel order (status transition)
Schema Design Decisions
| Decision |
Choice |
Rationale |
Trade-offs |
| Pagination style |
Cursor (Relay spec) |
Product and order lists can be large; real-time inventory changes make offset pagination unreliable |
Slightly more complex client code vs. offset |
| Error handling |
Payload type with errors: [UserError!]! |
Checkout and cart operations have rich domain errors (out of stock, invalid coupon) needing typed responses |
Clients must check both errors and the entity field |
| Nullability policy |
Strict non-null on reliable data; nullable on computed/external-dependent fields |
Reduces client null-checking burden on core fields |
Server must guarantee non-null fields -- requires careful resolver implementation |
| ID strategy |
Global ID implementing Node interface |
Both web and mobile clients use Apollo Client; global IDs enable cache normalization without configuration |
Base64 encoding adds minor overhead; local IDs exposed via separate legacyId field for REST migration |
| Naming convention |
Standard GraphQL conventions: camelCase fields, PascalCase types, SCREAMING_SNAKE_CASE enums |
Consistent with client SDK generation tools and GraphQL community norms |
No trade-off for new projects |
Schema Structure
Custom Scalars
DateTime -- ISO-8601 timestamp strings (2024-03-15T10:30:00Z); used on all createdAt, updatedAt, expiresAt fields
Decimal -- Arbitrary-precision decimal as string ("19.99"); used for all monetary amounts to avoid floating-point errors
URL -- Validated URL string; used for imageUrl, thumbnailUrl fields
JSON -- Arbitrary JSON object; used for ProductVariant.attributes to handle the open-ended nature of variant attributes (size charts, material specs)
Interfaces
Node -- id: ID!; implemented by User, Product, ProductVariant, Cart, Order, Address, Category -- enables node(id: ID!) root lookup and Apollo Client cache normalization
UserError -- message: String!, code: String!; base for all domain errors returned in mutation payloads
Type Definitions (SDL)
# ─── Scalars ────────────────────────────────────────────────────────────
scalar DateTime
scalar Decimal
scalar URL
scalar JSON
# ─── Enums ──────────────────────────────────────────────────────────────
enum OrderStatus {
PENDING
PAYMENT_PROCESSING
CONFIRMED
SHIPPED
DELIVERED
CANCELLED
REFUNDED
}
enum CartItemStatus {
AVAILABLE
LOW_STOCK # fewer than 5 units remaining
OUT_OF_STOCK
PRICE_CHANGED # price changed since item was added
}
enum AddressType {
SHIPPING
BILLING
}
enum SortDirection {
ASC
DESC
}
enum ProductSortField {
PRICE
NAME
CREATED_AT
POPULARITY
}
# ─── Interfaces ──────────────────────────────────────────────────────────
interface Node {
id: ID!
}
interface UserError {
message: String!
code: String!
}
# ─── Error Types ─────────────────────────────────────────────────────────
type ValidationError implements UserError {
message: String!
code: String!
field: String! # which input field caused the error
}
type NotFoundError implements UserError {
message: String!
code: String!
entityType: String!
}
type OutOfStockError implements UserError {
message: String!
code: String!
productVariant: ProductVariant!
requestedQuantity: Int!
availableQuantity: Int!
}
type InvalidCouponError implements UserError {
message: String!
code: String!
couponCode: String!
}
# ─── Core Types ──────────────────────────────────────────────────────────
"""
A registered user account. Guests interact with Cart without a User.
"""
type User implements Node {
id: ID!
email: String!
firstName: String!
lastName: String!
"""Computed display name combining firstName and lastName."""
displayName: String!
isEmailVerified: Boolean!
createdAt: DateTime!
updatedAt: DateTime!
addresses(type: AddressType): [Address!]!
defaultShippingAddress: Address
orders(
filter: OrderFilterInput
first: Int
after: String
last: Int
before: String
): OrderConnection!
}
type Address implements Node {
id: ID!
type: AddressType!
firstName: String!
lastName: String!
line1: String!
line2: String
city: String!
stateOrProvince: String!
postalCode: String!
country: String! # ISO 3166-1 alpha-2
isDefault: Boolean!
}
"""
A product in the catalog. All purchasable items are ProductVariants;
Product holds shared content and metadata.
"""
type Product implements Node {
id: ID!
name: String!
slug: String!
description: String!
"""Short description for listing cards, max 160 characters."""
shortDescription: String!
thumbnailUrl: URL
imageUrls: [URL!]!
categories: [Category!]!
variants: [ProductVariant!]!
"""
Returns the lowest price across all available variants.
Null if all variants are out of stock.
"""
minPrice: Decimal
maxPrice: Decimal
isAvailable: Boolean!
createdAt: DateTime!
updatedAt: DateTime!
}
"""
A specific purchasable configuration of a Product (e.g., size=L, color=Red).
"""
type ProductVariant implements Node {
id: ID!
product: Product!
sku: String!
"""
Open-ended attribute map. Keys depend on product type:
e.g., {"size": "L", "color": "Red"} or {"material": "Cotton", "fit": "Slim"}.
"""
attributes: JSON!
"""Human-readable variant label, e.g., 'Large / Red'."""
label: String!
price: Decimal!
compareAtPrice: Decimal # original price for sale display
inventoryQuantity: Int!
isAvailable: Boolean!
imageUrl: URL
}
type Category implements Node {
id: ID!
name: String!
slug: String!
description: String
parentCategory: Category
"""Direct children only. Use depth argument for subtree traversal."""
childCategories(depth: Int = 1): [Category!]!
products(
filter: ProductFilterInput
orderBy: ProductOrderByInput
first: Int
after: String
): ProductConnection!
}
"""
The current user's shopping cart.
Anonymous carts are identified by a cartToken stored client-side.
"""
type Cart implements Node {
id: ID!
items: [CartItem!]!
subtotal: Decimal!
discountAmount: Decimal!
estimatedTax: Decimal!
estimatedTotal: Decimal!
appliedCouponCode: String
"""
Any items with status OUT_OF_STOCK or PRICE_CHANGED require
user acknowledgment before checkout can proceed.
"""
hasIssues: Boolean!
updatedAt: DateTime!
}
type CartItem implements Node {
id: ID!
productVariant: ProductVariant!
quantity: Int!
unitPrice: Decimal! # price at time of adding to cart
currentPrice: Decimal! # current live price
lineTotal: Decimal! # quantity × currentPrice
status: CartItemStatus!
}
"""
A completed or in-progress purchase. Line items are immutable snapshots.
"""
type Order implements Node {
id: ID!
orderNumber: String! # human-readable, e.g., "ORD-20240315-4821"
status: OrderStatus!
lineItems: [OrderLineItem!]!
shippingAddress: Address!
billingAddress: Address!
subtotal: Decimal!
discountAmount: Decimal!
taxAmount: Decimal!
shippingAmount: Decimal!
total: Decimal!
appliedCouponCode: String
"""Null until order ships."""
trackingNumber: String
"""Null until order ships."""
estimatedDeliveryAt: DateTime
placedAt: DateTime!
updatedAt: DateTime!
}
"""
Immutable snapshot of a ProductVariant at purchase time.
Preserves price and product name even if the variant is later modified.
"""
type OrderLineItem implements Node {
id: ID!
"""
References the live ProductVariant. May be null if variant was deleted.
Always use the snapshot fields (snapshotName, snapshotPrice) for display.
"""
productVariant: ProductVariant
snapshotName: String! # "Blue Widget - Large / Red"
snapshotSku: String!
snapshotPrice: Decimal!
quantity: Int!
lineTotal: Decimal!
}
# ─── Connection Types (Relay Cursor spec) ───────────────────────────────
type ProductConnection {
edges: [ProductEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type ProductEdge {
node: Product!
cursor: String!
}
type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type OrderEdge {
node: Order!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# ─── Input Types ─────────────────────────────────────────────────────────
input ProductFilterInput {
categoryId: ID
minPrice: Decimal
maxPrice: Decimal
isAvailable: Boolean
searchQuery: String
}
input ProductOrderByInput {
field: ProductSortField!
direction: SortDirection!
}
input OrderFilterInput {
status: OrderStatus
placedAfter: DateTime
placedBefore: DateTime
}
input AddCartItemInput {
productVariantId: ID!
quantity: Int!
}
input UpdateCartItemInput {
cartItemId: ID!
quantity: Int! # set to 0 to remove
}
input ApplyCouponInput {
cartId: ID!
couponCode: String!
}
input CheckoutInput {
cartId: ID!
shippingAddressId: ID!
billingAddressId: ID!
"""
Client must confirm awareness of cart issues (out of stock, price changes)
by setting this to true if Cart.hasIssues is true.
"""
acknowledgeIssues: Boolean
}
input CreateAddressInput {
type: AddressType!
firstName: String!
lastName: String!
line1: String!
line2: String
city: String!
stateOrProvince: String!
postalCode: String!
country: String!
isDefault: Boolean
}
input UpdateUserProfileInput {
firstName: String
lastName: String
email: String
}
# ─── Payload Types ───────────────────────────────────────────────────────
type AddCartItemPayload {
cart: Cart
cartItem: CartItem
errors: [UserError!]!
}
type UpdateCartItemPayload {
cart: Cart
errors: [UserError!]!
}
type ApplyCouponPayload {
cart: Cart
errors: [UserError!]!
}
type CheckoutPayload {
order: Order
errors: [UserError!]!
}
type CreateAddressPayload {
address: Address
errors: [UserError!]!
}
type UpdateUserProfilePayload {
user: User
errors: [UserError!]!
}
type CancelOrderPayload {
order: Order
errors: [UserError!]!
}
# ─── Viewer Type (authenticated context) ─────────────────────────────────
"""
The authenticated user's session context.
Use viewer-scoped queries instead of passing userId arguments.
"""
type Viewer {
user: User!
cart: Cart!
}
# ─── Query Root ──────────────────────────────────────────────────────────
type Query {
"""Fetch any entity by its global Node ID."""
node(id: ID!): Node
"""The authenticated user's session. Returns null for unauthenticated requests."""
viewer: Viewer
product(id: ID!): Product
productBySlug(slug: String!): Product
products(
filter: ProductFilterInput
orderBy: ProductOrderByInput
first: Int = 20
after: String
): ProductConnection!
category(id: ID!): Category
categoryBySlug(slug: String!): Category
"""Top-level categories only. Use Category.childCategories for subtrees."""
rootCategories: [Category!]!
"""
Look up a cart by its opaque token. Used for anonymous/guest carts.
Authenticated users should use viewer { cart } instead.
"""
cart(token: String!): Cart
}
# ─── Mutation Root ────────────────────────────────────────────────────────
type Mutation {
addCartItem(input: AddCartItemInput!): AddCartItemPayload!
updateCartItem(input: UpdateCartItemInput!): UpdateCartItemPayload!
applyCoupon(input: ApplyCouponInput!): ApplyCouponPayload!
checkout(input: CheckoutInput!): CheckoutPayload!
cancelOrder(orderId: ID!): CancelOrderPayload!
createAddress(input: CreateAddressInput!): CreateAddressPayload!
updateUserProfile(input: UpdateUserProfileInput!): UpdateUserProfilePayload!
}
Relationship Map
| From |
Field |
To |
Cardinality |
Paginated |
| User |
addresses |
Address |
1→many |
No (bounded by address book size ~10) |
| User |
orders |
OrderConnection |
1→many |
Yes (cursor) |
| Viewer |
cart |
Cart |
1→1 |
No |
| Product |
variants |
[ProductVariant] |
1→many |
No (bounded ~50) |
| Product |
categories |
[Category] |
many→many |
No (bounded ~5 per product) |
| Category |
products |
ProductConnection |
many→many |
Yes (cursor) |
| Category |
childCategories |
[Category] |
1→many |
No (depth argument limits scope) |
| Cart |
items |
[CartItem] |
1→many |
No (bounded by cart max ~100 items) |
| CartItem |
productVariant |
ProductVariant |
many→1 |
No |
| Order |
lineItems |
[OrderLineItem] |
1→many |
No (bounded at checkout time) |
| OrderLineItem |
productVariant |
ProductVariant |
many→1 |
No (nullable -- variant may be deleted) |
Validation Results
| Check |
Status |
Notes |
| Naming conventions |
✅ |
All types PascalCase, fields camelCase, enums SCREAMING_SNAKE_CASE |
| All mutations have payload types |
✅ |
All 7 mutations return XxxPayload with errors: [UserError!]! |
| All large collections paginated |
✅ |
products, category.products, user.orders use cursor connections |
| Bounded collections not paginated |
✅ |
cart.items, order.lineItems, product.variants are bounded by domain |
| N+1 risks documented |
⚠️ |
CartItem.productVariant, OrderLineItem.productVariant, Product.categories all require DataLoader batching in resolvers |
| Node interface coverage |
✅ |
User, Product, ProductVariant |
…(truncated)
1---2name: graphql-schema-design3description: Guides expert-level graphql schema design implementation: api-design and design-patterns decision frameworks, production-ready patterns, and concrete templates for graphql schema design workflows. Use when the user asks about graphql schema design, graphql schema design configuration, or api-design best practices for graphql projects. Do NOT use when the user needs a different backend infrastructure capability -- check sibling skills in the backend infrastructure subcategory.4license: Apache-2.05---6# GraphQL Schema Design78## When to Use910**Use this skill when:**11- User is designing a new GraphQL API from scratch and needs schema structure guidance -- type definitions, query/mutation/subscription organization, and naming conventions12- User is migrating a REST API to GraphQL and needs to understand how to translate resource-based endpoints into a graph-based type system13- User is experiencing N+1 query problems, over-fetching, or under-fetching in an existing GraphQL schema and needs structural redesign14- User needs to model complex domain relationships -- bidirectional associations, polymorphic types, recursive structures, or union/interface hierarchies15- User is building a federated GraphQL architecture across multiple microservices and needs schema composition guidance16- User asks how to version or evolve a GraphQL schema without breaking existing clients17- User needs to design mutations with proper input types, error handling patterns, and optimistic UI support18- User is designing a schema for pagination, filtering, and sorting at scale and needs a consistent, extensible pattern1920**Do NOT use this skill when:**21- User needs GraphQL resolver implementation guidance -- this skill covers schema structure, not resolver logic or DataLoader setup22- User needs GraphQL authentication/authorization middleware configuration -- check the backend-security skills23- User needs GraphQL client-side query writing or fragment composition -- this skill is server-schema design only24- User is asking about REST API design without GraphQL involvement -- check API design skills in the sibling subcategory25- User needs GraphQL subscription infrastructure setup (WebSocket servers, pub/sub brokers) -- that is an infrastructure concern beyond schema design26- User needs GraphQL performance monitoring, APM tooling, or query complexity analysis at runtime -- check observability skills27- User is asking about gRPC or OpenAPI/Swagger schema design -- separate skill domains2829## Process3031### 1. Identify the Domain Model and Access Patterns3233Before writing a single type definition, map the domain thoroughly.3435- List all core business entities and draw their relationships -- identify which are nodes in a graph (independent entities with identity) vs. value objects (embedded data without identity)36- Distinguish ownership relationships (a `Post` belongs to exactly one `User`) from association relationships (a `Post` has many `Tags` that exist independently)37- Catalog the top 10-15 read access patterns the UI actually needs -- screens, components, and dashboards drive schema shape more than the database schema does38- Catalog write operations grouped by actor, frequency, and atomicity requirements -- a single checkout mutation may span inventory, payment, and order creation39- Identify which entities are queried by ID most often vs. by collection with filtering -- entities queried by ID are candidates for the Node interface; filterable collections need pagination and filter input types40- Note cardinality: one-to-one, one-to-many, and many-to-many relationships have distinct modeling strategies in GraphQL41- Flag any data that is computed or derived (aggregates, calculated fields) -- these need special attention to avoid expensive resolver chains4243### 2. Define the Type System Foundation4445Establish scalar, enum, interface, and union types before building object types.4647- Start with custom scalars for domain primitives: `DateTime` (ISO-8601 strings), `URL`, `EmailAddress`, `UUID`, `JSON`, `Decimal` (for currency/precise numbers) -- never use raw `String` for typed values the client will parse48- Define enums for all closed sets of values: `OrderStatus`, `UserRole`, `PaymentMethod` -- use SCREAMING_SNAKE_CASE for enum values per GraphQL spec convention49- Design interfaces for shared contracts across multiple types. The `Node` interface (`id: ID!`) is the single most important interface -- implement it on every top-level entity to enable generic caching and refetching in clients like Apollo and Relay50- Use the `Error` interface pattern to create a structured error type hierarchy: define a base `UserError` interface with `message: String!` and `code: String!`, then create domain-specific error types implementing it51- Define union types for truly polymorphic responses where types share no fields -- use `SearchResult = User | Post | Product` rather than a single `SearchResult` object with nullable fields52- Create `Connection` and `Edge` types following the Relay Cursor Connections specification -- even if not using Relay, this pattern provides a battle-tested pagination contract: `UserConnection`, `UserEdge`, `PageInfo`53- Establish `XxxInput` naming convention for all input types used in mutations -- never reuse output object types as mutation inputs because their field requirements differ5455### 3. Design the Query Root5657Structure the Query type to be discoverable, consistent, and client-friendly.5859- Use singular/plural naming rigorously: `user(id: ID!): User` for single-entity lookup, `users(filter: UserFilterInput, pagination: PaginationInput): UserConnection!` for collection queries60- Never return nullable connections -- a collection query that finds zero results should return an empty connection, not null. Use `UserConnection!` not `UserConnection`61- Group related fields semantically and document them with triple-quoted SDL descriptions -- these appear in GraphQL Playground, GraphiQL, and schema documentation tools62- Implement the Node interface lookup at the root: `node(id: ID!): Node` enables clients to refetch any entity by global ID without knowing its type in advance63- Avoid deeply nested required arguments -- a query like `organizationUsersByRole(orgId: ID!, role: UserRole!, status: UserStatus)` should use a single filter input type64- Design filter input types to be additive: `UserFilterInput { id: ID, email: String, role: UserRole, status: UserStatus, createdAfter: DateTime }` -- all fields optional, combined with AND semantics by default65- Add `orderBy: [UserOrderByInput!]` as a separate argument from filtering -- this keeps filtering and sorting concerns independent and allows multiple sort keys66- Consider search vs. filter: full-text search (`query: String`) is a different pattern from structured filtering and should be a separate field or argument6768### 4. Design Mutations6970Mutations require the most careful design because they represent state changes with side effects.7172- Follow the single-responsibility principle strictly: one mutation per logical operation. `createUser`, `updateUser`, `deleteUser` are separate mutations -- never a generic `mutateUser` operation73- Use namespaced mutation naming for large schemas: `userCreate`, `userUpdate`, `userDelete` groups all user mutations alphabetically in tooling. Alternatively use the `Mutation.user: UserMutations` nested object pattern (note: this breaks introspection-based tooling in some clients -- document the tradeoff)74- Every mutation takes exactly one input object argument: `createUser(input: CreateUserInput!): CreateUserPayload!` -- this is the most future-proof signature because new fields can be added to the input without changing the mutation signature75- Every mutation returns a payload type, never a raw object type: `CreateUserPayload { user: User, errors: [UserError!]! }` -- the payload wrapper allows adding fields like `clientMutationId` (Relay), warnings, and side-effect data without a breaking change76- Always include an `errors` field in the mutation payload -- use the union/interface error pattern so clients can distinguish between `ValidationError`, `AuthorizationError`, `NotFoundError`, and `ConflictError` with typed fields77- Design optimistic UI support: ensure the returned payload contains all fields the UI needs to update local state without a separate refetch -- if creating a post, return the full `Post` type with resolved author, category, and tag data78- Mark destructive mutations with a `DeleteXxxPayload { deletedId: ID!, success: Boolean! }` pattern -- returning the deleted ID enables cache eviction in Apollo Client and similar79- Use `upsert` mutations sparingly and only when the create-vs-update distinction is genuinely irrelevant to the caller -- document the idempotency semantics explicitly8081### 5. Model Relationships and Connections8283Graph relationships are the core value proposition of GraphQL -- design them carefully.8485- Default to returning full nested objects rather than foreign key IDs -- `post { author { id name avatarUrl } }` is the GraphQL idiom; returning `authorId: ID!` defeats the purpose and forces the client to make additional queries86- For large one-to-many relationships (more than ~20 items), always paginate using cursor-based connections. Offset pagination (`skip`/`limit`) has correctness issues with real-time data and does not scale -- prefer opaque cursor strings87- The Relay Cursor Connection spec fields: `edges { node cursor } pageInfo { hasNextPage hasPreviousPage startCursor endCursor }` -- implement all four `pageInfo` fields even if bidirectional pagination is not immediately needed88- For many-to-many relationships with edge data (a `User` enrolled in a `Course` with an `enrolledAt` date and `progress` percentage), model the join as an explicit `Enrollment` type with its own connection89- Avoid circular reference problems by designing which side "owns" the traversal -- `User.posts` is fine; adding `Post.author.posts` in a single query should be handled by query depth limiting in the server, not prevented in schema design90- Use `@deprecated(reason: "Use newFieldName instead")` directive on old fields rather than removing them -- GraphQL's deprecation mechanism is the proper version evolution tool91- For highly connected data (social graphs, org hierarchies), add `depth` and `limit` arguments to recursive fields: `user { followers(depth: 2, limit: 100) { ... } }` to prevent unbounded traversal9293### 6. Apply Naming Conventions and Documentation9495Consistent naming is not cosmetic -- it determines how discoverable and maintainable the schema is.9697- Types: PascalCase (`UserProfile`, `OrderLineItem`) -- never abbreviate unless the abbreviation is universal in the domain (`SKU`, `URL`, `ID` are fine; `Usr` and `Ord` are not)98- Fields: camelCase (`firstName`, `createdAt`, `isActive`) -- Boolean fields should use `is`, `has`, or `can` prefix: `isVerified`, `hasChildren`, `canEdit`99- Input types: `[Verb][Noun]Input` for mutations (`CreateOrderInput`, `UpdateUserProfileInput`), `[Noun]FilterInput` for queries (`ProductFilterInput`)100- Payload types: `[Verb][Noun]Payload` (`CreateOrderPayload`, `UpdateUserPayload`)101- Enum values: SCREAMING_SNAKE_CASE (`PENDING_REVIEW`, `OUT_OF_STOCK`)102- Connection types: `[Noun]Connection` and `[Noun]Edge` (`ProductConnection`, `ProductEdge`)103- Write SDL descriptions for every type, every field with non-obvious semantics, and every argument. Use the triple-quote format: `"""Returns the 10 most recent orders. Use the connection args for pagination beyond 10."""`104- Document nullability intent: if a field is nullable because it is genuinely optional vs. because it might fail to load vs. because it is computed async, note this in the description105106### 7. Validate Schema Design Decisions107108Before finalizing, run the schema through a validation checklist.109110- Check for N+1 risk: any field that requires a database query per parent object is an N+1 risk. Identify these fields and ensure they are addressed in the resolver layer with DataLoader batching -- the schema design should not be changed to hide this, but the field should be documented111- Verify all mutations have error payloads and all queries have non-nullable connection returns112- Run the schema through a linter -- `graphql-schema-linter` enforces naming conventions, deprecation requirements, and description coverage automatically113- Check query depth -- define a maximum allowed query depth (typically 7-10 levels) and verify no legitimate client query requires more than this114- Simulate the top 5 client queries against the schema using a tool like `graphql-inspector` -- verify the field paths are intuitive and require no unnecessary traversal115- Validate that no input type has required fields that should be optional, and no output type has non-null fields that could realistically be absent116- Check for breaking changes before any schema deployment using `graphql-inspector diff` -- adding fields and types is non-breaking; removing fields, changing argument types, and changing nullability from nullable to non-null are all breaking117118### 8. Document Schema Evolution Strategy119120Schema design is not a one-time event -- establish the governance model upfront.121122- Commit to semantic versioning via schema changelog, not URL versioning (`/graphql/v2`) -- GraphQL evolves gracefully through deprecation and field addition123- Establish a deprecation policy: deprecated fields must remain in the schema for a minimum of 30-90 days (set based on your release cadence and client deployment speed)124- Use the `@deprecated` directive immediately when introducing a replacement: `oldField: String @deprecated(reason: "Use newField which returns a typed object")` -- never remove first, deprecate first125- Set up `graphql-inspector` in CI to automatically block pull requests that introduce breaking changes to the public schema126- Maintain a schema changelog (SCHEMA_CHANGELOG.md) alongside the code with entries for every added type, added field, deprecated field, and reason for deprecation127- Consider schema contracts for large teams: use a schema registry (Apollo Schema Registry or open-source Hive) to track schema versions, validate composition in federated setups, and enforce review gates128129## Output Format130131```132## GraphQL Schema Design Review133134### Domain Model Summary135- Core entities: [list with brief description]136- Key relationships: [entity → entity with cardinality]137- Primary access patterns: [top 5 queries driving design]138- Write operations: [top 5 mutations driving design]139140### Schema Design Decisions141142| Decision | Choice | Rationale | Trade-offs |143|----------|--------|-----------|------------|144| Pagination style | Cursor (Relay spec) / Offset | [reason] | [trade-off] |145| Error handling | Payload union / errors array | [reason] | [trade-off] |146| Nullability policy | Strict non-null / permissive | [reason] | [trade-off] |147| ID strategy | Global ID (Node) / local ID | [reason] | [trade-off] |148| Naming convention | Standard camelCase/PascalCase | [reason] | [trade-off] |149150### Schema Structure151152#### Custom Scalars153[List with justification for each non-standard scalar]154155#### Interfaces156[List with the contract each interface enforces]157158#### Type Definitions (SDL)159160[Full SDL for all types, organized as:]161-- Scalars and Enums162-- Interfaces163-- Core object types (most important entities first)164-- Connection types165-- Input types (query filters, mutation inputs)166-- Payload types (mutation returns)167-- Query root168-- Mutation root169170#### Relationship Map171[Diagram or table showing type → type traversal paths]172173### Validation Results174175| Check | Status | Notes |176|-------|--------|-------|177| Naming conventions | ✅ / ⚠️ / ❌ | [details] |178| All mutations have payload types | ✅ / ⚠️ / ❌ | [details] |179| All collections paginated | ✅ / ⚠️ / ❌ | [details] |180| N+1 risks documented | ✅ / ⚠️ / ❌ | [details] |181| Breaking change check | ✅ / ⚠️ / ❌ | [details] |182| Description coverage | ✅ / ⚠️ / ❌ | [details] |183184### Evolution Notes185- Fields marked for deprecation: [list]186- Planned additions: [list]187- Breaking changes required (with migration plan): [list]188```189190## Rules1911921. **NEVER remove a field without first deprecating it for at least 30 days.** Field removal is a breaking change for all clients even if no client currently uses the field -- unknown clients, generated SDKs, and cached queries all depend on introspection stability. Use `@deprecated(reason: "...")` and remove only after confirming zero usage via query analytics.1931942. **NEVER change a field from nullable to non-null without treating it as a breaking change.** Making a field non-null (`String` → `String!`) breaks any client that handles null values for that field. The reverse (non-null to nullable) is safe. When in doubt, start nullable and tighten later only after all clients are verified.1951963. **ALWAYS put the `errors` field on every mutation payload.** A mutation that returns `createUser: User!` has no mechanism to communicate domain errors (email already taken, validation failure) without throwing GraphQL errors, which forces clients to parse error extensions. The `errors: [UserError!]!` field on a payload type is the correct pattern.1971984. **NEVER use generic types as mutation inputs.** Reusing output types as input types (`createUser(user: User)`) breaks immediately when the output type adds computed fields, relations, or server-generated fields like `id` and `createdAt`. Always create dedicated `CreateUserInput`, `UpdateUserInput` types with exactly the fields the client should provide.1992005. **ALWAYS paginate collections that could exceed 20-30 items in production.** A field returning `[Post!]!` with no pagination argument will eventually return thousands of objects, causing memory and latency problems on both server and client. The only exception is truly bounded collections (a user's 2FA recovery codes, a product's fixed set of images with a hard cap of 10).2012026. **NEVER design around a specific client's current data shape.** Schema design based on "this is what the React component tree looks like today" creates a brittle, client-coupled schema that cannot serve mobile apps, partner integrations, or internal tooling. Design around the domain model; let clients compose what they need.2032047. **ALWAYS implement the Node interface on top-level entities.** The `node(id: ID!): Node` root query with global IDs (base64-encoded `TypeName:localId`) is not just a Relay pattern -- it enables any client to efficiently refetch stale data, powers cache normalization in Apollo Client, and provides a universal entity lookup mechanism.2052068. **NEVER put business logic in the schema design itself.** Schema defines structure and contracts, not computation rules. Avoid temptations like adding authorization flags directly as scalar fields (`canUserEdit: Boolean!` computed per-viewer) unless this is a deliberate, documented design choice -- these fields are expensive (one resolver call per object in a list) and couple the schema to viewer-specific state.2072089. **ALWAYS distinguish between null-because-absent and null-because-failed.** A nullable field `thumbnailUrl: String` could mean "this product has no thumbnail" (intentional) or "the CDN URL resolver failed" (error). Use the `@semanticNonNull` pattern in documentation, or model the error case explicitly with a union type. Clients cannot distinguish these cases without schema-level clarity.20921010. **NEVER add a field to the schema until you have a resolver implementation plan.** Underfetching is a client problem; an unresolvable field is a server contract violation. If a field is added to the schema but the resolver returns null due to unimplemented logic, clients build against a broken contract. Coordinate schema additions with resolver work, or gate new fields behind a feature flag in schema stitching.211212## Edge Cases213214### Migrating a REST API to GraphQL Incrementally215216When wrapping existing REST endpoints in a GraphQL schema, resist the urge to mirror the REST resource structure 1:1. REST resources are organized around CRUD operations on individual entities; GraphQL schemas should be organized around how clients actually traverse the data graph. A REST endpoint `GET /users/{id}/orders?status=COMPLETED` becomes `user(id: ID!) { orders(filter: { status: COMPLETED }) { ... } }` -- the nesting reflects ownership, not endpoint hierarchy. Use schema stitching or a thin gateway layer so the GraphQL schema can evolve independently of the underlying REST endpoints. Start with read-only query types wrapping existing GET endpoints, then introduce mutations only once the query layer is stable.217218### Polymorphic Types: Interface vs. Union219220The choice between `interface` and `union` is often confused. Use an `interface` when the polymorphic types share a meaningful common contract that clients will query against -- `SearchResult` types that are all `Node` implementors with `id` and `title` fields should be an interface, because clients can write a fragment that applies to all of them. Use a `union` when the types are genuinely heterogeneous with no shared fields -- `PaymentMethod = CreditCard | BankTransfer | CryptoCurrency` should be a union because there is no meaningful shared contract. The practical test: if you find yourself adding shared fields to a union type, convert it to an interface. Inline fragments (`... on CreditCard { cardNumber lastFour }`) work identically on both.221222### Multi-Tenancy and Viewer-Scoped Data223224In multi-tenant SaaS applications, some data is global (product catalog, public user profiles) and some is viewer-scoped (the current user's orders, their organization's settings). Avoid adding `currentUser: User` fields directly on other types (`order { currentUserCanApprove: Boolean! }`) because this couples the schema to the authenticated context and makes the schema unusable for batch processing, admin tools, or unauthenticated access. Instead, put viewer-specific capabilities on a dedicated `viewer: Viewer` root query type that contains the authenticated user's context. The `Viewer` type holds `orders`, `permissions`, `organization` -- all scoped to the authenticated session -- while global types remain free of viewer context.225226### Schema Federation Across Microservices227228In Apollo Federation or schema stitching architectures, each subgraph owns a slice of the overall schema. The key design rules are: every entity that is referenced across subgraph boundaries must implement `@key` with a stable, queryable field (almost always the primary `id`); subgraphs should extend types only to add fields they own the data for; and no subgraph should extend a type just to add computed fields that belong in the consuming application layer. The main failure mode in federated schemas is ownership ambiguity -- two teams both adding fields to the `User` type without coordination causes type conflicts at composition time. Establish a schema ownership registry (a simple table in a wiki is sufficient for teams under 20) that maps each type and field to an owning team.229230### Recursive and Hierarchical Data Structures231232Organizational hierarchies, category trees, comment threads, and file systems are common recursive structures. Do not model these with unbounded recursive types (`Category { children: [Category!]! }`). Instead, add depth control arguments (`children(depth: Int = 1): [Category!]!`) and document the maximum supported depth. For very deep or very large trees (org charts with 10,000 nodes), consider a flattened list representation at the schema level (`organization { allDepartments: [Department!]! }` with `parentId` on each `Department`) and let the client reconstruct the tree -- this approach is more efficient for large datasets and avoids the query complexity explosion of deeply nested traversals.233234### Schema Design for Real-Time Subscriptions235236Subscriptions share the type system with queries and mutations, but their design requirements differ. A subscription payload should contain the minimum data needed to update the client's local state -- it should not be a full entity dump. Design subscription types to include the event type (`UserUpdatedEvent { eventType: UserEventType!, user: User!, changedFields: [String!]! }`), not just the current state. This allows clients to apply optimistic updates correctly and distinguish create/update/delete events. Never model a subscription to return a connection type -- subscriptions push individual events, not paginated collections. For filtered subscriptions (`onOrderUpdated(orderId: ID!)`), document the filter semantics precisely and limit the number of filterable dimensions to prevent server-side subscription fan-out.237238### Large Schema Governance (50+ Types)239240When a schema grows beyond 50 types, tooling and process become as important as design patterns. Split the SDL across multiple files organized by domain (user, product, order, payment) and merge them at build time. Use `graphql-schema-linter` with a configuration file that enforces description coverage (at least 80% of public fields documented), naming conventions, and deprecation format. Establish a schema review process: every PR that adds or modifies schema definitions requires review by the schema owner (typically a platform or API team member) in addition to code review. Track field usage with GraphQL observability tools to identify candidates for deprecation -- fields with zero usage over 90 days should be deprecated proactively.241242## Example243244**Input:** "We're building an e-commerce platform with a team of 8 engineers. We have Products with variants (size/color), a Cart and Checkout flow, Orders with line items, and User accounts with addresses. We need to support both a web storefront and a mobile app as clients. What should our GraphQL schema look like?"245246**Output:**247248## GraphQL Schema Design Review -- E-Commerce Platform249250### Domain Model Summary251- **Core entities:** User, Product, ProductVariant, Cart, CartItem, Order, OrderLineItem, Address, Category252- **Key relationships:**253 - User → [Address] (one-to-many, user owns addresses)254 - User → [Order] (one-to-many, user owns orders)255 - User → Cart (one-to-one, user has one active cart)256 - Product → [ProductVariant] (one-to-many, bounded -- max ~50 variants per product)257 - Product → [Category] (many-to-many via ProductCategoryEdge)258 - Cart → [CartItem] (one-to-many, CartItem references ProductVariant)259 - Order → [OrderLineItem] (one-to-many, immutable snapshot of purchase)260- **Primary access patterns:**261 1. Product listing with filtering by category, price range, availability (paginated)262 2. Product detail page with all variants, images, and descriptions263 3. Cart retrieval with current prices and availability status264 4. Order history for authenticated user (paginated, filterable by status)265 5. Checkout flow: validate cart → create order → process payment266- **Write operations:**267 1. Add/update/remove cart items268 2. Create order from cart (checkout)269 3. Update user profile and addresses270 4. Apply discount codes to cart271 5. Cancel order (status transition)272273### Schema Design Decisions274275| Decision | Choice | Rationale | Trade-offs |276|----------|--------|-----------|------------|277| Pagination style | Cursor (Relay spec) | Product and order lists can be large; real-time inventory changes make offset pagination unreliable | Slightly more complex client code vs. offset |278| Error handling | Payload type with `errors: [UserError!]!` | Checkout and cart operations have rich domain errors (out of stock, invalid coupon) needing typed responses | Clients must check both `errors` and the entity field |279| Nullability policy | Strict non-null on reliable data; nullable on computed/external-dependent fields | Reduces client null-checking burden on core fields | Server must guarantee non-null fields -- requires careful resolver implementation |280| ID strategy | Global ID implementing Node interface | Both web and mobile clients use Apollo Client; global IDs enable cache normalization without configuration | Base64 encoding adds minor overhead; local IDs exposed via separate `legacyId` field for REST migration |281| Naming convention | Standard GraphQL conventions: camelCase fields, PascalCase types, SCREAMING_SNAKE_CASE enums | Consistent with client SDK generation tools and GraphQL community norms | No trade-off for new projects |282283### Schema Structure284285#### Custom Scalars286287- `DateTime` -- ISO-8601 timestamp strings (`2024-03-15T10:30:00Z`); used on all `createdAt`, `updatedAt`, `expiresAt` fields288- `Decimal` -- Arbitrary-precision decimal as string (`"19.99"`); used for all monetary amounts to avoid floating-point errors289- `URL` -- Validated URL string; used for `imageUrl`, `thumbnailUrl` fields290- `JSON` -- Arbitrary JSON object; used for `ProductVariant.attributes` to handle the open-ended nature of variant attributes (size charts, material specs)291292#### Interfaces293294- `Node` -- `id: ID!`; implemented by User, Product, ProductVariant, Cart, Order, Address, Category -- enables `node(id: ID!)` root lookup and Apollo Client cache normalization295- `UserError` -- `message: String!, code: String!`; base for all domain errors returned in mutation payloads296297#### Type Definitions (SDL)298299```graphql300# ─── Scalars ────────────────────────────────────────────────────────────301scalar DateTime302scalar Decimal303scalar URL304scalar JSON305306# ─── Enums ──────────────────────────────────────────────────────────────307enum OrderStatus {308 PENDING309 PAYMENT_PROCESSING310 CONFIRMED311 SHIPPED312 DELIVERED313 CANCELLED314 REFUNDED315}316317enum CartItemStatus {318 AVAILABLE319 LOW_STOCK # fewer than 5 units remaining320 OUT_OF_STOCK321 PRICE_CHANGED # price changed since item was added322}323324enum AddressType {325 SHIPPING326 BILLING327}328329enum SortDirection {330 ASC331 DESC332}333334enum ProductSortField {335 PRICE336 NAME337 CREATED_AT338 POPULARITY339}340341# ─── Interfaces ──────────────────────────────────────────────────────────342interface Node {343 id: ID!344}345346interface UserError {347 message: String!348 code: String!349}350351# ─── Error Types ─────────────────────────────────────────────────────────352type ValidationError implements UserError {353 message: String!354 code: String!355 field: String! # which input field caused the error356}357358type NotFoundError implements UserError {359 message: String!360 code: String!361 entityType: String!362}363364type OutOfStockError implements UserError {365 message: String!366 code: String!367 productVariant: ProductVariant!368 requestedQuantity: Int!369 availableQuantity: Int!370}371372type InvalidCouponError implements UserError {373 message: String!374 code: String!375 couponCode: String!376}377378# ─── Core Types ──────────────────────────────────────────────────────────379"""380A registered user account. Guests interact with Cart without a User.381"""382type User implements Node {383 id: ID!384 email: String!385 firstName: String!386 lastName: String!387 """Computed display name combining firstName and lastName."""388 displayName: String!389 isEmailVerified: Boolean!390 createdAt: DateTime!391 updatedAt: DateTime!392 addresses(type: AddressType): [Address!]!393 defaultShippingAddress: Address394 orders(395 filter: OrderFilterInput396 first: Int397 after: String398 last: Int399 before: String400 ): OrderConnection!401}402403type Address implements Node {404 id: ID!405 type: AddressType!406 firstName: String!407 lastName: String!408 line1: String!409 line2: String410 city: String!411 stateOrProvince: String!412 postalCode: String!413 country: String! # ISO 3166-1 alpha-2414 isDefault: Boolean!415}416417"""418A product in the catalog. All purchasable items are ProductVariants;419Product holds shared content and metadata.420"""421type Product implements Node {422 id: ID!423 name: String!424 slug: String!425 description: String!426 """Short description for listing cards, max 160 characters."""427 shortDescription: String!428 thumbnailUrl: URL429 imageUrls: [URL!]!430 categories: [Category!]!431 variants: [ProductVariant!]!432 """433 Returns the lowest price across all available variants.434 Null if all variants are out of stock.435 """436 minPrice: Decimal437 maxPrice: Decimal438 isAvailable: Boolean!439 createdAt: DateTime!440 updatedAt: DateTime!441}442443"""444A specific purchasable configuration of a Product (e.g., size=L, color=Red).445"""446type ProductVariant implements Node {447 id: ID!448 product: Product!449 sku: String!450 """451 Open-ended attribute map. Keys depend on product type: 452 e.g., {"size": "L", "color": "Red"} or {"material": "Cotton", "fit": "Slim"}.453 """454 attributes: JSON!455 """Human-readable variant label, e.g., 'Large / Red'."""456 label: String!457 price: Decimal!458 compareAtPrice: Decimal # original price for sale display459 inventoryQuantity: Int!460 isAvailable: Boolean!461 imageUrl: URL462}463464type Category implements Node {465 id: ID!466 name: String!467 slug: String!468 description: String469 parentCategory: Category470 """Direct children only. Use depth argument for subtree traversal."""471 childCategories(depth: Int = 1): [Category!]!472 products(473 filter: ProductFilterInput474 orderBy: ProductOrderByInput475 first: Int476 after: String477 ): ProductConnection!478}479480"""481The current user's shopping cart. 482Anonymous carts are identified by a cartToken stored client-side.483"""484type Cart implements Node {485 id: ID!486 items: [CartItem!]!487 subtotal: Decimal!488 discountAmount: Decimal!489 estimatedTax: Decimal!490 estimatedTotal: Decimal!491 appliedCouponCode: String492 """493 Any items with status OUT_OF_STOCK or PRICE_CHANGED require 494 user acknowledgment before checkout can proceed.495 """496 hasIssues: Boolean!497 updatedAt: DateTime!498}499500type CartItem implements Node {501 id: ID!502 productVariant: ProductVariant!503 quantity: Int!504 unitPrice: Decimal! # price at time of adding to cart505 currentPrice: Decimal! # current live price506 lineTotal: Decimal! # quantity × currentPrice507 status: CartItemStatus!508}509510"""511A completed or in-progress purchase. Line items are immutable snapshots.512"""513type Order implements Node {514 id: ID!515 orderNumber: String! # human-readable, e.g., "ORD-20240315-4821"516 status: OrderStatus!517 lineItems: [OrderLineItem!]!518 shippingAddress: Address!519 billingAddress: Address!520 subtotal: Decimal!521 discountAmount: Decimal!522 taxAmount: Decimal!523 shippingAmount: Decimal!524 total: Decimal!525 appliedCouponCode: String526 """Null until order ships."""527 trackingNumber: String528 """Null until order ships."""529 estimatedDeliveryAt: DateTime530 placedAt: DateTime!531 updatedAt: DateTime!532}533534"""535Immutable snapshot of a ProductVariant at purchase time.536Preserves price and product name even if the variant is later modified.537"""538type OrderLineItem implements Node {539 id: ID!540 """541 References the live ProductVariant. May be null if variant was deleted.542 Always use the snapshot fields (snapshotName, snapshotPrice) for display.543 """544 productVariant: ProductVariant545 snapshotName: String! # "Blue Widget - Large / Red"546 snapshotSku: String!547 snapshotPrice: Decimal!548 quantity: Int!549 lineTotal: Decimal!550}551552# ─── Connection Types (Relay Cursor spec) ───────────────────────────────553type ProductConnection {554 edges: [ProductEdge!]!555 pageInfo: PageInfo!556 totalCount: Int!557}558559type ProductEdge {560 node: Product!561 cursor: String!562}563564type OrderConnection {565 edges: [OrderEdge!]!566 pageInfo: PageInfo!567 totalCount: Int!568}569570type OrderEdge {571 node: Order!572 cursor: String!573}574575type PageInfo {576 hasNextPage: Boolean!577 hasPreviousPage: Boolean!578 startCursor: String579 endCursor: String580}581582# ─── Input Types ─────────────────────────────────────────────────────────583input ProductFilterInput {584 categoryId: ID585 minPrice: Decimal586 maxPrice: Decimal587 isAvailable: Boolean588 searchQuery: String589}590591input ProductOrderByInput {592 field: ProductSortField!593 direction: SortDirection!594}595596input OrderFilterInput {597 status: OrderStatus598 placedAfter: DateTime599 placedBefore: DateTime600}601602input AddCartItemInput {603 productVariantId: ID!604 quantity: Int!605}606607input UpdateCartItemInput {608 cartItemId: ID!609 quantity: Int! # set to 0 to remove610}611612input ApplyCouponInput {613 cartId: ID!614 couponCode: String!615}616617input CheckoutInput {618 cartId: ID!619 shippingAddressId: ID!620 billingAddressId: ID!621 """622 Client must confirm awareness of cart issues (out of stock, price changes)623 by setting this to true if Cart.hasIssues is true.624 """625 acknowledgeIssues: Boolean626}627628input CreateAddressInput {629 type: AddressType!630 firstName: String!631 lastName: String!632 line1: String!633 line2: String634 city: String!635 stateOrProvince: String!636 postalCode: String!637 country: String!638 isDefault: Boolean639}640641input UpdateUserProfileInput {642 firstName: String643 lastName: String644 email: String645}646647# ─── Payload Types ───────────────────────────────────────────────────────648type AddCartItemPayload {649 cart: Cart650 cartItem: CartItem651 errors: [UserError!]!652}653654type UpdateCartItemPayload {655 cart: Cart656 errors: [UserError!]!657}658659type ApplyCouponPayload {660 cart: Cart661 errors: [UserError!]!662}663664type CheckoutPayload {665 order: Order666 errors: [UserError!]!667}668669type CreateAddressPayload {670 address: Address671 errors: [UserError!]!672}673674type UpdateUserProfilePayload {675 user: User676 errors: [UserError!]!677}678679type CancelOrderPayload {680 order: Order681 errors: [UserError!]!682}683684# ─── Viewer Type (authenticated context) ─────────────────────────────────685"""686The authenticated user's session context. 687Use viewer-scoped queries instead of passing userId arguments.688"""689type Viewer {690 user: User!691 cart: Cart!692}693694# ─── Query Root ──────────────────────────────────────────────────────────695type Query {696 """Fetch any entity by its global Node ID."""697 node(id: ID!): Node698699 """The authenticated user's session. Returns null for unauthenticated requests."""700 viewer: Viewer701702 product(id: ID!): Product703 productBySlug(slug: String!): Product704705 products(706 filter: ProductFilterInput707 orderBy: ProductOrderByInput708 first: Int = 20709 after: String710 ): ProductConnection!711712 category(id: ID!): Category713 categoryBySlug(slug: String!): Category714715 """Top-level categories only. Use Category.childCategories for subtrees."""716 rootCategories: [Category!]!717718 """719 Look up a cart by its opaque token. Used for anonymous/guest carts.720 Authenticated users should use viewer { cart } instead.721 """722 cart(token: String!): Cart723}724725# ─── Mutation Root ────────────────────────────────────────────────────────726type Mutation {727 addCartItem(input: AddCartItemInput!): AddCartItemPayload!728 updateCartItem(input: UpdateCartItemInput!): UpdateCartItemPayload!729 applyCoupon(input: ApplyCouponInput!): ApplyCouponPayload!730 checkout(input: CheckoutInput!): CheckoutPayload!731 cancelOrder(orderId: ID!): CancelOrderPayload!732733 createAddress(input: CreateAddressInput!): CreateAddressPayload!734 updateUserProfile(input: UpdateUserProfileInput!): UpdateUserProfilePayload!735}736```737738#### Relationship Map739740| From | Field | To | Cardinality | Paginated |741|------|-------|----|-------------|-----------|742| User | addresses | Address | 1→many | No (bounded by address book size ~10) |743| User | orders | OrderConnection | 1→many | Yes (cursor) |744| Viewer | cart | Cart | 1→1 | No |745| Product | variants | [ProductVariant] | 1→many | No (bounded ~50) |746| Product | categories | [Category] | many→many | No (bounded ~5 per product) |747| Category | products | ProductConnection | many→many | Yes (cursor) |748| Category | childCategories | [Category] | 1→many | No (depth argument limits scope) |749| Cart | items | [CartItem] | 1→many | No (bounded by cart max ~100 items) |750| CartItem | productVariant | ProductVariant | many→1 | No |751| Order | lineItems | [OrderLineItem] | 1→many | No (bounded at checkout time) |752| OrderLineItem | productVariant | ProductVariant | many→1 | No (nullable -- variant may be deleted) |753754### Validation Results755756| Check | Status | Notes |757|-------|--------|-------|758| Naming conventions | ✅ | All types PascalCase, fields camelCase, enums SCREAMING_SNAKE_CASE |759| All mutations have payload types | ✅ | All 7 mutations return `XxxPayload` with `errors: [UserError!]!` |760| All large collections paginated | ✅ | `products`, `category.products`, `user.orders` use cursor connections |761| Bounded collections not paginated | ✅ | `cart.items`, `order.lineItems`, `product.variants` are bounded by domain |762| N+1 risks documented | ⚠️ | `CartItem.productVariant`, `OrderLineItem.productVariant`, `Product.categories` all require DataLoader batching in resolvers |763| Node interface coverage | ✅ | User, Product, ProductVariant764765…(truncated)