# Building Idiomatic GraphQL APIs Without the Common Anti-Patterns

GraphQL has changed how developers design and consume APIs. Instead of relying on multiple REST endpoints to retrieve different pieces of information, GraphQL allows clients to request exactly the data they need through a single, flexible query interface.

However, flexibility does not automatically translate into good API design.

Many GraphQL implementations become difficult to maintain because of poorly structured schemas, inefficient database queries, excessive resolver logic, inconsistent error handling, and unnecessary complexity. These issues can negatively affect performance, developer experience, and long-term scalability.

Building an idiomatic GraphQL API means following the principles and conventions that make GraphQL predictable, efficient, and easy to work with.

In this article, we'll explore how to build well-structured GraphQL APIs, identify common anti-patterns, and understand the practices that lead to maintainable and scalable implementations.

## 1\. Start With a Well-Designed GraphQL Schema

The schema is the foundation of every GraphQL API. It defines the types, fields, relationships, queries, and mutations that clients can access.

One common mistake is designing schemas around database tables rather than the needs of the application and its consumers.

For example, consider this schema:

```graphql
type User {
  id: ID!
  first_name: String!
  last_name: String!
  email_address: String!
  created_at: String!
}
```

Although this structure may reflect the database, it does not follow common GraphQL naming conventions.

A more idiomatic approach would be:

```graphql
type User {
  id: ID!
  firstName: String!
  lastName: String!
  email: String!
  createdAt: String!
}
```

GraphQL commonly uses camelCase for field names and PascalCase for type names.

A well-designed schema should:

- Use descriptive and consistent naming conventions.
- Represent business concepts rather than database implementation details.
- Define clear relationships between types.
- Use appropriate scalar types.
- Avoid exposing unnecessary internal information.
- Remain intuitive for developers consuming the API.

**Key takeaway:** Design your schema as a developer-facing contract, not simply as a representation of your database.

## 2\. Avoid the N+1 Query Problem

The N+1 query problem is one of the most common performance issues in GraphQL applications.

It happens when a resolver executes an additional database query for every item returned by a parent resolver.

Consider this query:

```graphql
query {
  users {
    id
    name
    posts {
      id
      title
    }
  }
}
```

A poorly optimized implementation might execute:

1. One query to retrieve all users.
2. One additional query for each user's posts.

If the API returns 100 users, it could execute 101 database queries.

This creates unnecessary database load and increases response times.

### How to solve it with DataLoader

DataLoader helps batch and cache data-loading operations within a request.

Example:

```javascript
const DataLoader = require("dataloader");

const postLoader = new DataLoader(async (userIds) => {
  const posts = await db.post.findMany({
    where: {
      userId: { in: userIds }
    }
  });

  return userIds.map(userId =>
    posts.filter(post => post.userId === userId)
  );
});
```

Your resolver can then use the loader:

```javascript
const resolvers = {
  User: {
    posts: (user, args, context) => {
      return context.postLoader.load(user.id);
    }
  }
};
```

This approach batches requests and reduces repetitive database operations.

Remember to create request-scoped loaders when appropriate, particularly when authorization or user-specific data is involved.

## 3\. Don't Turn GraphQL Into a REST API With a Single Endpoint

One misconception is that GraphQL simply replaces multiple REST endpoints with one endpoint.

This often leads to schemas containing generic fields such as:

```graphql
type Query {
  getUser(id: ID!): User
  getAllUsers: [User]
  getUserPosts(userId: ID!): [Post]
  getPostDetails(postId: ID!): Post
}
```

While these fields can work, excessive operation-specific naming can make a schema unnecessarily verbose.

A cleaner approach is to use resource-oriented fields and let GraphQL relationships handle nested data.

```graphql
type Query {
  user(id: ID!): User
  users: [User!]!
  post(id: ID!): Post
}
```

Clients can retrieve related information through the schema:

```graphql
query {
  user(id: "123") {
    id
    name
    posts {
      title
      publishedAt
    }
  }
}
```

This makes the API more flexible and reduces the need for separate fields for every possible combination of data.

The goal is not to eliminate every specialized query. It is to avoid designing GraphQL as a collection of REST-style operations without taking advantage of its type system and relationships.

## 4\. Avoid Putting Business Logic Inside Resolvers

Resolvers connect GraphQL fields to the underlying application logic.

A common anti-pattern is placing database operations, validation, authorization, and business rules directly inside resolver functions.

For example:

```javascript
const resolvers = {
  Mutation: {
    createOrder: async (_, args, context) => {
      if (!args.productId) {
        throw new Error("Product ID required");
      }

      const product = await db.product.findUnique({
        where: { id: args.productId }
      });

      if (!product) {
        throw new Error("Product not found");
      }

      return db.order.create({
        data: {
          productId: product.id,
          userId: context.user.id
        }
      });
    }
  }
};
```

This might work initially, but as the application grows, resolver functions become difficult to test and maintain.

