GraphQL là gì thật sự — bài toán gốc, mô hình tư duy, và khi nào KHÔNG nên dùng

JD ghi "có kinh nghiệm GraphQL". Người chưa dùng bao giờ thường học cú pháp trước: query, mutation, dấu ngoặc nhọn. Nhưng interviewer ở mức Senior/Tech Lead hiếm khi hỏi cú pháp. Họ hỏi: nó sinh ra để giải bài toán gì, nó bắt mình trả giá gì, và khi nào anh sẽ không chọn nó. Bài này dựng nền đó. Bốn bài sau đi sâu từng mảng: cách một query chạy, N+1 và DataLoader, bảo mật và giới hạn chi phí, vận hành, tiến hoá schema, federation.

Thuật ngữ dùng trong bài
schema (bản mô tả mọi kiểu dữ liệu và thao tác mà API cho phép — "hợp đồng" giữa client và server) · SDL (Schema Definition Language — cú pháp viết schema, vd type User { name: String! }) · type / field (kiểu dữ liệu / một thuộc tính của kiểu) · query / mutation / subscription (thao tác đọc / ghi / đăng ký nhận sự kiện server đẩy về) · operation (một query, mutation hay subscription client gửi lên) · selection set (danh sách field client chọn trong cặp { }) · resolver (hàm phía server lấy dữ liệu cho đúng một field) · variables (giá trị truyền riêng, không nối vào chuỗi query) · fragment (một nhóm field có tên, dùng lại nhiều chỗ) · alias (đặt tên khác cho field trong response, vd a: user(id: 1)) · introspection (query đặc biệt __schema để hỏi server "schema của anh là gì") · over-fetching / under-fetching (nhận thừa dữ liệu / thiếu dữ liệu nên phải gọi thêm) · round trip (một lượt gửi request rồi chờ response qua mạng) · BFF (Backend For Frontend — backend riêng, mỗi endpoint trả đúng dữ liệu cho một màn hình) · N+1 (1 câu lấy danh sách, rồi N câu lấy dữ liệu con cho từng phần tử).
Kiểm chứng: tài liệu lấy qua Context7: GraphQL spec (/graphql/graphql-spec — Section 6 Execution, Section 7 Response), graphql.org (/graphql/graphql.github.io — learn, FAQ best practices, serving over HTTP), graphql-js 17 (/graphql/graphql-js). Lịch sử: graphql.org, blog Postman "What is GraphQL? Part 1: The Facebook Years". Mọi output trong bài là output thật chạy bằng graphql-js 17.0.2 trên Node 22.23.3 (container node:22-bookworm-slim), code ở labs/graphql/node/. Phần chưa verify được ghi rõ tại chỗ.

Trả lời 30 giây (khi interviewer hỏi "GraphQL là gì, khác REST thế nào")

GraphQL là một ngôn ngữ truy vấn cho API cộng với một runtime thực thi. Server công bố một schema có kiểu — đó là hợp đồng. Client gửi một query dạng cây, chọn đúng những field nó cần. Server đi qua cây đó, gọi một resolver cho mỗi field, và trả JSON có đúng hình dạng của query. Facebook làm ra nó năm 2012 cho app iOS mới của News Feed: để một màn hình chỉ cần một lượt mạng, và để không phải version lại API mỗi khi app đổi giao diện.
Cái giá: HTTP cache gần như mất vì mọi thứ đi qua POST /graphql; N+1 thành mặc định vì mỗi field một resolver; client tự quyết chi phí của query nên server phải giới hạn độ sâu và chi phí; lỗi trả về kèm HTTP 200 nên monitoring theo status code bị mù.
Tôi chọn nó khi có nhiều loại client (web, mobile, đối tác) cần những lát cắt khác nhau của cùng một đồ thị dữ liệu. Tôi tránh nó khi chỉ có một client, khi là gọi nội bộ giữa các service (gRPC hoặc REST hợp hơn), hoặc khi cache HTTP/CDN là yêu cầu chính.

