Schema Design
The GraphQL schema is the contract between your API and every client that consumes it. It defines what data exists, how it relates, and what operations are allowed. A well-designed schema is intuitive to query, easy to evolve, and hard to misuse. A poorly designed schema becomes a source of confusion, performance problems, and breaking changes.
Get the schema right before building resolvers. Changing a resolver is a refactor. Changing a schema is a migration.
The Building Blocks
Types
Types are the core of a GraphQL schema. Every piece of data has a type:
type User {
id: ID!
email: String!
name: String!
role: UserRole!
createdAt: DateTime!
orders(first: Int, after: String): OrderConnection!
}
type Order {
id: ID!
status: OrderStatus!
totalCents: Int!
currency: String!
items: [LineItem!]!
customer: User!
createdAt: DateTime!
}
type LineItem {
id: ID!
product: Product!
quantity: Int!
unitPriceCents: Int!
}
Each field has a name and a return type. The ! means non-nullable — the field is guaranteed to return a value (or the entire parent object errors). Without !, the field can return null.
Queries
Queries are read operations. They are the entry points for fetching data:
type Query {
user(id: ID!): User
users(first: Int, after: String, role: UserRole): UserConnection!
order(id: ID!): Order
viewer: User!
}
The viewer pattern (used by GitHub's GraphQL API) returns the currently authenticated user without requiring an ID. It is a useful convention for "me" endpoints.
Mutations
Mutations are write operations. They change state and return the result:
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(input: UpdateUserInput!): UpdateUserPayload!
deleteUser(id: ID!): DeleteUserPayload!
cancelOrder(input: CancelOrderInput!): CancelOrderPayload!
}
Subscriptions
Subscriptions are real-time operations. The server pushes updates to the client:
type Subscription {
orderStatusChanged(orderId: ID!): Order!
newMessage(channelId: ID!): Message!
}
Subscriptions require WebSocket support and add operational complexity. Use them only when polling is insufficient.
Schema-First vs Code-First
Schema-First
Write the .graphql schema file first, then implement resolvers that match it. The schema is the design artifact — reviewable, diffable, and understandable without reading code.
# schema.graphql — written first, reviewed by the team
type Query {
product(id: ID!): Product
products(category: String, first: Int, after: String): ProductConnection!
}
type Product {
id: ID!
name: String!
description: String!
priceCents: Int!
category: String!
inStock: Boolean!
}
Tools like Apollo Server and graphql-tools load the schema from .graphql files and wire resolvers to it.
Code-First
Define the schema in code using a library like Nexus (TypeScript), Strawberry (Python), or gqlgen (Go). The schema is generated from type definitions in your programming language.
Code-first gives you autocompletion and type checking in your editor, but the schema is harder to review in pull requests because it is spread across code files rather than concentrated in one .graphql file.
Recommendation: schema-first for teams where the schema is a design artifact reviewed by multiple stakeholders. Code-first for teams where the backend developers are the primary schema designers and want tight integration with their type system.
Naming Conventions
Fields: camelCase
GraphQL convention is camelCase for field names:
type User {
id: ID!
firstName: String!
lastName: String!
emailAddress: String!
createdAt: DateTime!
}
Types: PascalCase
Type names are PascalCase. Be specific — User, not Data. OrderConnection, not Connection.
Enums: SCREAMING_SNAKE_CASE
enum OrderStatus {
PENDING
CONFIRMED
SHIPPED
DELIVERED
CANCELED
}
enum UserRole {
ADMIN
MEMBER
VIEWER
}
Mutations: verbNoun
Name mutations as actions: createUser, updateOrder, cancelSubscription. The verb describes what happens; the noun describes what it happens to.
Nullable vs Non-Nullable Fields
The ! suffix means a field will never return null. Be deliberate about this choice:
type User {
id: ID! # Always exists — non-nullable
email: String! # Required for all users — non-nullable
name: String! # Required — non-nullable
phone: String # Optional — nullable
avatarUrl: String # May not have an avatar — nullable
}
Default to non-nullable. If a field can always be resolved, make it non-nullable. This gives clients stronger guarantees and simpler code (no null checks).
Make fields nullable when:
- The data genuinely may not exist (phone number, avatar)
- The field depends on an external service that might fail (you want to return partial data rather than erroring the whole object)
- You might remove the field in the future (nullable fields can be deprecated and returned as null)
GitHub's GraphQL API uses nullable fields for data that may not be set:
type Repository {
name: String! # Always has a name
description: String # May not have a description
homepageUrl: URI # May not have a homepage
licenseInfo: License # May not have a license
}
Input Types for Mutations
Use dedicated input types for mutation arguments. Do not reuse output types as inputs:
input CreateUserInput {
email: String!
name: String!
role: UserRole
}
input UpdateUserInput {
id: ID!
email: String
name: String
role: UserRole
}
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(input: UpdateUserInput!): UpdateUserPayload!
}
Notice that CreateUserInput has email and name as required, but UpdateUserInput has them as optional (only update the fields that are provided).
Mutation Payloads
Return a payload type from mutations, not the raw resource. The payload can include the resource, errors, and metadata:
type CreateUserPayload {
user: User
errors: [UserError!]!
}
type UserError {
field: String!
message: String!
code: ErrorCode!
}
enum ErrorCode {
INVALID_EMAIL
DUPLICATE_EMAIL
NAME_TOO_LONG
UNAUTHORIZED
}
This pattern (used by Shopify's GraphQL API) allows mutations to return domain-specific errors alongside the result, without relying on GraphQL-level errors for business logic.
Connections & Pagination
The Relay connection pattern is the standard for paginated lists in GraphQL:
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
Queried like:
query {
users(first: 10, after: "cursor_abc") {
edges {
node {
id
name
email
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
This is more verbose than a simple list, but it supports cursor-based pagination, which is more reliable than offset-based pagination for real-time data.
Common Pitfalls
- Exposing database schema — naming types and fields after database tables and columns instead of domain concepts. The schema should reflect the API consumer's mental model, not the storage layer.
- Everything nullable — making all fields nullable "just in case." This pushes null-handling complexity to every client. Default to non-nullable and make fields nullable only with a specific reason.
- Reusing types for input and output — using the same
Usertype for both query results and mutation inputs. Input types have different requirements (some fields are required for creation, optional for update, and absent from input entirely likeidorcreatedAt). - Giant mutation arguments — passing 15 arguments to a mutation instead of grouping them in an input type. Input types are easier to document, validate, and evolve.
- No pagination on lists — returning unbounded lists. Any field that returns a list of items that could grow should use pagination (connections or simple
first/afterarguments). - Stringly-typed fields — using
Stringfor fields that have a fixed set of values. Use enums:OrderStatusinstead ofString,Currencyinstead ofString. - Breaking changes without deprecation — removing or renaming fields without the
@deprecateddirective. Deprecate first, monitor usage, then remove.
Key Takeaways
- The schema is the contract. Design it before building resolvers. Review schema changes with the same rigor as API endpoint changes.
- Use non-nullable (
!) by default. Nullable fields should have an explicit reason. - Use input types for mutations and payload types for mutation responses. Do not reuse output types as inputs.
- Use enums for fields with fixed values. They provide type safety and self-documentation.
- Follow naming conventions: camelCase for fields, PascalCase for types, SCREAMING_SNAKE_CASE for enum values, verbNoun for mutations.
- Schema-first design is better for cross-team collaboration. Code-first is better for tight backend integration. Choose based on who reviews the schema.
- Paginate all list fields using the connection pattern or simple cursor arguments. Unbounded lists are a performance and reliability risk.