A better approach is to separate responsibilities.

For example:

```javascript
const resolvers = {
  Mutation: {
    createOrder: (_, args, context) => {
      return context.services.orderService.createOrder({
        productId: args.productId,
        userId: context.user.id
      });
    }
  }
};
```

The service layer handles the actual business logic.

This separation offers several advantages:

- Easier unit testing.
- Reusable business logic.
- Cleaner resolver functions.
- Better separation of concerns.
- Easier maintenance as the application evolves.

Resolvers should coordinate operations rather than become the entire application architecture.

## 5\. Use Mutations for Actions and Queries for Data Retrieval

GraphQL separates operations into queries and mutations.

Queries are intended for retrieving data, while mutations represent operations that modify application state.

An anti-pattern is using queries to perform operations that change data.

For example:

```graphql
type Query {
  updateUserEmail(
    userId: ID!
    email: String!
  ): User
}
```

This is misleading because clients generally expect queries to be read-only.

A better schema would be:

```graphql
type Mutation {
  updateUserEmail(
    userId: ID!
    email: String!
  ): User!
}
```

This makes the API more predictable and communicates the intention of each operation.

For production systems, also consider:

- Input validation.
- Authorization checks.
- Consistent return types.
- Transaction management.
- Idempotency where appropriate.

Use mutations to represent meaningful state changes rather than treating them as generic database commands.

## 6\. Don't Ignore GraphQL Error Handling

Error handling is another area where poorly designed GraphQL APIs can create confusion.

A common mistake is returning generic errors without meaningful context.

For example:

```javascript
throw new Error("Something went wrong");
```

This does not help developers understand what happened or how to resolve the issue.

A better approach is to use structured errors and consistent error-handling conventions.

For example:

```javascript
throw new GraphQLError("User not found", {
  extensions: {
    code: "USER_NOT_FOUND"
  }
});
```

Clients can use the error code to handle specific situations.

Common error categories include:

- `UNAUTHENTICATED`: The user has not authenticated.
- `FORBIDDEN`: The user lacks permission.
- `BAD_USER_INPUT`: The supplied input is invalid.
- `NOT_FOUND`: The requested resource does not exist.
- `INTERNAL_SERVER_ERROR`: An unexpected server-side failure occurred.

Avoid exposing database queries, stack traces, credentials, or sensitive internal details in production error responses.

Also remember that GraphQL can return both `data` and `errors` in the same response. Clients should be designed to handle partial results when appropriate.

## 7\. Implement Proper Pagination Instead of Returning Everything

Returning thousands of records through a single GraphQL query can negatively affect performance.

Consider this schema:

```graphql
type Query {
  products: [Product!]!
}
```

If a client requests every available product, the server may need to retrieve and serialize a large amount of data.

A better approach is to implement pagination.

GraphQL supports different pagination strategies, including offset-based and cursor-based pagination.

### Cursor-based pagination example

```graphql
type Query {
  products(
    first: Int!
    after: String
  ): ProductConnection!
}
```

The connection can be structured as follows:

```graphql
type ProductConnection {
  edges: [ProductEdge!]!
  pageInfo: PageInfo!
}

type ProductEdge {
  cursor: String!
  node: Product!
}

type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
}
```

Clients can request the first 10 products:

```graphql
query {
  products(first: 10) {
    edges {
      cursor
      node {
        id
        name
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}
```

Cursor-based pagination is especially useful when working with large datasets or frequently changing records.

Always enforce reasonable pagination limits on the server rather than relying entirely on client behavior.

## 8\. Protect Your API From Expensive Queries

GraphQL allows clients to request nested data through multiple levels of relationships.

However, without appropriate safeguards, clients can submit deeply nested or computationally expensive queries.

For example:

```graphql
query {
  users {
    posts {
      comments {
        author {
          posts {
            comments {
              author {
                name
              }
            }
          }
        }
      }
    }
  }
}
```

Such queries can place significant pressure on databases and application servers.

To protect your API, consider implementing:

**Query depth limiting:** Restricts how deeply queries can be nested.

**Query complexity analysis:** Assigns costs to fields and rejects operations that exceed an acceptable threshold.

**Rate limiting:** Controls how frequently clients can access the API.

**Pagination limits:** Prevents requests for excessively large datasets.

**Timeouts:** Stops operations that take too long to execute.

**Caching:** Reduces repetitive work for frequently requested data.

Security should be part of GraphQL API design from the beginning, not something added after performance problems appear.

## 9\. Avoid Overusing Nullable Fields and Unnecessary Non-Null Types

GraphQL provides nullable and non-null types.

For example:

```graphql
type User {
  id: ID!
  name: String!
  phoneNumber: String
}
```

Here, `id` and `name` are required, while `phoneNumber` is optional.

A common schema-design mistake is marking every field as non-null without considering real application behavior.

