BlogBlog · graphql

GraphQL vs REST: chọn theo bài toán, không theo trend (so sánh trên cùng một order dashboard)

Cùng một order dashboard viết bằng REST và GraphQL: over/under-fetching, N+1 và DataLoader, caching, error, schema evolution, bảo mật query, tooling, kèm bảng quyết định và cách kết hợp cả hai.

Part of Deep Dive series
November 2, 2026
29 min read
By Leo Pham
graphqlrestapi-designbackendarchitecture
Share this post:

Hầu hết các cuộc tranh luận "GraphQL hay REST" mình từng tham gia đều thiếu một thứ: một bài toán cụ thể để so. Ai cũng đem ra so một REST API viết vội với một GraphQL API được tối ưu kỹ, hoặc ngược lại. Kết quả là team chọn theo cảm tính, rồi sáu tháng sau phải trả giá bằng N+1 query, cache không hit, hay một endpoint /dashboard-v3-final.

Bài này làm ngược lại: lấy một tính năng thật, là màn hình order dashboard cho nhân viên vận hành, và viết nó bằng cả hai cách. Dashboard hiện 20 đơn gần nhất, mỗi đơn có tên và email khách, danh sách sản phẩm và tổng tiền. Mobile app của shipper dùng cùng dữ liệu nhưng chỉ cần mã đơn, trạng thái, tổng tiền và tên khách.

Từ ví dụ đó, mình đi qua từng trục: số request, N+1, caching, error, schema evolution, bảo mật, tooling. Cuối bài là bảng quyết định và cách mình kết hợp REST, GraphQL và tRPC trong cùng một hệ thống. Code dùng Node.js 22+, Express 5 cho REST, Apollo Server 5 (graphql-js 16) cho GraphQL, PostgreSQL qua pg.

TL;DR

  • REST thuần resource gây under-fetching (41 request cho một màn hình) và over-fetching (trả cả địa chỉ, điểm loyalty khi chỉ cần tên). Bạn có thể sửa bằng include/sparse fieldsets hoặc một endpoint BFF.
  • GraphQL dời bài toán N+1 từ client về server. Không có DataLoader thì một query sinh ra hàng chục câu SQL.
  • REST được hưởng HTTP caching và CDN gần như miễn phí. GraphQL cần persisted queries (GET + hash) và normalized cache ở client.
  • GraphQL trả 200 kèm mảng errors, nên monitoring theo status code không còn đủ. Bù lại, schema evolution bằng @deprecated mượt hơn version /v2.
  • GraphQL bắt buộc có giới hạn depth/cost và rate limit theo cost. Tắt introspection ở production không phải là biện pháp bảo mật.
  • Public API, webhook, upload file thì dùng REST. Nhiều client với nhu cầu dữ liệu khác nhau thì GraphQL làm BFF. Monorepo TypeScript full-stack thì cân nhắc tRPC.

Bài toán: order dashboard cho web và mobile

Schema PostgreSQL rút gọn:

CREATE TABLE customers (
  id uuid PRIMARY KEY, name text NOT NULL, email text NOT NULL, phone text,
  address jsonb, loyalty_points int NOT NULL DEFAULT 0, marketing_consent boolean NOT NULL DEFAULT false
);
CREATE TABLE products (id uuid PRIMARY KEY, sku text UNIQUE NOT NULL, name text NOT NULL, image_url text);
CREATE TABLE orders (
  id uuid PRIMARY KEY, customer_id uuid NOT NULL REFERENCES customers(id),
  status text NOT NULL, total_amount bigint NOT NULL, currency text NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE order_items (
  id uuid PRIMARY KEY, order_id uuid NOT NULL REFERENCES orders(id),
  product_id uuid NOT NULL REFERENCES products(id), quantity int NOT NULL, unit_price bigint NOT NULL
);
CREATE INDEX ON orders (status, created_at DESC, id DESC);
CREATE INDEX ON order_items (order_id);

Hai client, hai nhu cầu:

  • Web dashboard: 20 đơn paid mới nhất, gồm id, status, totalAmount, createdAt, tên + email khách, và danh sách item (tên sản phẩm, số lượng).
  • Mobile shipper: 20 đơn, chỉ cần id, status, totalAmount, tên khách. Mạng 4G chập chờn, payload càng nhỏ càng tốt.

Cách làm REST: resource, include và BFF

REST thuần resource và cái giá under-fetching

Thiết kế REST "sách giáo khoa" có ba endpoint: GET /v1/orders, GET /v1/customers/{id}, GET /v1/orders/{id}/items. Client dashboard ghép chúng lại:

// web/src/load-dashboard.ts: cách ghép ngây thơ
const api = (path: string) =>
  fetch(`https://api.acme.vn${path}`, { headers: { Authorization: `Bearer ${token}` } }).then((r) => r.json())

const { data: orders } = await api('/v1/orders?status=paid&limit=20')
const rows = await Promise.all(
  orders.map(async (o: { id: string; customerId: string }) => {
    const [customer, items] = await Promise.all([
      api(`/v1/customers/${o.customerId}`),
      api(`/v1/orders/${o.id}/items`),
    ])
    return { ...o, customer, items }
  }),
)

Đếm request: 1 (list) + 20 (customer) + 20 (items) = 41 request, chia thành hai đợt nối tiếp nhau. Trên HTTP/2 các request đi chung một kết nối nên đỡ hơn, nhưng mỗi request vẫn phải qua auth middleware, rate limiter, log và câu SQL riêng. Đây là under-fetching: một request không đủ dữ liệu cho màn hình.

Đồng thời là over-fetching: GET /v1/customers/{id} trả cả address, loyaltyPoints, marketingConsent trong khi dashboard chỉ cần name và email. Với mobile còn tệ hơn: toàn bộ mảng items được tải về rồi bỏ đi.

Sửa trong REST: include, sparse fieldsets, BFF

REST không bắt bạn chịu 41 request. Bạn có ba công cụ:

  1. Compound document: GET /v1/orders?include=customer,items, kiểu JSON:API.
  2. Sparse fieldsets: ?fields[customer]=name,email để cắt over-fetching.
  3. Endpoint BFF (Backend for Frontend): GET /bff/web/dashboard-orders trả đúng shape mà màn hình cần.

Đây là cách implement include sao cho không sinh N+1 ở server. Mỗi quan hệ chỉ thêm một câu SQL dạng WHERE id = ANY($1):

// rest/src/routes/orders.ts (Express 5)
import express from 'express'
import * as z from 'zod'
import { pool } from '../db.js'

export const ordersRouter = express.Router()

const Query = z.object({
  status: z.enum(['pending', 'paid', 'cancelled']).default('paid'),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  include: z
    .string()
    .default('')
    .transform((s) => new Set(s.split(',').filter(Boolean)))
    .refine((set) => [...set].every((v) => v === 'customer' || v === 'items'), 'include chỉ nhận customer, items'),
})

ordersRouter.get('/v1/orders', async (req, res) => {
  const q = Query.parse(req.query) // ZodError được error middleware map sang 422
  const { rows: orders } = await pool.query(
    `SELECT id, customer_id, status, total_amount, currency, created_at
       FROM orders WHERE status = $1 ORDER BY created_at DESC, id DESC LIMIT $2`,
    [q.status, q.limit],
  )
  const customerIds = [...new Set(orders.map((o) => o.customer_id))]
  const orderIds = orders.map((o) => o.id)

  const [customers, items] = await Promise.all([
    q.include.has('customer')
      ? pool.query('SELECT id, name, email FROM customers WHERE id = ANY($1::uuid[])', [customerIds])
      : null,
    q.include.has('items')
      ? pool.query(
          `SELECT oi.order_id, oi.quantity, oi.unit_price, p.name AS product_name
             FROM order_items oi JOIN products p ON p.id = oi.product_id
            WHERE oi.order_id = ANY($1::uuid[])`,
          [orderIds],
        )
      : null,
  ])

  const customerById = new Map(customers?.rows.map((c) => [c.id, c]))
  res.json({
    data: orders.map((o) => ({
      id: o.id,
      status: o.status,
      totalAmount: Number(o.total_amount),
      currency: o.currency,
      createdAt: o.created_at,
      customer: customers ? customerById.get(o.customer_id) : undefined,
      items: items?.rows.filter((i) => i.order_id === o.id).map(({ order_id, ...rest }) => rest),
    })),
  })
})

Kết quả là 1 request từ client và 3 câu SQL ở server. Cái giá: mỗi quan hệ phải tự code tay, và khi có 15 màn hình với 15 tổ hợp include/fields, bạn đang tự viết lại một phần của GraphQL. Quy ước chung cho pagination, lỗi và versioning của REST có trong bài thiết kế REST API dễ dùng, khó dùng sai.

Cách làm GraphQL: schema, resolver và query theo từng client

# graphql/src/schema.graphql
type Query {
  orders(status: OrderStatus, first: Int = 20, after: String): OrderConnection!
  order(id: ID!): Order
}

enum OrderStatus { PENDING PAID CANCELLED }

type OrderConnection { edges: [OrderEdge!]!, pageInfo: PageInfo! }
type OrderEdge { cursor: String!, node: Order! }
type PageInfo { hasNextPage: Boolean!, endCursor: String }

type Order {
  id: ID!
  status: OrderStatus!
  totalAmount: String! # bigint dạng chuỗi, Int của GraphQL chỉ có 32-bit
  currency: String!
  createdAt: String!
  customer: Customer!
  items: [OrderItem!]!
}

type Customer { id: ID!, name: String!, email: String!, phone: String }
type OrderItem { quantity: Int!, unitPrice: String!, product: Product! }
type Product { id: ID!, sku: String!, name: String! }

Một chi tiết hay bị bỏ qua: Int trong GraphQL là số nguyên có dấu 32-bit, tối đa khoảng 2,1 tỷ. Một đơn B2B 3 tỷ đồng sẽ làm serializer ném lỗi. Tiền nên dùng String hoặc một custom scalar, đừng dùng Int hay Float.

Mỗi client giờ tự viết query với đúng field nó cần:

# Web dashboard
query DashboardOrders($after: String) {
  orders(status: PAID, first: 20, after: $after) {
    edges {
      node {
        id status totalAmount createdAt
        customer { name email }
        items { quantity product { name } }
      }
    }
    pageInfo { hasNextPage endCursor }
  }
}

# Mobile shipper
query ShipperOrders {
  orders(first: 20) { edges { node { id status totalAmount customer { name } } } }
}

Một endpoint, hai shape response, và không phải viết thêm endpoint nào. Đây là lợi thế thật của GraphQL: client chọn shape, server chỉ định nghĩa graph. Câu hỏi còn lại là server tốn bao nhiêu để trả lời.

N+1 trong resolver và DataLoader

Resolver viết theo trực giác:

// Ngây thơ: mỗi Order gọi một câu SQL lấy customer, một câu lấy items
const naiveResolvers = {
  Order: {
    customer: (o: { customer_id: string }) =>
      pool.query('SELECT * FROM customers WHERE id = $1', [o.customer_id]).then((r) => r.rows[0]),
    items: (o: { id: string }) =>
      pool.query('SELECT * FROM order_items WHERE order_id = $1', [o.id]).then((r) => r.rows),
  },
}

Query dashboard với 20 đơn, mỗi đơn trung bình 3 item, sẽ chạy 1 (orders) + 20 (customers) + 20 (items) + 60 (products) = 101 câu SQL. GraphQL không xóa N+1, nó chỉ dời N+1 từ client về server, nơi bạn khó nhìn thấy hơn.

DataLoader (thư viện gốc của GraphQL Foundation) gom mọi lệnh .load(id) trong cùng một tick của event loop thành một lần gọi batch:

// graphql/src/loaders.ts
import DataLoader from 'dataloader'
import { pool } from './db.js'

type CustomerRow = { id: string; name: string; email: string; phone: string | null }
type ItemRow = { id: string; order_id: string; product_id: string; quantity: number; unit_price: string }
type ProductRow = { id: string; sku: string; name: string }

// Batch function PHẢI trả mảng cùng độ dài, cùng thứ tự với keys
function byKey<T, K>(keys: readonly K[], rows: T[], keyOf: (row: T) => K): (T | null)[] {
  const map = new Map(rows.map((r) => [keyOf(r), r]))
  return keys.map((k) => map.get(k) ?? null)
}

export function createLoaders() {
  return {
    customerById: new DataLoader<string, CustomerRow | null>(async (ids) => {
      const { rows } = await pool.query<CustomerRow>(
        'SELECT id, name, email, phone FROM customers WHERE id = ANY($1::uuid[])', [[...ids]])
      return byKey(ids, rows, (r) => r.id)
    }),
    itemsByOrderId: new DataLoader<string, ItemRow[]>(async (orderIds) => {
      const { rows } = await pool.query<ItemRow>(
        'SELECT id, order_id, product_id, quantity, unit_price::text FROM order_items WHERE order_id = ANY($1::uuid[]) ORDER BY id',
        [[...orderIds]])
      const groups = new Map<string, ItemRow[]>()
      for (const r of rows) groups.set(r.order_id, [...(groups.get(r.order_id) ?? []), r])
      return orderIds.map((id) => groups.get(id) ?? [])
    }),
    productById: new DataLoader<string, ProductRow | null>(async (ids) => {
      const { rows } = await pool.query<ProductRow>(
        'SELECT id, sku, name FROM products WHERE id = ANY($1::uuid[])', [[...ids]])
      return byKey(ids, rows, (r) => r.id)
    }),
  }
}
export type Loaders = ReturnType<typeof createLoaders>
// graphql/src/resolvers.ts
import { GraphQLError } from 'graphql'
import { pool } from './db.js'
import type { GqlContext } from './server.js'

type OrderRow = { id: string; customer_id: string; status: string; total_amount: string; currency: string; created_at: Date }

const encode = (o: OrderRow) => Buffer.from(JSON.stringify([o.created_at.toISOString(), o.id])).toString('base64url')

export const resolvers = {
  OrderStatus: { PENDING: 'pending', PAID: 'paid', CANCELLED: 'cancelled' }, // enum GraphQL ↔ giá trị DB
  Query: {
    orders: async (_: unknown, args: { status?: string; first: number; after?: string }, ctx: GqlContext) => {
      if (!ctx.viewer) throw new GraphQLError('Cần đăng nhập', { extensions: { code: 'UNAUTHENTICATED' } })
      const first = Math.min(args.first, 100)
      const [afterTs, afterId] = args.after ? JSON.parse(Buffer.from(args.after, 'base64url').toString()) : [null, null]
      const { rows } = await pool.query<OrderRow>(
        `SELECT id, customer_id, status, total_amount::text, currency, created_at FROM orders
          WHERE ($1::text IS NULL OR status = $1)
            AND ($2::timestamptz IS NULL OR (created_at, id) < ($2::timestamptz, $3::uuid))
          ORDER BY created_at DESC, id DESC LIMIT $4`,
        [args.status ?? null, afterTs, afterId, first + 1],
      )
      const page = rows.slice(0, first)
      return {
        edges: page.map((o) => ({ cursor: encode(o), node: o })),
        pageInfo: { hasNextPage: rows.length > first, endCursor: page.length ? encode(page.at(-1)!) : null },
      }
    },
  },
  Order: {
    totalAmount: (o: OrderRow) => o.total_amount,
    createdAt: (o: OrderRow) => o.created_at.toISOString(),
    customer: (o: OrderRow, _: unknown, ctx: GqlContext) => ctx.loaders.customerById.load(o.customer_id),
    items: (o: OrderRow, _: unknown, ctx: GqlContext) => ctx.loaders.itemsByOrderId.load(o.id),
  },
  OrderItem: {
    unitPrice: (i: { unit_price: string }) => i.unit_price,
    product: (i: { product_id: string }, _: unknown, ctx: GqlContext) => ctx.loaders.productById.load(i.product_id),
  },
}

Cùng query đó giờ chỉ chạy 4 câu SQL: orders, customers, order_items, products. Mỗi tầng của graph một câu, bất kể có bao nhiêu đơn.

Ba quy tắc với DataLoader mà team nào cũng vấp ít nhất một lần:

  • Tạo loader mới cho mỗi request (trong context), không bao giờ dùng singleton. DataLoader cache kết quả theo key. Dùng chung giữa các request nghĩa là user B nhận dữ liệu user A đã load, và dữ liệu cache không bao giờ được làm mới.
  • Batch function phải trả đúng thứ tự và đúng số lượng như keys. SQL WHERE id = ANY(...) không đảm bảo thứ tự, nên phải map lại như hàm byKey. Key không tìm thấy thì trả null (hoặc một Error cho riêng key đó), đừng bỏ qua.
  • Phân quyền vẫn thuộc về resolver, không phải loader. Loader chỉ batch, nó không biết viewer có được xem customer đó hay không.

Có một ca DataLoader không tự giải được: danh sách con có giới hạn theo từng parent, ví dụ items(first: 3) cho mỗi đơn. Một câu LIMIT 3 chung cho cả batch sẽ chỉ trả 3 item cho toàn bộ 20 đơn. Phải giới hạn theo từng nhóm bằng window function, và key của loader phải gồm cả argument (${orderId}:${first}), nếu không hai field cùng order với first khác nhau sẽ dùng nhầm kết quả của nhau:

SELECT * FROM (
  SELECT oi.*, ROW_NUMBER() OVER (PARTITION BY oi.order_id ORDER BY oi.id) AS rn
    FROM order_items oi
   WHERE oi.order_id = ANY($1::uuid[])
) t
WHERE t.rn <= $2;

Over-fetching và under-fetching: đếm thật trên dashboard

Tổng hợp cho màn hình 20 đơn, trung bình 3 item mỗi đơn. Các con số dưới đây là phép đếm từ code ở trên, không phải benchmark:

Cách làm HTTP request từ client Câu SQL ở server Over-fetching Chi phí bảo trì
REST thuần resource 41 (2 đợt nối tiếp) 41 Cao: customer trả đủ mọi field Thấp lúc đầu, client phức tạp
REST + include/fields 1 3 Thấp nếu có sparse fieldsets Mỗi quan hệ code tay
REST BFF endpoint 1 3 Không có Mỗi màn hình một endpoint
GraphQL, không DataLoader 1 101 Không có N+1 ẩn ở server
GraphQL + DataLoader 1 4 Không có Phải viết loader, quản lý schema

Đọc bảng này theo hướng: GraphQL thắng khi số lượng tổ hợp client × màn hình lớn. Nếu bạn chỉ có một web app và ba màn hình, endpoint BFF hoặc include cho ra kết quả tương đương với ít thành phần hơn.

Caching: HTTP cache của REST vs persisted queries và normalized cache

REST: HTTP làm hộ phần lớn

GET /v1/products/{id} có URL làm cache key tự nhiên. Thêm Cache-Control: public, max-age=300 và ETag là browser, CDN và reverse proxy đều cache được, không cần thêm dòng code nào ở client. Chi tiết về stale-while-revalidate và CDN có trong bài HTTP caching từ A tới Z.

GraphQL: POST không cache được, nên phải biến thành GET

Mặc định GraphQL gửi POST /graphql với query trong body, và CDN không cache POST. Automatic Persisted Queries (APQ), mà Apollo Server bật sẵn với cache in-memory, giải quyết việc này: client gửi SHA-256 của query thay cho cả câu query, qua GET:

# Lần đầu: server chưa biết hash, nên trả lỗi PERSISTED_QUERY_NOT_FOUND
curl -G 'https://api.acme.vn/graphql' \
  -H 'apollo-require-preflight: true' \
  --data-urlencode 'extensions={"persistedQuery":{"version":1,"sha256Hash":"<sha256 của query>"}}' \
  --data-urlencode 'variables={"first":20}'
# {"errors":[{"message":"PersistedQueryNotFound","extensions":{"code":"PERSISTED_QUERY_NOT_FOUND"}}]}

# Client gửi lại một lần kèm cả "query" và hash. Từ đó, URL GET ở trên cache được ở CDN.

Header apollo-require-preflight có ở đó vì CSRF prevention của Apollo Server (bật mặc định) chặn GET "simple request" không có header đặc biệt. Quên header này là request bị từ chối với 400.

APQ là tối ưu băng thông và cache, không phải bảo mật: client nào cũng đăng ký được query mới. Bản dùng cho bảo mật là persisted documents (còn gọi là trusted documents). Bạn trích toàn bộ query từ code client lúc build (GraphQL Codegen client preset có option persistedDocuments), đẩy danh sách đó lên server, và server chỉ chạy query có trong allowlist.

Khi query đã đi qua GET, server còn phải nói cho CDN biết nó được cache bao lâu. Apollo Server tính header Cache-Control từ cache hint của từng field, lấy maxAge nhỏ nhất trong cả response. Bạn khai báo hint bằng directive trong schema:

enum CacheControlScope { PUBLIC PRIVATE }
directive @cacheControl(maxAge: Int, scope: CacheControlScope, inheritMaxAge: Boolean)
  on FIELD_DEFINITION | OBJECT | INTERFACE | UNION

type Product @cacheControl(maxAge: 300) { id: ID!, sku: String!, name: String! }
type Customer @cacheControl(maxAge: 0, scope: PRIVATE) { id: ID!, name: String!, email: String!, phone: String }

Chỉ cần một field maxAge: 0 trong response là cả response không được cache. Đây là hành vi đúng nhưng hay gây bất ngờ: thêm customer { name } vào query catalog là hit rate CDN về 0. Với REST, mỗi URL có policy riêng nên chuyện này dễ nhìn thấy hơn.

Thực tế quan trọng hơn: dashboard của mình là dữ liệu private theo user. CDN không nên cache nó dù là REST hay GraphQL. Với dữ liệu private, phần cache có giá trị nằm ở client.

Normalized cache ở client

Apollo Client (InMemoryCache), urql Graphcache và Relay lưu entity theo __typename:id, ví dụ Order:7f3e... hay Customer:0b9d.... Khi mutation markOrderShipped trả về { id status }, mọi danh sách và màn hình chi tiết đang hiển thị đơn đó tự cập nhật mà không phải refetch. Với REST, TanStack Query cache theo URL/query key, nên sau mutation bạn phải tự invalidateQueries đúng các key liên quan.

Cái giá của normalized cache: mọi selection phải lấy id, nếu không cache không chuẩn hóa được. Thêm hoặc bớt phần tử trong danh sách phân trang cần viết typePolicies/merge function. Đây là phần mà người mới học GraphQL tốn thời gian nhất.

Error, nullability và schema evolution

GraphQL trả 200 kèm mảng errors

// Trong resolver Query.order
throw new GraphQLError('Không có quyền xem đơn của chi nhánh khác', {
  extensions: { code: 'FORBIDDEN' },
})
{
  "data": { "order": null },
  "errors": [
    {
      "message": "Không có quyền xem đơn của chi nhánh khác",
      "path": ["order"],
      "extensions": { "code": "FORBIDDEN" }
    }
  ]
}

Response này thường đi với HTTP 200: data một phần, errors một phần. Hệ quả vận hành: dashboard đếm tỷ lệ 5xx sẽ xanh trong khi một nửa query đang lỗi. Bạn phải đo error rate theo operationName và extensions.code, qua plugin Apollo hoặc OpenTelemetry. Lỗi ở cấp request (query sai cú pháp, không qua validation) thì khác: spec GraphQL over HTTP với media type application/graphql-response+json cho phép trả 4xx, và từ Apollo Server 5 lỗi coerce variable mặc định trả 400.

Nullability quyết định "bán kính nổ" của lỗi. Một field non-null (customer: Customer!) lỗi sẽ khiến null lan lên parent nullable gần nhất. Trong schema ở trên, edges: [OrderEdge!]! và node: Order! đều non-null. Vì vậy chỉ một customer bị xóa cũng làm orders thành null, rồi lan tiếp lên data: cả dashboard trắng trơn vì một dòng dữ liệu. Quy tắc của mình: field trỏ sang service hoặc bảng khác mà có thể vắng thì để nullable.

Với lỗi nghiệp vụ mà client cần xử lý (hủy đơn đã giao), mình dùng "errors as data" thay vì errors:

type Mutation {
  cancelOrder(id: ID!, reason: String!): CancelOrderResult!
}
union CancelOrderResult = Order | OrderNotCancellable
type OrderNotCancellable { message: String!, currentStatus: OrderStatus! }

Client buộc phải xử lý cả hai nhánh qua __typename, và TypeScript sinh từ codegen sẽ nhắc nếu bạn quên nhánh nào.

Schema evolution: @deprecated thay cho /v2

type Order {
  totalAmount: String! @deprecated(reason: "Dùng total { amount currency }; xóa sau 2027-03-01")
  total: Money!
}
type Money { amount: String!, currency: String! }

Vì client chỉ nhận field nó xin, thêm field mới không ảnh hưởng ai. Field cũ được đánh dấu @deprecated, và IDE cùng codegen sẽ cảnh báo người dùng. Nhờ usage reporting (Apollo GraphOS, GraphQL Hive) bạn biết chính xác client nào còn query totalAmount trước khi xóa nó. REST làm được điều tương tự bằng Deprecation + Sunset header và log, nhưng ở mức endpoint, không phải mức field.

Chặn breaking change ngay trong CI:

npx @graphql-inspector/cli diff 'git:origin/main:graphql/src/schema.graphql' graphql/src/schema.graphql
# Báo lỗi (exit code khác 0) nếu xóa field, đổi type, hoặc thêm argument bắt buộc

Bảo mật: depth, cost, introspection và rate limit theo cost

Một endpoint GraphQL cho phép client tự viết query, tức là cho phép client tự quyết định server tốn bao nhiêu CPU. Nếu schema có vòng (Customer.orders → Order.customer), query sau chỉ dài vài dòng nhưng có thể nổ thành hàng triệu node:

query Expensive {
  orders(first: 100) { edges { node {
    customer { orders(first: 100) { customer { orders(first: 100) { customer { name } } } } }
  } } }
}

Thêm vào đó là alias (a1: order(id: 1) a2: order(id: 2) ... lặp hàng nghìn lần) và batching nhiều operation trong một request. Rate limit theo số request hoàn toàn bất lực với kiểu tấn công này, vì đây chỉ là một request. Apollo Server tắt HTTP batching theo mặc định (allowBatchedHttpRequests: false). Nếu bật nó cho client cũ, hãy nhớ cost phải tính trên tổng các operation trong batch.

Lớp phòng thủ đầu tiên là validation rule giới hạn depth, cost, alias và token, dùng GraphQL Armor:

// graphql/src/server.ts
import { readFileSync } from 'node:fs'
import { ApolloServer } from '@apollo/server'
import { startStandaloneServer } from '@apollo/server/standalone'
import { ApolloArmor } from '@escape.tech/graphql-armor'
import { authenticate } from './auth.js' // đọc Bearer token, trả viewer hoặc null
import { costRateLimitPlugin } from './cost-rate-limit.js'
import { createLoaders, type Loaders } from './loaders.js'
import { resolvers } from './resolvers.js'

export type GqlContext = { viewer: { id: string; role: 'staff' | 'admin' } | null; ip: string; loaders: Loaders }

const typeDefs = readFileSync(new URL('./schema.graphql', import.meta.url), 'utf8')

const armor = new ApolloArmor({
  maxDepth: { n: 6 }, // mặc định cũng là 6
  costLimit: { maxCost: 3000 }, // mặc định 5000; hạ xuống theo query thật lớn nhất của bạn
  maxAliases: { n: 10 },
  maxTokens: { n: 2000 },
  blockFieldSuggestion: { enabled: true }, // không gợi ý "Did you mean ...?" (lộ tên field)
})
const protection = armor.protect()

const server = new ApolloServer<GqlContext>({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV !== 'production',
  ...protection,
  plugins: [...protection.plugins, costRateLimitPlugin],
  validationRules: [...protection.validationRules],
})

const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
  context: async ({ req }) => ({
    viewer: await authenticate(req.headers.authorization),
    ip: req.socket.remoteAddress ?? 'unknown',
    loaders: createLoaders(), // mới cho mỗi request
  }),
})
console.log(`GraphQL ready at ${url}`)

