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
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.
How much does it cost?+
Nothing. It ships with Murage, which is open source. Your bots run on the AI plan you already have.
Can I change it?+
Yes. Open it in Settings → Skills to read, edit or duplicate it. Skill Guard scans it again after every edit.
Give your first job to Murage.
Download the app, connect the AI you already pay for, and tell your Chief of Staff what needs doing. Plan on about ten minutes from install to a working team.
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)
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
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?"
Known N+1 risks requiring DataLoader:CartItem.productVariant (batched by variantId), Product.categories (batched by productId), OrderLineItem.productVariant (batched by variantId)
Future consideration: When adding a loyalty/rewards system, add User.rewardPoints: Int! and CheckoutInput.rewardPointsToApply: Int -- the payload pattern means no breaking changes to the checkout mutation signature
Guest checkout: The cart(token: String!) query supports anonymous carts now; when adding guest checkout (order without User), add guestEmail: String to CheckoutInput and make Order.user: User nullable -- this will be a nullable-ization (non-null → nullable), which is a safe non-breaking change