1. Bài toán gốc và bối cảnh ra đời

Năm 2012, Facebook viết lại app iOS từ HTML5 sang native. Màn hình chính là News Feed: một story có người đăng, có ảnh, có comment, mỗi comment có người viết, có lượt like... Dữ liệu có dạng đồ thị (các object trỏ sang nhau), còn API lúc đó là các endpoint REST và FQL trả về những khối cố định. Nick Schrock, Dan Schafer và Lee Byron dựng một API mới cho News Feed. Bản thử đầu tiên có từ tháng 2/2012, và chạy trong app iOS từ khoảng tháng 8/2012. Facebook mở mã nguồn spec cùng bản cài đặt tham chiếu graphql-js vào 2015. Cuối 2018 Linux Foundation công bố GraphQL Foundation, và từ 2019 foundation này là "nhà" trung lập của spec.

Ba giới hạn của cách làm trước đó (REST theo resource) là lý do GraphQL ra đời:

  1. Over-fetching: endpoint trả hình dạng cố định, màn hình chỉ cần vài field.
  2. Under-fetching → nhiều lượt mạng nối tiếp: phải có đơn hàng mới biết customer_id để gọi tiếp. Trên mạng di động, mỗi lượt tốn hàng trăm ms.
  3. Version API: app mobile bản cũ nằm trên máy người dùng hàng năm trời, không ép cập nhật được. Đổi hình dạng endpoint là làm vỡ chúng, nên team phải giữ /v1, /v2, /v3.

Lab 01 tái hiện đúng bài toán đó: cùng một màn hình "chi tiết đơn hàng" (trạng thái, tổng tiền, tên khách, danh sách sản phẩm kèm ảnh nhỏ), lấy bằng 3 cách. RTT mạng là giả lập 100ms mỗi request (số minh hoạ); số request, số lượt và số byte là đo thật.

Dòng thời gian lấy dữ liệu một màn hình bằng REST và bằng GraphQL REST phải gọi GET /orders/42 trước, chờ 100ms, rồi mới gọi song song GET /customers/7 và GET /products, xong ở khoảng 209ms, nhận 7096 byte. GraphQL gửi một POST /graphql, xong ở khoảng 106ms, nhận 443 byte nhưng gửi lên 201 byte chuỗi query. Cùng 1 màn hình "chi tiết đơn hàng" — RTT giả lập 100ms/request 0 50 100 150 200 250 ms REST 3 resource GET /orders/42 GET /customers/7 GET /products?ids=1,2,3 phải chờ customer_id xong 209ms 3 request · 2 lượt nối tiếp · nhận 7 096 byte (thừa ~16 lần) GraphQL 1 query POST /graphql xong 106ms 1 request · 1 lượt · nhận 443 byte · nhưng gửi lên 201 byte chuỗi query
Hình 1 — Số liệu từ lab 01. REST mất 2 lượt nối tiếp vì phải có đơn hàng mới biết gọi khách nào, sản phẩm nào; và nhận về 7 096 byte trong khi màn hình chỉ dùng chừng 440 byte. GraphQL gom thành 1 lượt và chỉ nhận đúng field đã chọn. Đổi lại, chuỗi query phải gửi lên mỗi lần (201 byte) — vấn đề mà persisted query ở bài 5 giải quyết.
$ ./run.sh 01-rest-vs-graphql.mjs
RTT giả lập = 100ms/request. Mỗi cách chạy 9 lần, lấy lần nhanh nhất (máy dùng chung, số chậm là nhiễu).

cách lấy dữ liệu             request lượt nối tiếp byte gửi byte nhận thời gian
REST (3 resource)                  3             2       41      7096     209ms
BFF (1 endpoint/màn hình)          1             1       20       422     103ms
GraphQL (1 query)                  1             1      201       443     106ms