Về introspection: tắt ở production giúp giảm việc bị dò schema, nhưng không phải biện pháp bảo mật. Error message gợi ý field và chính bundle JavaScript của client đều lộ schema. Biện pháp thật cho API first-party là persisted documents (chỉ chạy query có trong allowlist), cộng với phân quyền ở từng resolver.

Lớp thứ hai là rate limit theo cost thay vì theo số request. GitHub GraphQL API là ví dụ công khai: rate limit tính theo điểm (point) dựa trên số node mà query yêu cầu, không theo số lần gọi. Plugin dưới đây tính cost trước khi execute, rồi trừ vào ngân sách theo phút của từng user trong Redis:

// graphql/src/cost-rate-limit.ts
import type { ApolloServerPlugin } from '@apollo/server'
import { GraphQLError } from 'graphql'
import { getComplexity, simpleEstimator, type ComplexityEstimator } from 'graphql-query-complexity'
import { Redis } from 'ioredis'
import type { GqlContext } from './server.js'

const redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379')
const BUDGET_PER_MINUTE = 20_000

// Field có argument `first` thì nhân cost của con với số phần tử
const listEstimator: ComplexityEstimator = ({ args, childComplexity }) =>
  typeof args.first === 'number' ? 1 + args.first * childComplexity : undefined

