Skip to content
Santekno.com | Tech Tutorials and Trends
EN
📖 0%
21 Jul 2025 · 6 min read ·Article 21 / 125
Go

21 Best Practices for Designing a GraphQL Schema

IH
Ihsan Arif
Writer at Santekno · Backend Engineer

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:

graphql
1type Usr { nm: String }
Good example:
graphql
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.

graphql
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.

graphql
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.

graphql
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” (!).

graphql
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.

graphql
1type Query {
2  posts(first: Int, after: String): PostConnection!
3}


7. Adopt the Standard Connection Pattern for Pagination

It looks like this:

graphql
1type PostConnection {
2  pageInfo: PageInfo!
3  edges: [PostEdge!]
4}
5
6type PostEdge {
7  node: Post!
8  cursor: String!
9}
This promotes consistency and lets built-in GraphQL tooling work to its full potential.


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).

graphql
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.

graphql
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.

graphql
 1# Avoid uncontrolled relationship depth
 2query {
 3  user {
 4    posts {
 5      comments {
 6        author {
 7          posts { ... }
 8        }
 9      }
10    }
11  }
12}
Apply a depth limit on the backend.


11. Add Rate Limiting Simulation via a Directive

graphql
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.

graphql
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.

graphql
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.

graphql
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.

graphql
1enum OrderStatus {
2  PENDING
3  PROCESSED
4  DELIVERED
5}
Use unions for query results that can return multiple types.


18. Use Fragments for Consistency in Client Queries

On the client side, fragments help avoid duplicating query structures.

graphql
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.

graphql
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:

graphql
 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

js
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

MERMAID
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

PracticeBenefit
Naming consistencyEasy for the whole team to understand
Custom scalarBetter validation & data types
Input type for mutationsEasy to scale & maintain parameters
Standard paginationConsistent data consumption pattern
Authorization directiveClear which parts require authentication
Non-null fieldsClient is aware which data must exist
CI/CD linterPrevents bugs & breaking changes
Enum & unionType-safe, powerful queries
Schema documentationAutomated 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 🚀


Danger

References:

Related Articles

💬 Comments