Đọc kỹ dòng BFF. Một endpoint viết riêng cho màn hình (/bff/order-screen/42) cũng đạt 1 lượt và 422 byte — ngang GraphQL. Nghĩa là về mặt mạng, GraphQL không thắng một BFF viết tốt. Cái GraphQL thật sự đổi là ai phải viết endpoint cho từng màn hình: với BFF, mỗi màn hình mới (hoặc mỗi field mới) là một ticket cho backend; với GraphQL, frontend tự chọn field trên schema có sẵn. Đây là lợi ích về tổ chức, không phải về hiệu năng — một ý rất đáng nói trong phỏng vấn.

2. Mô hình tư duy cốt lõi

Chỉ cần hai ý. Mọi tính năng và mọi nỗi đau của GraphQL đều suy ra từ hai ý này:

  1. Schema là một đồ thị có kiểu. Type là các nút, field là các cạnh nối sang type khác (Order.customer → Customer). Schema là hợp đồng công khai giữa client và server.
  2. Query là một cây chọn ra từ đồ thị đó. Server đi qua cây, gọi một resolver cho mỗi field trên mỗi object, kết quả của field cha là đầu vào của các field con. Response có đúng hình dạng của query.
type Query { order(id: ID!): Order }
type Order {
  id: ID!
  status: String!
  total: Int!
  createdAt: String!
  notes: String
  customer: Customer!
  items: [OrderItem!]!
}
type Customer { id: ID! name: String! email: String! phone: String! }
type OrderItem { qty: Int! unitPrice: Int! product: Product! }
type Product { id: ID! name: String! thumbnail: String! description: String! images: [String!]! }
Server đi qua cây query từng tầng, response mọc theo cùng hình dạng Bên trái là query. Ở giữa là cây field: tầng 1 là order, tầng 2 là status, customer, items, tầng 3 là name của customer, qty và product của từng item, tầng 4 là name của product. Mỗi tầng sáng lên khi resolver của nó chạy. Bên phải các dòng JSON hiện ra theo đúng tầng đó. query (client gửi) resolver chạy theo cây response (cùng hình dạng) query { order(id: 42) { status customer { name } items { qty product { name } } } } order status customer items ×3 name qty product name items ×3 → resolver của qty, product, name chạy cho TỪNG item { "order": { "status": "SHIPPING", "customer": { "name": "Nguyễn Văn A" }, "items": [ { "qty": 1, "product": { "name": "Sản phẩm 1" } }, …×3 ] } }
Hình 2 — Tầng nào có resolver chạy thì tầng đó sáng lên, và đúng phần JSON đó xuất hiện. Kết quả của order là đầu vào (parent) của status, customer, items; mỗi item lại là parent của qty và product. Vì resolver chạy theo từng object, 3 item nghĩa là 3 lần gọi resolver product — gốc của N+1 ở bài 3.

Response thật từ lab (rút gọn còn 1 item):

{
  "data": {
    "order": {
      "id": "42",
      "status": "SHIPPING",
      "total": 2100000,
      "createdAt": "2026-09-30T10:00:00Z",
      "customer": { "name": "Nguyễn Văn A" },
      "items": [
        { "qty": 1, "product": { "name": "Sản phẩm 1", "thumbnail": "https://cdn.example.com/p/1/thumb.jpg" } },
        …
      ]
    }
  }
}

Từ hai ý trên suy ra gần như mọi thứ người ta hay liệt kê về GraphQL — không cần học thuộc:

Hệ quảSuy ra từ đâuĐi sâu ở
Chỉ 1 endpoint, thường là POST /graphqlQuery đã nói rõ cần gì, URL không cần mang ý nghĩaBài 5
Không cần version APIClient chỉ nhận field đã chọn → thêm field mới không ảnh hưởng client cũBài 5
Introspection, codegen, IDE gợi ý fieldSchema có kiểu và tự mô tả đượcmục 3.3
N+1 là mặc định1 resolver cho mỗi field trên mỗi object; server không biết trước hình dạng queryBài 3
Cache HTTP gần như mấtHình dạng response do client quyết; request thường là POSTBài 5
Chi phí mỗi request do client quyếtClient chọn độ sâu, độ rộng, aliasBài 4
Lỗi từng phần, thường kèm HTTP 200Lỗi gắn vào từng vị trí (path) trong cây, phần khác vẫn trảBài 2, Bài 5
Phân quyền phải đặt ở từng field / objectMột field tới được từ nhiều đường đi trong đồ thịBài 4