export const costRateLimitPlugin: ApolloServerPlugin<GqlContext> = {
  async requestDidStart() {
    return {
      async didResolveOperation({ request, document, schema, contextValue }) {
        const cost = getComplexity({
          schema,
          query: document,
          variables: request.variables,
          operationName: request.operationName ?? undefined,
          estimators: [listEstimator, simpleEstimator({ defaultComplexity: 1 })],
        })
        const who = contextValue.viewer?.id ?? `ip:${contextValue.ip}`
        const key = `gql-cost:${who}:${Math.floor(Date.now() / 60_000)}`
        const used = await redis.incrby(key, cost)
        if (used === cost) await redis.expire(key, 120)
        if (used > BUDGET_PER_MINUTE) {
          throw new GraphQLError('Vượt ngân sách query cost trong phút này', {
            extensions: { code: 'RATE_LIMITED', cost, http: { status: 429, headers: new Map([['retry-after', '60']]) } },
          })
        }
      },
    }
  },
}

Một số lưu ý về plugin này. extensions.http là cách Apollo Server 4+ cho phép set status và header từ GraphQLError, nên client nhận đúng 429. Cost tính bằng estimator chỉ là ước lượng tĩnh. Nếu muốn chính xác hơn, ghi lại thời gian thực thi theo operationName rồi chỉnh trọng số. Còn đặt BUDGET_PER_MINUTE thế nào: đo cost của các query thật mà client của bạn gửi, rồi cho dư vài lần.

