正在加载,请稍候…

GraphQL vs REST: When to Use Each and How to Build Both Well

Comprehensive GraphQL vs REST comparison: query language basics, schemas, resolvers, N+1 problem, subscriptions, authentication, and when each approach fits your architecture.

The Real Tradeoffs (Not the Marketing Version)

REST vs GraphQL debates often devolve into tribal arguments. The reality is that both are valid approaches with genuine tradeoffs, and the "right" choice depends on your client-server relationship, team structure, and data complexity.

GraphQL solves specific problems REST has: over-fetching, under-fetching, and frontend teams blocked on backend changes. REST solves problems GraphQL has: caching, simplicity, and predictable performance. Neither is universally better.

REST in 2026

REST (Representational State Transfer) structures APIs around resources and HTTP semantics:

GET    /users          → List users
GET    /users/123      → Get user 123
POST   /users          → Create user
PUT    /users/123      → Replace user 123
PATCH  /users/123      → Partially update user 123
DELETE /users/123      → Delete user 123

What REST does well:

  • HTTP caching works out of the box (CDNs, browser cache)
  • Simple mental model — resources + verbs
  • Easy to monitor (each endpoint = one type of operation)
  • Stateless by design
  • Works with any HTTP client without special tooling

REST pain points:

  • Over-fetching: GET /users returns 20 fields, you need 3
  • Under-fetching: Need user + posts + comments = 3 requests
  • API versioning (v1, v2...) causes proliferation
  • Frontend teams wait on backend for new field combinations

GraphQL Fundamentals

GraphQL is a query language for APIs — clients specify exactly what data they need:

# Client sends exactly what it wants:
query GetUserProfile($userId: ID!) {
  user(id: $userId) {
    id
    name
    email
    # Request posts with just the fields needed
    posts(limit: 5) {
      title
      publishedAt
      commentCount  # Computed field — no extra request
    }
  }
}

# Server returns exactly that shape:
{
  "data": {
    "user": {
      "id": "123",
      "name": "Alice",
      "email": "alice@example.com",
      "posts": [
        { "title": "My First Post", "publishedAt": "2026-01-15", "commentCount": 7 }
      ]
    }
  }
}

One HTTP endpoint (POST /graphql) handles all queries. No over/under-fetching.

Schema-First Design

GraphQL starts with a schema that defines the API contract:

# schema.graphql
type User {
  id: ID!
  name: String!
  email: String!
  posts(limit: Int = 10, offset: Int = 0): [Post!]!
  followers: [User!]!
  createdAt: DateTime!
}

type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  comments(limit: Int = 20): [Comment!]!
  tags: [String!]!
  publishedAt: DateTime
  commentCount: Int!
}

type Comment {
  id: ID!
  content: String!
  author: User!
  createdAt: DateTime!
}

# Root types — entry points for all operations
type Query {
  user(id: ID!): User
  users(limit: Int = 20, offset: Int = 0): [User!]!
  post(id: ID!): Post
  searchPosts(query: String!): [Post!]!
}

type Mutation {
  createPost(input: CreatePostInput!): Post!
  updatePost(id: ID!, input: UpdatePostInput!): Post!
  deletePost(id: ID!): Boolean!
  addComment(postId: ID!, content: String!): Comment!
}

type Subscription {
  commentAdded(postId: ID!): Comment!
}

input CreatePostInput {
  title: String!
  content: String!
  tags: [String!]
}

input UpdatePostInput {
  title: String
  content: String
  tags: [String!]
}

scalar DateTime

Building a GraphQL Server (Node.js)

// Using Apollo Server 4 with Express
import { ApolloServer } from '@apollo/server'
import { expressMiddleware } from '@apollo/server/express4'
import express from 'express'
import { readFileSync } from 'fs'

const typeDefs = readFileSync('./schema.graphql', 'utf8')

const resolvers = {
  Query: {
    user: async (_: unknown, { id }: { id: string }, ctx: Context) => {
      return ctx.db.users.findById(id)
    },
    
    users: async (_: unknown, { limit, offset }: { limit: number, offset: number }, ctx: Context) => {
      return ctx.db.users.findAll({ limit, offset })
    },

    searchPosts: async (_: unknown, { query }: { query: string }, ctx: Context) => {
      return ctx.db.posts.search(query)
    },
  },

  Mutation: {
    createPost: async (_: unknown, { input }: { input: CreatePostInput }, ctx: Context) => {
      if (!ctx.user) throw new AuthenticationError('Not authenticated')
      
      return ctx.db.posts.create({
        ...input,
        authorId: ctx.user.id,
        publishedAt: new Date(),
      })
    },
  },

  // Field resolvers — called when this field is requested
  User: {
    posts: async (user: User, { limit, offset }: PaginationArgs, ctx: Context) => {
      return ctx.db.posts.findByAuthor(user.id, { limit, offset })
    },
    
    followers: async (user: User, _: unknown, ctx: Context) => {
      return ctx.db.follows.findFollowers(user.id)
    },
  },

  Post: {
    author: async (post: Post, _: unknown, ctx: Context) => {
      return ctx.db.users.findById(post.authorId)
    },
    
    commentCount: async (post: Post, _: unknown, ctx: Context) => {
      return ctx.db.comments.countByPost(post.id)
    },
  },
}

const server = new ApolloServer({ typeDefs, resolvers })
await server.start()

const app = express()
app.use('/graphql', expressMiddleware(server, {
  context: async ({ req }) => ({
    user: await getUser(req.headers.authorization),
    db: createDbConnection(),
  }),
}))

The N+1 Problem and DataLoader

The most common GraphQL performance issue: when resolving a list, each item triggers a separate database query:

Query: posts { author { name } }

Execution (naive):
1. SELECT * FROM posts LIMIT 10          → 10 posts
2. SELECT * FROM users WHERE id = 1      → post 1's author
3. SELECT * FROM users WHERE id = 2      → post 2's author
... 10 more queries!

Total: 11 queries for a "simple" request

DataLoader batches these queries:

import DataLoader from 'dataloader'

// Create a loader that batches user lookups
const userLoader = new DataLoader<string, User>(async (userIds) => {
  // One query for all IDs
  const users = await db.users.findByIds([...userIds])
  
  // DataLoader requires results in same order as keys
  const userMap = new Map(users.map(u => [u.id, u]))
  return userIds.map(id => userMap.get(id) ?? new Error(`User ${id} not found`))
})

// Use in resolver — DataLoader batches these automatically
const resolvers = {
  Post: {
    author: (post: Post, _: unknown, ctx: Context) => {
      return ctx.loaders.user.load(post.authorId) // Batched!
    },
  },
}

// Create loaders per request (important! different users shouldn't share caches)
context: async ({ req }) => ({
  loaders: {
    user: new DataLoader(batchLoadUsers),
    comment: new DataLoader(batchLoadComments),
  },
})

// Now: 10 posts → 1 SELECT * FROM users WHERE id IN (1,2,3,...10)

Authentication and Authorization

// Pattern: Permission-based field authorization

// Using GraphQL Shield
import { shield, rule, and, or } from 'graphql-shield'

const isAuthenticated = rule({ cache: 'contextual' })(
  async (_, __, ctx) => !!ctx.user
)

const isPostAuthor = rule({ cache: 'strict' })(
  async (post, _, ctx) => post.authorId === ctx.user?.id
)

const isAdmin = rule({ cache: 'contextual' })(
  async (_, __, ctx) => ctx.user?.role === 'admin'
)

const permissions = shield({
  Query: {
    users: isAdmin,
    user: isAuthenticated,
  },
  Mutation: {
    createPost: isAuthenticated,
    deletePost: or(isPostAuthor, isAdmin),
    updatePost: isPostAuthor,
  },
})

// Alternative: Directive-based auth
# Schema directive for authorization
type Query {
  adminStats: Stats @auth(requires: ADMIN)
  myProfile: User @auth(requires: USER)
  publicPosts: [Post!]!
}

directive @auth(requires: Role = USER) on FIELD_DEFINITION
enum Role { ADMIN USER }

Subscriptions (Real-Time)

// WebSocket-based subscriptions with graphql-ws
import { WebSocketServer } from 'ws'
import { useServer } from 'graphql-ws/lib/use/ws'
import { PubSub } from 'graphql-subscriptions'

const pubsub = new PubSub()

const resolvers = {
  Mutation: {
    addComment: async (_, { postId, content }, ctx) => {
      const comment = await ctx.db.comments.create({ postId, content, authorId: ctx.user.id })
      
      // Publish event for subscribers
      await pubsub.publish('COMMENT_ADDED', { commentAdded: comment, postId })
      
      return comment
    },
  },

  Subscription: {
    commentAdded: {
      subscribe: (_, { postId }) => pubsub.asyncIterator(['COMMENT_ADDED']),
      resolve: (payload) => payload.commentAdded,
      // Filter: only send to subscribers watching this post
      filter: (payload, variables) => payload.postId === variables.postId,
    },
  },
}

// Client subscription:
const COMMENT_SUBSCRIPTION = gql`
  subscription OnCommentAdded($postId: ID!) {
    commentAdded(postId: $postId) {
      id
      content
      author { name }
      createdAt
    }
  }
`

Caching with GraphQL

GraphQL doesn't benefit from HTTP caching the way REST does (all requests are POST to the same URL). Solutions:

// 1. Persisted Queries — convert operations to GETs
// Client sends hash → server looks up full query
// GET /graphql?operationName=GetUser&variables={...}&extensions={"persistedQuery":{...}}

// Apollo's automatic persisted queries:
const link = createPersistedQueryLink({ useGETForHashedQueries: true })

// 2. Response caching with cache hints
const resolvers = {
  Query: {
    post: async (_, { id }, ctx) => {
      const post = await ctx.db.posts.findById(id)
      
      // Hint cache for 60 seconds
      ctx.setCacheHint({ maxAge: 60, scope: 'PUBLIC' })
      
      return post
    },
  },
}

// 3. Client-side caching with Apollo Client
const client = new ApolloClient({
  cache: new InMemoryCache({
    typePolicies: {
      Post: {
        keyFields: ['id'],
        fields: {
          comments: {
            keyArgs: ['postId'],
            merge(existing = [], incoming) {
              return [...existing, ...incoming] // Append pagination
            },
          },
        },
      },
    },
  }),
})

When to Choose GraphQL vs REST

Choose GraphQL when:

  • Multiple client types (web, mobile, third-party) with different data needs
  • Complex, interconnected data (social graphs, content management)
  • Frontend team velocity is blocked by API changes
  • You need real-time subscriptions
  • Mobile clients where bandwidth matters

Choose REST when:

  • Simple CRUD operations
  • Public API for third-party developers (REST is more familiar)
  • CDN caching is important
  • Team is small and REST is already well-understood
  • File uploads are primary use case (GraphQL handles these awkwardly)

Use Both: GraphQL for your internal frontend, REST for public APIs and webhooks.

The choice isn't permanent — many companies start with REST and add GraphQL as client requirements grow. Avoid rewriting working APIs; instead, add GraphQL as a layer alongside existing REST endpoints.

→ Convert and transform data between JSON and XML formats with the JSON to XML converter.