3. Internals và quyết định thiết kế

Mục này đi qua những gì người mới cần biết để đọc được một request GraphQL thật. Thuật toán thực thi chi tiết (parse → validate → execute) nằm ở bài 2.

3.1 Một operation đầy đủ: biến, fragment, alias

query OrderScreen($id: ID!, $withNotes: Boolean = false) {   # tên operation + khai báo biến
  order(id: $id) {
    ...OrderHeader                                          # dùng fragment
    notes @include(if: $withNotes)                          # directive có sẵn: @include / @skip
    buyer: customer { name }                                # alias: đổi "customer" thành "buyer" trong response
  }
}

fragment OrderHeader on Order { id status total createdAt }

# biến gửi RIÊNG, không nối vào chuỗi query:
# { "id": "42", "withNotes": true }

3.2 Đi qua HTTP thế nào

Spec GraphQL không định nghĩa tầng vận chuyển. Có một spec riêng là GraphQL over HTTP. Thực tế phổ biến:

POST /graphql
Content-Type: application/json

{ "query": "query OrderScreen($id: ID!) { … }", "variables": { "id": "42" }, "operationName": "OrderScreen" }

HTTP/1.1 200 OK
{ "data": { … }, "errors": [ … ], "extensions": { … } }

Query cũng gửi được bằng GET ?query=… (cache được). Theo spec GraphQL over HTTP, mutation không được chạy qua GET — Apollo Server trả 405, nhưng Lighthouse với cấu hình mặc định vẫn chạy và ghi thật (lab ở bài 4). Mã HTTP cũng khác nhau giữa các server: lỗi cú pháp/validate thường là 400, còn lỗi trong resolver thì vẫn là 200 kèm mảng errors — bài 5 đo từng trường hợp.

3.3 Introspection: schema tự mô tả

Mọi server GraphQL theo spec đều trả lời được query __schema. Đây là nền của GraphiQL, codegen, autocomplete trong IDE. Với schema nhỏ của lab (5 type tự định nghĩa):

Introspection:
  query introspection chuẩn dài 1927 ký tự
  response 23740 byte, 17 type, trong đó 9 type không phải hệ thống: Query, ID, Order, String, Int, Customer, OrderItem, Product, Boolean
  field của Order: id, status, total, createdAt, notes, customer, items

Tiện cho dev, nhưng cũng là bản đồ cho kẻ tấn công. Tắt introspection ở production chưa đủ để giấu schema — bài 4 cho thấy thông báo lỗi "Did you mean …" vẫn lộ tên field.

3.4 Các quyết định thiết kế và cái giá của từng cái

Quyết định của GraphQLĐược gìĐánh đổi cái gì
Schema có kiểu, bắt buộcValidate query trước khi chạy; codegen cho client; tài liệu luôn đúng với codePhải thiết kế schema cẩn thận; đổi kiểu một field là breaking change
Field mặc định nullable (phải viết ! mới là non-null)Một field lỗi không làm hỏng cả responseClient phải xử lý null ở khắp nơi; dùng ! bừa thì một lỗi nhỏ xoá trắng cả màn hình (bài 2)
Client tự chọn fieldKhông over-fetch; thêm field không cần versionServer không tối ưu trước được; chi phí mỗi request khó đoán (bài 4)
Một endpointClient đơn giảnMất ngữ nghĩa HTTP: cache theo URL, status code, method; WAF và log theo URL gần như vô dụng
Resolver theo từng fieldGhép dữ liệu từ nhiều nguồn rất dễ (DB, service khác, cache)N+1 mặc định; mỗi field tốn một lần gọi hàm — lab 02 đo 10 000 object × 10 field qua executor chậm hơn map tay ~11 lần
Không quy định transportChạy được trên HTTP, WebSocket, cả trong processMỗi server tự quyết status code, CSRF, batching → hành vi khác nhau giữa Apollo, Lighthouse, Yoga (bài 5)