REST cũng cần rate limit, nhưng mỗi endpoint có chi phí gần như cố định, nên giới hạn theo số request là đủ.

Tooling: codegen, OpenAPI, BFF và tRPC

GraphQL Codegen sinh type cho từng query từ schema:

// web/codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli'

const config: CodegenConfig = {
  schema: '../graphql/src/schema.graphql',
  documents: ['src/**/*.{ts,tsx}'],
  generates: {
    './src/gql/': { preset: 'client', presetConfig: { persistedDocuments: true } },
  },
}
export default config
// web/src/dashboard-query.ts
import { graphql } from './gql'

export const DashboardOrders = graphql(`
  query DashboardOrders($after: String) {
    orders(status: PAID, first: 20, after: $after) {
      edges { node { id status totalAmount customer { name email } } }
      pageInfo { hasNextPage endCursor }
    }
  }
`)
// Kết quả query có type chính xác theo selection: node.customer.email là string, node.items không tồn tại

REST + OpenAPI cho trải nghiệm tương đương ở mức endpoint:

// npx openapi-typescript ./openapi.yaml -o src/api/schema.d.ts
import createClient from 'openapi-fetch'
import type { paths } from './api/schema'

const api = createClient<paths>({ baseUrl: 'https://api.acme.vn' })
const { data, error } = await api.GET('/v1/orders', {
  params: { query: { status: 'paid', include: 'customer,items' } },
})
// data và error có type từ OpenAPI; gõ sai tên query param là lỗi compile

