GraphQL Schema is the contract between your server and your clients.
It defines what data your clients can request, how they will receive it, and how they can modify it.
Good schema design makes your API easy to use, predictable, and maintainable.
In the past, I’ve designed several production-ready GraphQL schemas that lasted in time.
I saw and learned what worked well and what didn’t.
In today’s article, I’ll discuss the key principles, rules, patterns, and design considerations that worked well for me in the past.
1. General Principles
1.1 YAGNI (You Aren’t Gonna Need It)
Keep your API minimal.
Expose fields and use cases only when needed.
Deprecate the fields that are not being used anymore.
Fewer fields means less surface are for bugs, simpler documentation, and easier client development.
1.2 Completeness
Keep your schema minimal, yet complete.
Clients should be able to achieve their use cases completely.
A complete schema saves clients from multiple APIs, requests, and keeps data fetching efficient.
1.3 Embed Domain Knowledge
Encode as mush as possible and as clearly as possible the domain knowledge in the schema.
Your names should match the domain knowledge and language.
Domain-Driven schemas are much easier to deal with, especially for newer developers to understand the business domain.
1.4 No Implementation Details
The API should focus on the Domain and Client use cases.
Implementation details must be hidden.
2. Naming Conventions
2.1 camelCase For Fields & Inputs
Field names and inputs should use camelCase.
Same as we do in JavaScript.
type User {
firstName: String!
lastName: String!
}
2.2 PascalCase For Types & Enums
Type names should use pascalCase.
This matches how classes are defined in JavaScript.
type OrderDetail { ... }
enum PaymentMethod { CREDIT_CARD, PAYPAL }
2.3 ALL_CAPS For Enum Values
Enum values should use ALL_CAPS, because they are similar to constants.
enum Role { ADMIN, EDITOR, VIEWER }
2.4 Use Create/Update/Delete In Mutations
Use Create/Update/Delete in mutations instead of they synonyms, unless you have a specific reason to use another verb.
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload
deletePost(input: DeletePostInput!): DeletePostPayload
}
2.5 Avoid Abbreviations
Prefer to be explicit about names.
3. Fields And Mutations
3.1 Default Values
Use default values to make an API more predictable and communicate intent.
type Query {
#<strong> ⛔ Bad</strong>
products(first: Int!, <strong>orderBy: ProductsOrdering</strong>)
#<strong> ✅ Good</strong>
products(first: Int!, <strong>orderBy: ProductsOrdering = CREATED_AT</strong>)
}
3.2 Explicit Side-Effects
Avoid implicit and surprise side-effects in fields and mutations.
The name should convey what it does at runtime.
3.3 Mutation Naming: verbEntity
Name mutations using verbEntity (and not entityVerb).
#<strong> ⛔ Bad</strong>
userInvite
#<strong> ✅ Good</strong>
inviteUser
3.4 Single-Purpose Fields
Do one thing, and do it well.
Prefer specific solutions rather than overly clever and generic fields.
3.5 Beyond CRUD
Think beyond CRUD.
Use action or behavior specific fields and mutations to help clients use your API.
#<strong> ⛔ Bad</strong>
<strong>updatePost</strong>(input: {..., <strong>archived: true }</strong>) {
...
}
#<strong> ✅ Good</strong>
<strong>archivePost</strong>(input: { <strong>postID: "abc"</strong> }) {
...
}
3.6 Input & Payload Types
Use Input and Payload types for mutations.
Use specific object types for mutation inputs and outputs.
Use *Input suffix for input and *Payload suffix for the result.
type Mutation {
# <strong>⛔ Bad</strong>
createProduct(name: String!): Product
# <strong>✅ Good</strong>
createProduct(<strong>input: CreateProductInput!</strong>): <strong>CreateProductPayload</strong>
}
3.7 Don’t Reuse Types Across Mutations
Don’t reuse input and payload types across multiple mutations.
This prevents accidental coupling.
3.8 Use Non-Null With Care
Use Non-Null mostly on scalar fields and use them with care on associations.
Non-Nullable mean that the field must never be null.
If it is null for any reason, GraphQL will return error without the data.
type User {
# <strong>Prefer nullable associations, especially if backed by external services</strong>
profilePicture: ProfilePicture
# <strong>Scalars are usually safer</strong>
name: String!
}
You can read more about nullability here.
3.9 Prefer Required Arguments
Prefer a strong schema rather than runtime checks.
Use more specific fields if needed.
type Query {
# <strong>⛔ Bad</strong>
userByNameOrID(<strong>id: ID</strong>, <strong>name: String</strong>): User
# <strong>✅ Good</strong>
userByName(<strong>name: String!</strong>): User
userByID(<strong>id: ID!</strong>): User
}
4. Error Handling
4.1 GraphQL Errors For Global Issues
Use GraphQL Errors for developer-facing global errors.
Example where to use such errors: internal (unexpected) server errors, timeouts, auth, schema validation, etc.
4.2 Error Extensions
Use GraphQL Error extensions to encode additional information.
Always provide a unique “code”.
This helps the client to decide what to do based on the “code”, instead of the error message.
{
"errors": [
{
"message": "The current user does not have permissions for: ...",
"<strong>extensions</strong>": {
<strong>"code": "UNAUTHORIZED"</strong>
}
}
]
}
4.3 User-Facing Errors in Schema
Use user errors, or errors-in-schema, for user-facing errors.
Use the schema instead of top-level errors for errors that are meant to be displayed to the user.
{
"errors": [
{
"message": "Invalid input arguments.",
<strong>"extensions": {</strong>
"code": "BAD_USER_INPUT",
<strong>"errors": [
{ "field": "email", "message": "Email already registered" }
]</strong>
<strong>}</strong>
}
]
}
5. Identification
5.1 Global IDs
Every type that implements the Node interface must define a unique and global ID.
Those IDs are opaque so that it is clear they are not intended to be decoded on the client.
5.2 Rich ID Encoding
Encode enough information in IDs for global fetching.
Prefer encoding more that not enough for evolution.
This might mean including the parent object id, the shard key, etc.
📌 TL;DR
- General Principles
- YAGNI (You Aren’t Gonna Need It)
- Completeness
- Embed Domain Knowledge
- No Implementation Details
- Naming Conventions
- camelCase For Fields & Inputs
- PascalCase For Types & Enums
- ALL_CAPS For Enum Values
- Use Create/Update/Delete In Mutations
- Avoid Abbreviations
- Fields And Mutations
- Use Default Values
- Prefer Explicit Side-Effects
- Mutation Naming: verbEntity
- Prefer Single-Purpose Fields
- Think Beyond CRUD
- Input & Payload Types
- Don’t Reuse Types Across Mutations
- Use Non-Null With Care
- Prefer Required Arguments
- Error Handling
- GraphQL Errors For Global Issues
- Error Extensions
- User-Facing Errors in Schema
- Identification
- Use Global IDs
- Rich ID Encoding
