Web Development

GraphQL API Design: Best Practices for Modern Applications

December 14, 2025 Waqas Ahmed 15 min
GraphQL API Design: Best Practices for Modern Applications

Why GraphQL API Design Requires More Upfront Thinking

GraphQL shifts API design responsibility from the server to the schema. A well-designed schema is self-documenting, efficiently resolves data, and evolves without breaking clients. A poorly designed schema creates N+1 performance nightmares, leaks internal data structures, and becomes unmaintainable at scale. This guide covers production GraphQL patterns — schema design, dataloader optimization, authentication, subscriptions, and caching — that have been validated in large-scale applications.

Schema Design Principles

Design your GraphQL schema around business domain concepts, not database tables. A schema that mirrors your ORM models will create brittle coupling and expose implementation details to clients. Think in terms of what the client needs, not what the database stores.

type User {
  id: ID!
  name: String!
  email: String!
  orders(first: Int, after: String): OrderConnection!
  totalSpent: Float!
}

type Order {
  id: ID!
  status: OrderStatus!
  items: [OrderItem!]!
  createdAt: DateTime!
  customer: User!
}

enum OrderStatus {
  PENDING
  PROCESSING
  SHIPPED
  DELIVERED
  CANCELLED
}

type OrderConnection {
  edges: [OrderEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

Use non-null types (!) by default and nullable only where absence is a meaningful state — not just a potential error condition. This communicates intent clearly and enables client-side type safety without additional null checks.

Resolvers and the N+1 Problem

The N+1 problem is the most common GraphQL performance issue. When resolving a list of orders with their customers, a naive resolver makes 1 query for orders and then N separate queries for each customer. At 100 orders, that is 101 database queries.

The solution is DataLoader — a batching and caching utility that collects individual item loads during a single tick of the event loop and resolves them in a single batched query:

import DataLoader from 'dataloader';

// Create per-request (not per-server) DataLoader instances
export function createLoaders(db) {
  return {
    userLoader: new DataLoader(async (userIds: string[]) => {
      const users = await db.users.findMany({
        where: { id: { in: userIds } }
      });
      // IMPORTANT: Return in same order as input
      return userIds.map(id => users.find(u => u.id === id) ?? null);
    }),
  };
}

// In resolver
const customer = (order, _, { loaders }) => {
  return loaders.userLoader.load(order.customerId);
};

DataLoader instances must be created per-request, not per-server, to avoid data leakage between users.

Authentication with Directives

Custom schema directives provide a declarative way to apply authentication and authorization at the schema level rather than duplicating checks in every resolver:

directive @auth on FIELD_DEFINITION
directive @hasRole(role: String!) on FIELD_DEFINITION

type Query {
  me: User @auth
  adminStats: AdminStats @auth @hasRole(role: "ADMIN")
  publicPosts: [Post!]!
}

Implement directive transformers using GraphQL Yoga's or Apollo's schema transform utilities. This approach keeps resolvers focused on data fetching logic and centralizes access control in a single, testable location.

Subscriptions

GraphQL subscriptions enable real-time data over WebSockets. Use them selectively — they add operational complexity and should only replace polling when sub-second latency is required. Apollo Server with graphql-ws handles the WebSocket upgrade and subscription lifecycle:

type Subscription {
  orderStatusUpdated(orderId: ID!): Order!
}

// Subscription resolver
const resolvers = {
  Subscription: {
    orderStatusUpdated: {
      subscribe: (_, { orderId }, { pubSub }) =>
        pubSub.subscribe(`ORDER_STATUS:${orderId}`),
      resolve: (payload) => payload,
    },
  },
};

Pagination: Cursor vs Offset

Cursor-based pagination (Relay-style connections) is superior for most production use cases. Offset pagination breaks when items are added or removed between pages — a user can miss items or see duplicates. Cursors are stable references to a position in the result set. Implement cursor pagination using an opaque base64-encoded cursor containing the sort field value and ID:

function encodeCursor(id: string, createdAt: Date): string {
  return Buffer.from(`${createdAt.toISOString()}:${id}`).toString('base64');
}

function decodeCursor(cursor: string): { createdAt: Date; id: string } {
  const [createdAt, id] = Buffer.from(cursor, 'base64').toString().split(':');
  return { createdAt: new Date(createdAt), id };
}

Apollo vs GraphQL Yoga

Apollo Server is the most widely deployed GraphQL server with the largest ecosystem. GraphQL Yoga, from The Guild, is more standards-compliant, framework-agnostic, and lighter weight. For Next.js applications, Yoga integrates cleanly with API routes and supports the Fetch API natively. Apollo remains the better choice for large teams that need the Apollo Studio ecosystem, federated microservice graphs, and enterprise support.

Persisted Queries

Persisted queries reduce query parsing overhead and bandwidth, and enable query allowlisting as a security measure. Register queries during build time with a hash ID, and send only the hash at runtime:

// Client sends: { id: "sha256:abc123...", variables: { userId: "1" } }
// Server looks up full query from registered map
const persistedQueryPlugin = {
  requestDidStart: () => ({
    async executionDidStart({ request }) {
      if (request.extensions?.persistedQuery) {
        const { sha256Hash } = request.extensions.persistedQuery;
        request.query = queryMap[sha256Hash];
      }
    }
  })
};

Caching with Redis

Cache resolver results in Redis for expensive operations. Use a response cache plugin keyed by query hash + user context, with TTLs appropriate to data staleness requirements. Invalidate cache entries in Redis when underlying data changes via pub/sub or database triggers rather than relying on TTL expiry alone for consistency-sensitive queries.

A well-designed GraphQL API with dataloaders, directive-based auth, cursor pagination, and strategic caching serves as a stable, high-performance data layer that scales from prototype to millions of users without fundamental redesign.

#GraphQL#API Design#Backend#Node.js