Khác biệt cốt lõi: OpenAPI cho type theo endpoint (response luôn đủ field), còn GraphQL codegen cho type theo selection. Nếu client của bạn không viết bằng TypeScript (Swift, Kotlin), cả hai hệ đều có generator, và đây là lợi thế lớn so với tRPC.

BFF pattern: mỗi trải nghiệm frontend có một backend riêng để ghép dữ liệu từ các service phía sau. BFF có thể là REST (endpoint /bff/web/dashboard-orders) hoặc chính là GraphQL. Thực tế, rất nhiều GraphQL server trong production là một BFF đứng trước các REST/gRPC service nội bộ. Điểm mấu chốt là BFF thuộc về team frontend, không phải team domain.

tRPC là lựa chọn thứ ba khi cả client lẫn server là TypeScript trong cùng một monorepo (ví dụ Next.js). Không có schema riêng, không có codegen, type của procedure chảy thẳng sang client. Đổi lại, nó không dành cho public API hay client không phải TypeScript. Chi tiết ở bài tRPC với Next.js App Router.

Bảng quyết định và cách kết hợp

Tiêu chí REST (+ OpenAPI) GraphQL tRPC
Public API cho bên thứ ba Tốt nhất: ai cũng biết dùng, curl được Được, nhưng phải đầu tư cost limit và tài liệu Không phù hợp
Nhiều client với nhu cầu dữ liệu khác nhau Phải làm include/BFF cho từng client Tốt nhất Được nếu tất cả là TypeScript
CDN/HTTP caching cho dữ liệu public Miễn phí Cần APQ/persisted + GET Có thể cache query GET, ít dùng
Upload file, streaming, webhook Tự nhiên Vụng (multipart spec riêng) Có hỗ trợ FormData từ v11
Client không viết bằng TypeScript OpenAPI generator GraphQL codegen Không
Độ phức tạp vận hành Thấp Cao: N+1, cost, monitoring theo operation Thấp
Evolution Version/Sunset theo endpoint @deprecated theo field, usage tracking Phải deploy client và server khớp nhau