4. Đảm bảo và giới hạn

Spec đảm bảo:

Spec KHÔNG đảm bảo — và đây là chỗ hay bị hỏi:

// lab 02, mục D — "chuyển tiền" bằng 2 field trong 1 mutation
mutation { debit(account: "A", amount: 100) credit(account: "B", amount: 100) }

{"errors":[{"message":"ngân hàng B timeout","locations":[{"line":1,"column":45}],"path":["credit"]}],
 "data":{"debit":900,"credit":null}}
số dư sau đó: {"A":900,"B":1000} → A mất 100, B không nhận được

Cách đúng: một field mutation = một nghiệp vụ trọn vẹn (transfer(from, to, amount)), transaction nằm bên trong resolver/service. Và vì mạng có thể đứt sau khi server đã ghi, mutation quan trọng nên nhận một idempotency key — cùng kỹ thuật ở bài idempotency key với Redis.

Khung lý thuyết — tail latency: một query GraphQL thường "toả" ra nhiều nguồn dữ liệu, và response chỉ xong khi nhánh chậm nhất xong (lab 02, mục B). Nếu một query chạm k nguồn độc lập, xác suất ít nhất một nguồn rơi vào mức chậm p99 là 1 − 0,99k: k = 10 thì khoảng 9,6% request dính độ trễ p99 của một nguồn nào đó. Đây là ý của bài "The Tail at Scale" (Dean & Barroso, 2013). Query càng rộng, p99 của cả API càng xấu — kể cả khi từng nguồn đều khoẻ.

5. Failure modes

Bảng tổng hợp; mỗi dòng có lab tái hiện ở bài tương ứng.

Hỏng thế nàoKhi nào xảy raHệ quảPhát hiệnLab
N+1Resolver gọi DB theo từng object1 request = 2 051 câu SQL; tải tăng là hết connection DBĐếm SQL theo operationBài 3: 2 051 → 4 câu
Query quá sâu / quá rộngKhông giới hạn độ sâu, chi phíLighthouse: 1 request 15 giây, 764 MB RAM; memory_limit 128M thì PHP chếtThời gian, RAM theo operationBài 4
Validate quá tảiQuery lặp field hàng nghìn lầngraphql-js: 1 request 47 KB làm process hết heapCPU tăng mà chưa resolver nào chạyBài 4
Mutation dừng giữa chừngNhiều field ghi trong 1 mutationDữ liệu lệch (mục 4)errors có path trỏ vào mutationBài 1, lab 02-D
1 resolver chậm kéo cả responseGọi service ngoài không có timeoutp99 tăng vọtTracing theo fieldBài 2
Lỗi nằm trong HTTP 200Resolver lỗiDashboard theo status code vẫn xanhĐếm errors[] theo operationBài 5
Breaking change lọt ra productionXoá/đổi field khi app cũ còn dùngApp mobile bản cũ lỗi hàng loạtSchema check trong CIBài 5
Một subgraph chết (federation)Service phụ trách 1 phần schema sậpField non-null kéo data thành null toàn bộLỗi có code của gatewayBài 5

6. Vận hành trong production

Tóm tắt — chi tiết và lab ở bài 5:

7. So sánh và ma trận quyết định