For example, a user may register without providing a phone number. Making that field non-null would introduce unnecessary constraints.

On the other hand, making every field nullable weakens the API contract and forces clients to handle unnecessary null checks.

Choose nullability based on actual business requirements.

Consider:

- Can this field legitimately be missing?
- Is the value guaranteed to exist?
- Can an upstream service fail to provide it?
- What happens if a nested resolver returns null?

Remember that GraphQL non-null propagation can cause null values to bubble up through parent fields. This makes thoughtful nullability decisions particularly important.

## 10\. Use Interfaces and Unions When Modeling Different Types of Data

Another common mistake is creating one large type containing numerous optional fields to represent different entities.

For example, an application might have several notification types: email notifications, SMS notifications, and push notifications.

Instead of combining every possible field into one generic type, GraphQL supports interfaces and unions.

Example:

```graphql
union Notification =
  EmailNotification |
  SMSNotification |
  PushNotification

type EmailNotification {
  id: ID!
  emailAddress: String!
  subject: String!
}

type SMSNotification {
  id: ID!
  phoneNumber: String!
  message: String!
}

type PushNotification {
  id: ID!
  deviceToken: String!
  title: String!
}
```

Clients can query different types using inline fragments:

```graphql
query {
  notifications {
    __typename

    ... on EmailNotification {
      emailAddress
      subject
    }

    ... on SMSNotification {
      phoneNumber
      message
    }

    ... on PushNotification {
      deviceToken
      title
    }
  }
}
```

This creates a more expressive schema and makes it easier to represent different business entities without unnecessary optional fields.

## 11\. Treat Schema Evolution as an Important Part of API Design

GraphQL schemas evolve as applications introduce new features and business requirements.

A common anti-pattern is removing or renaming fields without considering existing clients.

For example, changing:

```graphql
type User {
  fullName: String!
}
```

to:

```graphql
type User {
  displayName: String!
}
```

could break applications that still depend on `fullName`.

A safer approach is to introduce the new field while deprecating the old one.

```graphql
type User {
  fullName: String! @deprecated(
    reason: "Use displayName instead."
  )

  displayName: String!
}
```

This gives API consumers time to migrate.

Good schema evolution practices include:

- Deprecating fields before removing them.
- Documenting schema changes.
- Monitoring deprecated field usage.
- Maintaining backward compatibility.
- Using schema checks in CI/CD pipelines.
- Communicating breaking changes clearly.

A GraphQL schema should evolve without unnecessarily disrupting existing consumers.

## 12\. Test Resolvers and API Behavior Independently

Testing is essential for maintaining reliable GraphQL APIs.

However, testing only whether a query returns a successful response is insufficient.

You should validate both the API contract and the underlying resolver behavior.

Consider including:

**Unit tests:** Verify business logic and individual resolver functions.

**Integration tests:** Validate interactions between resolvers, services, and databases.

**Schema tests:** Ensure types, fields, and relationships follow the expected contract.

**Authorization tests:** Confirm that users cannot access restricted data.

**Performance tests:** Identify expensive queries and unexpected database activity.

**Error tests:** Verify that invalid inputs and unexpected failures are handled correctly.

A well-tested GraphQL API is easier to extend, debug, and maintain as application complexity increases.

## Practical Checklist for Building Idiomatic GraphQL APIs

Before deploying a GraphQL API to production, review the following checklist:

- [ ] Use consistent GraphQL naming conventions.
- [ ] Design schemas around business concepts.
- [ ] Prevent N+1 query problems with batching and caching.
- [ ] Keep business logic outside resolvers.
- [ ] Use queries for retrieval and mutations for state changes.
- [ ] Implement meaningful and structured error handling.
- [ ] Add pagination for large collections.
- [ ] Enforce query depth and complexity limits.
- [ ] Apply authentication and authorization at the appropriate level.
- [ ] Choose nullable and non-null types carefully.
- [ ] Use interfaces and unions when appropriate.
- [ ] Deprecate fields before removing them.
- [ ] Test resolver behavior and API performance.
- [ ] Monitor query execution and production errors.

## Conclusion

Building idiomatic GraphQL APIs is not just about writing queries and resolvers. It is about creating a consistent, predictable, and scalable API architecture that developers can understand and maintain.

The most common GraphQL anti-patterns usually emerge when teams focus on implementing features quickly without considering schema design, query efficiency, separation of concerns, security, and long-term compatibility.

By designing schemas around business requirements, preventing N+1 queries, separating business logic from resolvers, implementing pagination, and protecting against expensive operations, development teams can build GraphQL APIs that perform reliably as applications grow.

Ultimately, a good GraphQL API should make life easier for both API consumers and the developers responsible for maintaining it. Following established GraphQL conventions and avoiding unnecessary complexity is one of the best ways to achieve that goal.

**What GraphQL anti-patterns have you encountered in real-world projects? Share your experiences and approaches in the comments!**