Quy tắc mình dùng:

  • Chọn REST khi API là sản phẩm cho bên ngoài, khi chủ yếu là CRUD trên vài resource, khi cần cache CDN, webhook hoặc upload file, hoặc khi team chưa ai vận hành GraphQL production.
  • Chọn GraphQL khi có từ ba client trở lên với nhu cầu dữ liệu khác nhau trên cùng một domain graph, khi màn hình ghép dữ liệu từ nhiều nguồn, và khi team sẵn sàng đầu tư DataLoader, cost limit, persisted documents và monitoring theo operation.
  • Chọn tRPC khi frontend và backend là TypeScript trong cùng repo, cùng một team, và API không có consumer bên ngoài.

Kiến trúc hybrid mình hay gặp nhất và cũng hay đề xuất nhất:

                 ┌──────────────── Public REST API (/v1, OpenAPI) ──── partner, webhook, upload
Client bên ngoài ┘
Web + mobile ─────── GraphQL BFF (persisted documents) ──┬── orders-service (REST nội bộ)
                                                         ├── catalog-service (REST, cache CDN)
                                                         └── payments-service (REST)
Admin Next.js ────── tRPC (cùng monorepo với admin)

Mỗi giao thức nằm ở đúng chỗ mạnh nhất của nó. Service domain giữ REST đơn giản, dễ cache và dễ test. GraphQL chỉ nằm ở lớp ghép dữ liệu cho UI, nơi lợi thế "client chọn shape" thật sự có giá trị.