Tiêu chíREST theo resourceBFF (REST theo màn hình)GraphQLgRPC
Ai quyết hình dạng dữ liệuServerServer, theo từng màn hìnhClientServer (file .proto)
Màn hình phức tạp cần mấy lượtNhiều (lab: 2)11Tuỳ thiết kế
Cache HTTP/CDNTốtTốtKém (cần GET + persisted query)Không
Hợp đồng có kiểuTuỳ (OpenAPI)TuỳBắt buộc, introspectionBắt buộc, codegen
Hiệu năng thôTốtTốtCó overhead mỗi fieldTốt nhất (binary, HTTP/2)
Bảo mật mặc địnhDễ: bề mặt cố định theo endpointDễKhó: bề mặt tổ hợp, phải tự giới hạnDễ
Độ phức tạp vận hànhThấpTrung bình (nhiều endpoint)Cao (DataLoader, cost, metric riêng)Trung bình
Nhiều loại client cùng dùngPhải thoả hiệpMỗi client 1 BFFTốt nhấtHợp nội bộ
Streaming / realtimeSSE, WebSocket tự làmNhư RESTSubscription (có trạng thái, khó scale)Streaming 2 chiều
Trình duyệt gọi thẳngCóCóCóCần gRPC-Web/proxy
Hệ sinh thái, tuyển ngườiRộng nhấtRộngKhá rộng; người vận hành giỏi hiếm hơnHẹp hơn, mạnh ở backend
flowchart TD
  Q1["Có nhiều loại client cần những lát cắt dữ liệu khác nhau?"] -->|"Không"| R1["REST hoặc BFF: đơn giản, cache tốt"]
  Q1 -->|"Có"| Q2["Dữ liệu dạng đồ thị, 1 màn hình ghép nhiều nguồn?"]
  Q2 -->|"Không"| R1
  Q2 -->|"Có"| Q3["Team sẵn sàng làm DataLoader, giới hạn chi phí, metric theo operation?"]
  Q3 -->|"Chưa"| R2["BFF theo màn hình trước, xem lại sau"]
  Q3 -->|"Có"| R3["GraphQL"]
  Q4["Gọi nội bộ giữa service, cần thông lượng, streaming?"] --> R4["gRPC"]

Dùng GraphQL khi: có từ hai loại client trở lên (web, iOS, Android, đối tác) đọc cùng một đồ thị dữ liệu; giao diện đổi liên tục; muốn frontend tự chủ mà không mở ticket backend cho từng field; có nhiều nguồn dữ liệu cần ghép lại ở một chỗ.
Tránh khi: chỉ có một client và nghiệp vụ CRUD đơn giản; gọi nội bộ giữa service (dùng gRPC/REST); cần cache CDN là chính (nội dung công khai, đọc nhiều); upload/download file; team chưa có người hiểu N+1, cost limit và observability theo operation — khi đó GraphQL chỉ chuyển độ phức tạp từ frontend sang chỗ khó thấy hơn.

8. Góc nhìn tech lead: tác động tổ chức

Đoạn có thể chép vào ADR:

Bối cảnh: 3 client (web, iOS, Android) cùng đọc dữ liệu đơn hàng/sản phẩm; mỗi màn hình mới cần 2–3 endpoint mới.
Quyết định: thêm lớp GraphQL ở edge cho client giao diện; service nội bộ vẫn gọi nhau bằng REST/gRPC.
Bắt buộc kèm theo: DataLoader cho mọi quan hệ; giới hạn độ sâu + chi phí; persisted query cho app first-party;
  metric theo operationName; schema check trong CI.
Hệ quả: frontend tự chủ hơn; backend cần thêm năng lực vận hành; mất cache CDN cho các trang công khai.
Xem lại khi: số client giảm còn 1, hoặc chi phí vận hành lớn hơn lợi ích (đo bằng lead time của tính năng).

