Schema Design
Schema design principles for building maintainable, intuitive GraphQL APIs
You are an expert in GraphQL schema design, helping developers build well-structured, evolvable APIs that follow community conventions and the GraphQL specification. ## Key Points - **Object types** represent domain entities with fields. - **Interfaces** define shared fields across types that share behavior (e.g., `Node` for globally unique IDs). - **Unions** represent "one of" relationships where types share no common fields. - **Enums** constrain values to a known set. - **Custom scalars** enforce domain-specific validation (`DateTime`, `URL`, `EmailAddress`). - The data may genuinely be absent (e.g., optional profile fields). - The field resolves from an external service that might fail — a nullable field lets the rest of the query succeed even if that field errors. 1. **Design schema from the client's perspective** — start with the UI needs, not the database schema. GraphQL schemas should model the domain, not the storage layer. 2. **Use consistent naming** — `camelCase` for fields, `PascalCase` for types, `SCREAMING_SNAKE_CASE` for enum values. 3. **Prefer specific types over generic ones** — `DateTime` scalar over `String` for dates, `URL` scalar over `String` for URLs. 4. **Version through evolution, not URL versioning** — add new fields and deprecate old ones with `@deprecated(reason: "Use displayName instead")`. 5. **Provide clear descriptions** — every type and field should have a description in the schema for documentation tooling.
skilldb get graphql-skills/Schema DesignFull skill: 187 linesSchema Design — GraphQL
You are an expert in GraphQL schema design, helping developers build well-structured, evolvable APIs that follow community conventions and the GraphQL specification.
Overview
GraphQL schema design is the foundation of any GraphQL API. A well-designed schema acts as a contract between client and server, enabling frontend teams to query exactly the data they need while giving backend teams flexibility in how that data is resolved. Good schema design emphasizes clarity, consistency, and forward compatibility.
Core Concepts
Schema Definition Language (SDL)
The SDL is the declarative syntax for defining types, queries, mutations, and subscriptions:
type Query {
user(id: ID!): User
users(filter: UserFilter, first: Int, after: String): UserConnection!
}
type User {
id: ID!
email: String!
displayName: String!
avatar: URL
posts(first: Int, after: String): PostConnection!
createdAt: DateTime!
}
Type System Design
GraphQL has five named type kinds: Scalar, Object, Interface, Union, and Enum. Use each deliberately:
- Object types represent domain entities with fields.
- Interfaces define shared fields across types that share behavior (e.g.,
Nodefor globally unique IDs). - Unions represent "one of" relationships where types share no common fields.
- Enums constrain values to a known set.
- Custom scalars enforce domain-specific validation (
DateTime,URL,EmailAddress).
interface Node {
id: ID!
}
union SearchResult = User | Post | Comment
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
scalar DateTime
scalar URL
Input Types
Always use dedicated input types for mutations rather than inlining arguments:
input CreatePostInput {
title: String!
body: String!
status: PostStatus = DRAFT
tagIds: [ID!]
}
type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload!
}
Mutation Payloads
Return dedicated payload types from mutations so you can evolve the response without breaking clients:
type CreatePostPayload {
post: Post
errors: [UserError!]!
}
type UserError {
field: [String!]
message: String!
code: ErrorCode!
}
Implementation Patterns
Nullable vs Non-Null Fields
Default to non-null (!) for fields that will always have a value. Use nullable fields when:
- The data may genuinely be absent (e.g., optional profile fields).
- The field resolves from an external service that might fail — a nullable field lets the rest of the query succeed even if that field errors.
type User {
id: ID! # Always present
email: String! # Required
bio: String # Optional — user may not have set one
avatar: URL # Optional — external CDN could fail
}
Relay-Style Node Interface
Adopt the Node interface for globally unique object identification:
interface Node {
id: ID!
}
type Query {
node(id: ID!): Node
nodes(ids: [ID!]!): [Node]!
}
This enables generic client-side caching and refetching.
Schema Stitching vs Federation
For microservice architectures, prefer Apollo Federation to compose a supergraph from multiple subgraphs:
# Users subgraph
type User @key(fields: "id") {
id: ID!
email: String!
}
# Posts subgraph
extend type User @key(fields: "id") {
id: ID! @external
posts: [Post!]!
}
Core Philosophy
A GraphQL schema is not a database diagram with a query language stapled on — it is a product interface designed for the humans and applications that consume it. The best schemas start from the client's perspective: what does the UI need to render, what actions does the user take, what relationships matter for the experience? The storage layer is an implementation detail that the schema should abstract away. When the schema mirrors the database, every storage refactor becomes a breaking API change.
Schema evolution replaces versioning in GraphQL. Unlike REST APIs that cut new URL versions (/v2/users), GraphQL schemas grow additively: new fields are added, old fields are deprecated with clear messages, and deprecated fields are eventually removed after clients migrate. This model works because GraphQL clients request specific fields — adding a field never breaks an existing query, and deprecation warnings surface in tooling long before removal. The schema becomes a living contract that evolves continuously rather than a snapshot frozen at release boundaries.
Naming and consistency are the highest-leverage investments in schema design. When every mutation follows verbNoun naming (createPost, publishPost, archivePost), every payload includes an errors field, and every paginated list uses the connection pattern, developers can predict the shape of unfamiliar parts of the API without reading documentation. Consistency reduces cognitive load and makes the schema self-teaching.
Anti-Patterns
-
Mirroring the database schema — exposing join table IDs, internal status codes, or storage-specific column names couples every client to the database. When you normalize tables or rename columns, every frontend breaks.
-
Mega-mutations with dozens of optional fields — a single
updateUsermutation that accepts every possible user field makes validation, authorization, and change tracking nearly impossible. Prefer focused, domain-meaningful mutations likeupdateEmail,changePassword, andsetBio. -
Stringly-typed fields where enums belong — using
Stringfor a field with a known set of values (status codes, categories, roles) discards compile-time safety and forces clients to handle unknown strings. Define anenumand let the type system enforce validity. -
Deeply nested types without depth limits — schemas that allow unbounded recursion (
User.posts.author.posts.author...) are resource exhaustion vulnerabilities. Every recursive relationship path must be protected with query depth limiting. -
Skipping field descriptions — a schema without descriptions is documentation debt that compounds with every new field. Every type, field, argument, and enum value should have a description string that appears in tooling and introspection results.
Best Practices
- Design schema from the client's perspective — start with the UI needs, not the database schema. GraphQL schemas should model the domain, not the storage layer.
- Use consistent naming —
camelCasefor fields,PascalCasefor types,SCREAMING_SNAKE_CASEfor enum values. - Prefer specific types over generic ones —
DateTimescalar overStringfor dates,URLscalar overStringfor URLs. - Version through evolution, not URL versioning — add new fields and deprecate old ones with
@deprecated(reason: "Use displayName instead"). - Provide clear descriptions — every type and field should have a description in the schema for documentation tooling.
- Design mutations as domain actions — use
publishPostnotupdatePost(status: PUBLISHED)when the operation has specific semantics. - Always return the mutated object — mutations should return the affected entity so clients can update their caches.
Common Pitfalls
- Exposing database internals — leaking join-table IDs, internal status codes, or storage-specific fields couples clients to your database schema.
- Deeply nested types without limits — allowing unbounded
user.posts.comments.author.posts...recursion can cause performance catastrophes. Use query depth limiting middleware. - Overly generic mutations — a single
updateUsermutation that accepts 20 optional fields is hard to validate and reason about. Prefer focused mutations. - Ignoring nullability semantics — marking everything non-null seems safe but prevents partial error recovery; marking everything nullable forces clients to handle every possible null.
- Skipping input validation — relying solely on the type system for validation misses business rules. Always validate inputs in resolvers or a validation layer.
Install this skill directly: skilldb add graphql-skills
Related Skills
Apollo Client
Apollo Client with React for querying, mutating, and caching GraphQL data
Apollo Server
Apollo Server setup, configuration, plugins, and production deployment patterns
Authentication
Authentication and authorization patterns for securing GraphQL APIs
Code Generation
Type-safe GraphQL development with graphql-codegen for TypeScript
Pagination
Cursor-based pagination following the Relay connection specification
Resolvers
Resolver patterns, data loading strategies, and the N+1 problem in GraphQL