21 Best Practices for Designing a GraphQL Schema
21 Best Practices for Designing a GraphQL Schema
GraphQL has revolutionized the way we build and consume APIs, offering a level of flexibility that REST struggles to provide. However, this flexibility can become a double-edged sword if we are not careful about how we design the GraphQL schema. A poor schema can lead to performance issues, security risks, and a suboptimal user experience.
As an engineer who has spent several years working with GraphQL — across fintech scale-ups, e-commerce, and nonprofit organizations — I have learned that there is a unique “ninja path” to designing a GraphQL schema that is more sustainable, scalable, and easy to maintain. Below are 21 best practices, complete with examples, simulations, and flow diagrams that I typically apply.
1. Use Consistent and Descriptive Names
Use a naming convention that is easy for cross-functional teams to understand. Field, type, and operation names should be consistent, memorable, and descriptive.
Bad example:
1type Usr { nm: String }1type User { name: String }2. Structure Root Query Fields by Topic
Group your main entities under the root query so clients can find them easily.
1type Query {
2 user(id: ID!): User
3 post(id: ID!): Post
4 search(query: String!): [SearchResult!]
5}3. Use Scalars and Custom Scalars Where Appropriate
Define custom scalars such as DateTime or Email for better validation.
1scalar DateTime
2
3type User {
4 birthDate: DateTime
5}4. Leverage Input Types in Mutations
Instead of cramming many arguments into a mutation function, use an input object.
1input CreatePostInput {
2 title: String!
3 content: String!
4}
5
6type Mutation {
7 createPost(input: CreatePostInput!): Post!
8}5. Use Non-Null (!) When Data Must Be Present
Help client developers by marking fields that are “required” (!).
1type User {
2 id: ID!
3 email: String!
4 phone: String
5}6. Support Pagination and Limit Over-fetching Queries
Use the relay pattern (edges & pageInfo) or a simple limit, offset approach.
1type Query {
2 posts(first: Int, after: String): PostConnection!
3}7. Adopt the Standard Connection Pattern for Pagination
It looks like this:
1type PostConnection {
2 pageInfo: PageInfo!
3 edges: [PostEdge!]
4}
5
6type PostEdge {
7 node: Post!
8 cursor: String!
9}8. Enforce Authorization at the Schema Level
Define a custom directive so developers know which fields/operations are secured (working together with the backend auth logic).
1directive @auth(role: Role!) on FIELD_DEFINITION
2
3type Query {
4 me: User! @auth(role: USER)
5 adminPanel: Panel! @auth(role: ADMIN)
6}9. Expose Errors in a Structured Way
Use the union/error payload pattern.
1union CreateUserResult = User | ValidationError
2
3type Mutation {
4 createUser(input: CreateUserInput!): CreateUserResult!
5}10. Don’t Over-Nest! Avoid Schemas That Are Too Deep
Too many levels make queries hard to maintain and prone to N+1 problems.
1# Avoid uncontrolled relationship depth
2query {
3 user {
4 posts {
5 comments {
6 author {
7 posts { ... }
8 }
9 }
10 }
11 }
12}11. Add Rate Limiting Simulation via a Directive
1directive @rateLimit(max: Int, window: String) on FIELD_DEFINITION
2
3type Query {
4 heavyResource: Data! @rateLimit(max: 5, window: "1m")
5}12. Limit Query Complexity at the Server Layer
Implement instrumentation to calculate the “weight” of each field. For example, mutations with heavy side effects are assigned a high score.
13. Document Your Schema with Descriptions
Descriptions are useful for automatic documentation tools such as GraphQL Playground or GraphiQL.
1"""
2User is a person that uses the app
3"""
4type User {
5 ...
6}14. Separate Mutations for Create, Update, and Delete
Don’t create a single “savePost” mutation. Split them up for clarity.
1type Mutation {
2 createPost(...): Post!
3 updatePost(...): Post!
4 deletePost(id: ID!): Boolean!
5}15. Avoid Breaking Changes—Version Wisely
Avoid modifying or removing fields/types abruptly. Use deprecated instead.
1type User {
2 oldField: String @deprecated(reason: "Use newField instead")
3 newField: String
4}16. Optimize for Batching & Caching
Apply a DataLoader on the backend for batching, and cache the resolution of child nodes.
17. Use Enums and Unions Wisely
Use enums for a limited set of values.
1enum OrderStatus {
2 PENDING
3 PROCESSED
4 DELIVERED
5}18. Use Fragments for Consistency in Client Queries
On the client side, fragments help avoid duplicating query structures.
1fragment userInfo on User {
2 id
3 name
4 avatar
5}19. Add Metadata Fields for Edge Cases
Such as totalCount for pagination, hasMore for lazy loading, and so on.
1type PageInfo {
2 hasNextPage: Boolean!
3 totalCount: Int
4}20. Security Audit: Limit Introspection in Production
Ideally, introspection should only be enabled during development.
21. Maintain Your Schema with CI/CD Tooling
Lint and validate your schema automatically (for example, using graphql-schema-linter ), set up deploy previews, and run behavioral tests in your CI pipeline.
Case Simulation: The CreateUser Flow
Let’s look at the schema, the logic flow, and error handling:
1input CreateUserInput {
2 name: String!
3 email: String!
4 password: String!
5}
6
7type ValidationError {
8 field: String!
9 message: String!
10}
11
12union CreateUserResult = User | ValidationError
13
14type Mutation {
15 createUser(input: CreateUserInput!): CreateUserResult!
16}Example Resolver Pseudocode
1async function createUser(parent, args, context) {
2 const { name, email, password } = args.input
3 const errors = validateUser(args.input)
4 if (errors.length > 0) {
5 return { field: errors[0].field, message: errors[0].message }
6 }
7 const user = await db.user.create({ name, email, passwordHash: hash(password) })
8 return user
9}Visualization: CreateUser Flow Diagram
graph TD
A[Client kirim createUser] --> B{Validasi Input}
B -- Tidak valid --> C[Return ValidationError]
B -- Valid --> D[Simpan ke Database]
D --> E[Return User]
Best Practice Checklist Table
| Practice | Benefit |
|---|---|
| Naming consistency | Easy for the whole team to understand |
| Custom scalar | Better validation & data types |
| Input type for mutations | Easy to scale & maintain parameters |
| Standard pagination | Consistent data consumption pattern |
| Authorization directive | Clear which parts require authentication |
| Non-null fields | Client is aware which data must exist |
| CI/CD linter | Prevents bugs & breaking changes |
| Enum & union | Type-safe, powerful queries |
| Schema documentation | Automated API docs |
Conclusion
Designing a solid GraphQL schema is like laying the foundation of our digital house. By applying the 21 best practices above, you and your team can build an API that is scalable, easy to maintain, secure, and loved by both client and frontend developers. There is no single absolute solution: keep evaluating, adapting, and learning from your business needs and the feedback of the teams consuming your API.
If you have unique experiences or other tips, share them in the comments!
Happy GraphQL-ing 🚀
References:
- GraphQL Official Best Practices
- Apollo GraphQL Patterns
- Personal experience building API schemas at Indonesian startups