9. Anti-patterns và bài học thực tế

  1. Schema là bản sao của bảng DB (sinh CRUD tự động cho mọi bảng): lộ cấu trúc nội bộ, đổi DB là đổi API, phân quyền rất khó. Schema nên mô tả nghiệp vụ mà client cần.
  2. Mutation nhiều field dùng như transaction — lab mục 4: tiền mất giữa chừng.
  3. Mutation kiểu "vạn năng" updateUser(input: JSON): mất kiểu, mất validate, mất khả năng phát hiện breaking change. Mỗi hành động nghiệp vụ một mutation có tên rõ ràng.
  4. Monitor bằng HTTP status: resolver lỗi vẫn trả 200 (bài 5 có lab).
  5. Upload/download file qua GraphQL: multipart cho GraphQL không nằm trong spec và từng là lỗ hổng CSRF ở nhiều server. Dùng presigned URL (xem bài MinIO), GraphQL chỉ trả URL.
  6. Chọn GraphQL vì "hiện đại" khi chỉ có một client: trả đủ chi phí (bảo mật, N+1, monitoring) mà không nhận lợi ích tổ chức nào.

Bài học công khai:

10. Câu hỏi phụ interviewer hay hỏi tiếp

GraphQL có nhanh hơn REST không?
Không tự nhiên nhanh hơn. Lab 01: GraphQL ngang một BFF viết tốt (1 lượt, ~440 byte). Executor còn tốn thêm CPU cho mỗi field (lab 02: chậm hơn map tay ~11 lần với 100 000 field). Nó thắng REST "theo resource" về số lượt mạng, không thắng về CPU.
Vì sao GraphQL hay dùng POST cho cả query?
Query có thể dài, URL có giới hạn độ dài, và lịch sử client đi theo hướng đó. Query hoàn toàn gửi được bằng GET (cache được); mutation thì spec cấm đi qua GET — nhưng phải kiểm tra server của mình có chặn thật không (Lighthouse mặc định không chặn). Persisted query (gửi hash thay vì cả chuỗi) là cách lấy lại GET + CDN.
Không version thì sửa schema thế nào?
Thêm field mới, đánh dấu field cũ @deprecated, đo xem còn ai dùng, xoá khi không còn ai. Breaking change được phát hiện tự động trong CI bằng findBreakingChanges — bài 5.
GraphQL có gắn với database nào không?
Không. Resolver có thể đọc SQL, Redis, gọi REST, gọi gRPC. GraphQL là lớp API, không phải ngôn ngữ truy vấn DB.
Mutation có transaction không?
Không có transaction giữa các field. Field gốc của mutation chạy nối tiếp nhưng không rollback cho nhau. Một field mutation nên là một nghiệp vụ trọn vẹn, transaction nằm trong service.
Cache GraphQL thế nào?
Ba tầng: client cache chuẩn hoá theo __typename + id (Apollo Client, Relay); server cache theo resolver/DataLoader trong một request; HTTP cache qua GET + persisted query + CDN. Chi tiết ở bài 5.
Subscription dùng khi nào?
Khi cần server đẩy sự kiện (chat, trạng thái đơn hàng). Nó giữ kết nối có trạng thái nên scale khó hơn request thường; nhiều team chọn polling hoặc SSE cho đơn giản.
Nội bộ giữa các service có nên dùng GraphQL?
Thường là không: gRPC có binary, HTTP/2, streaming 2 chiều, codegen chặt. GraphQL hợp ở "rìa" — nơi client giao diện gọi vào.

Phụ lục: lab đã chạy

Môi trường: graphql-js 17.0.2, Node 22.23.3 trong container node:22-bookworm-slim, máy 4 vCPU dùng chung (số đo thời gian nhiễu — chỉ xem tỉ lệ). RTT ở lab 01 là giả lập. Code ở labs/graphql/node/, output đầy đủ ở labs/graphql/out/01-rest-vs-graphql.txt và 02-execution.txt.

cd labs/graphql/node
docker run --rm -v "$PWD":/lab -w /lab node:22-bookworm-slim npm ci   # cài graphql 17.0.2, dataloader, pg
./run.sh 01-rest-vs-graphql.mjs    # REST vs BFF vs GraphQL, introspection
./run.sh 02-execution.mjs          # mục D: mutation không có transaction

Chưa verify: mốc thời gian lịch sử (lấy từ graphql.org và blog Postman, không tự kiểm chứng được); con số "2–4 tuần onboarding" là ước lượng kinh nghiệm, không phải số đo.