Nếu bạn đang có REST và muốn thử GraphQL, đừng viết lại. Mình làm theo thứ tự sau:

  1. Dựng GraphQL BFF đứng trước REST hiện có. Resolver gọi REST qua DataLoader, và batch function gọi một endpoint batch kiểu GET /v1/customers?ids=a,b,c. Nếu chưa có endpoint đó thì thêm vào.
  2. Chỉ chuyển một màn hình ghép dữ liệu nặng nhất sang trước, chỉ query read-only, chưa đụng tới mutation.
  3. Đo trước và sau: số request từ client, p95 latency của màn hình, số câu SQL mỗi operation.
  4. Chỉ mở rộng tiếp khi con số tốt lên rõ ràng và team đã có cost limit cùng monitoring theo operation.

Làm như vậy, quyết định "có nên dùng GraphQL" dựa trên số đo trong hệ thống của chính bạn, không phải trên một bài so sánh chung chung, kể cả bài này.

Những lỗi thường gặp

DataLoader singleton dùng chung giữa các request.

// Sai: cache sống mãi, dữ liệu của user này lọt sang user khác
const loaders = createLoaders()
context: async () => ({ loaders })
// Đúng
context: async ({ req }) => ({ loaders: createLoaders(), viewer: await authenticate(req.headers.authorization) })

Expose bảng DB 1:1 thành type GraphQL. Schema bị dính chặt vào DB: đổi tên cột là breaking change, và field nội bộ (password_hash, deleted_at) chỉ cách một dòng type User là bị lộ. Hãy thiết kế schema theo nhu cầu UI và map ở resolver.

Non-null ở mọi nơi. customer: Customer! trong khi customer nằm ở service khác. Service đó timeout là cả danh sách đơn biến mất. Field qua ranh giới service nên để nullable.

Coi HTTP 200 là thành công.

// Sai: alert theo tỷ lệ 5xx của /graphql
// Đúng: plugin ghi metric theo operation
willSendResponse: async ({ response, request }) => {
  if (response.body.kind === 'single' && response.body.singleResult.errors?.length) {
    metrics.increment('graphql_errors_total', { operation: request.operationName ?? 'anonymous' })
  }
}

Tiền dùng Int hoặc Float. Int tràn ở khoảng 2,1 tỷ, Float làm tròn sai. Dùng String hoặc custom scalar Decimal.

Mỗi màn hình một endpoint REST nhưng không ai sở hữu. /dashboard-v2, /dashboard-v3-final mọc ra vì team backend không muốn sửa endpoint cũ. Nếu đi đường BFF, hãy giao BFF cho team frontend, hoặc chuyển sang GraphQL.

GraphQL không giới hạn first. orders(first: 1000000) vượt qua mọi giới hạn depth. Luôn kẹp Math.min(args.first, 100) trong resolver và tính nó vào cost.

Checklist

  • Viết ra danh sách client và màn hình thật trước khi chọn giao thức. Ít client và ít tổ hợp thì REST + include/BFF thường là đủ.
  • REST: có include hoặc endpoint BFF cho màn hình ghép dữ liệu, mỗi quan hệ chỉ tốn một câu WHERE id = ANY($1).
  • GraphQL: mọi field quan hệ đi qua DataLoader tạo mới mỗi request. Bật log SQL và đếm số câu cho query lớn nhất.
  • GraphQL: bật maxDepth, costLimit, maxAliases, chặn field suggestion. Kẹp first trong resolver.
  • GraphQL: rate limit theo cost (Redis), không chỉ theo số request. Dùng persisted documents cho client first-party.
  • Monitoring theo operationName và extensions.code, không chỉ theo HTTP status.
  • Rà nullability: field qua ranh giới service để nullable. Tiền không dùng Int/Float.
  • CI chạy graphql-inspector diff (GraphQL) hoặc lint OpenAPI (REST) để chặn breaking change.
  • Dữ liệu public cache CDN qua REST GET hoặc APQ GET. Dữ liệu private dựa vào cache ở client.

Đọc thêm

Last updated: November 2, 2026

Comments