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, vdtype 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, vda: 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 (containernode: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 quaPOST /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:
- Over-fetching: endpoint trả hình dạng cố định, màn hình chỉ cần vài field.
- 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. - 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.
$ ./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.
- Tối ưu cho: client giao diện (web, mobile) đọc dữ liệu dạng đồ thị; nhiều loại client và nhiều phiên bản client cùng sống; sản phẩm đổi giao diện liên tục mà không muốn version API.
- Không tối ưu cho: cache HTTP/CDN; truyền file; gọi nội bộ giữa service cần thông lượng cao và streaming; CRUD đơn giản với một client duy nhất; API công khai mà mình không kiểm soát được chi phí query (làm được, nhưng tốn công — xem cách GitHub đặt giới hạn ở mục 9).
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:
- 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. - 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!]! }
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 /graphql | Query đã nói rõ cần gì, URL không cần mang ý nghĩa | Bài 5 |
| Không cần version API | Client 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 ý field | Schema có kiểu và tự mô tả được | mục 3.3 |
| N+1 là mặc định | 1 resolver cho mỗi field trên mỗi object; server không biết trước hình dạng query | Bài 3 |
| Cache HTTP gần như mất | Hình dạng response do client quyết; request thường là POST | Bài 5 |
| Chi phí mỗi request do client quyết | Client chọn độ sâu, độ rộng, alias | Bài 4 |
| Lỗi từng phần, thường kèm HTTP 200 | Lỗ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 / object | Mộ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 }
- Biến tách khỏi chuỗi query vì 2 lý do: chống injection (giống prepared statement trong SQL), và để chuỗi query không đổi giữa các lần gọi — server mới cache được bản đã parse/validate và mới dùng được persisted query (bài 5).
- Tên operation (
OrderScreen) không bắt buộc, nhưng ở production nên bắt buộc: mọi metric và log đều group theo nó, vì URL lúc nào cũng là/graphql. - Mutation viết giống query, chỉ khác từ khoá. Subscription giữ một kết nối (thường là
WebSocket theo giao thức
graphql-ws, hoặc SSE) để server đẩy sự kiện về.
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ộc | Validate query trước khi chạy; codegen cho client; tài liệu luôn đúng với code | Phả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ả response | Client 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 field | Không over-fetch; thêm field không cần version | Server không tối ưu trước được; chi phí mỗi request khó đoán (bài 4) |
| Một endpoint | Client đơn giản | Mấ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 field | Ghé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 transport | Chạy được trên HTTP, WebSocket, cả trong process | Mỗ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:
- Response có đúng hình dạng của query (cùng tên field, cùng thứ tự field trong JSON).
- Kiểu của input và output được kiểm tra (coercion — ép và kiểm tra kiểu giá trị).
- Query được validate trước khi chạy — nếu server có gọi bước validate (bài 2 cho thấy gọi thẳng
execute()của graphql-js là bỏ qua bước này). - Các field ở tầng gốc của mutation chạy nối tiếp, đúng thứ tự viết.
- Lỗi được báo kèm
path(vị trí trong response) và quy tắc lan truyền null rõ ràng.
Spec KHÔNG đảm bảo — và đây là chỗ hay bị hỏi:
- Không có transaction giữa các field của một mutation. Field 1 ghi xong, field 2 lỗi → field 1 không được rollback. Lab tái hiện ngay bên dưới.
- Không có thứ tự giữa các field của query (spec cho phép chạy song song).
- Không có "ảnh chụp nhất quán" giữa các field: hai resolver có thể đọc DB ở hai thời điểm khác nhau.
- Không có cache, không có phân quyền, không có giới hạn chi phí, không có chuẩn phân trang (kiểu connection của Relay chỉ là quy ước phổ biến).
- Kích thước response có thể tăng theo hàm mũ của độ sâu query (bài 4: query 109 ký tự → response 2,2 MB).
// 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ào | Khi nào xảy ra | Hệ quả | Phát hiện | Lab |
|---|---|---|---|---|
| N+1 | Resolver gọi DB theo từng object | 1 request = 2 051 câu SQL; tải tăng là hết connection DB | Đếm SQL theo operation | Bài 3: 2 051 → 4 câu |
| Query quá sâu / quá rộng | Không giới hạn độ sâu, chi phí | Lighthouse: 1 request 15 giây, 764 MB RAM; memory_limit 128M thì PHP chết | Thời gian, RAM theo operation | Bài 4 |
| Validate quá tải | Query lặp field hàng nghìn lần | graphql-js: 1 request 47 KB làm process hết heap | CPU tăng mà chưa resolver nào chạy | Bài 4 |
| Mutation dừng giữa chừng | Nhiều field ghi trong 1 mutation | Dữ liệu lệch (mục 4) | errors có path trỏ vào mutation | Bài 1, lab 02-D |
| 1 resolver chậm kéo cả response | Gọi service ngoài không có timeout | p99 tăng vọt | Tracing theo field | Bài 2 |
| Lỗi nằm trong HTTP 200 | Resolver lỗi | Dashboard theo status code vẫn xanh | Đếm errors[] theo operation | Bài 5 |
| Breaking change lọt ra production | Xoá/đổi field khi app cũ còn dùng | App mobile bản cũ lỗi hàng loạt | Schema check trong CI | Bài 5 |
| Một subgraph chết (federation) | Service phụ trách 1 phần schema sập | Field non-null kéo data thành null toàn bộ | Lỗi có code của gateway | Bài 5 |
6. Vận hành trong production
Tóm tắt — chi tiết và lab ở bài 5:
- Metric phải có, tính theo
operationName(vì URL luôn là/graphql): số request, tỉ lệ lỗi tính từ mảngerrors(không phải từ HTTP status), latency p95/p99, số câu SQL mỗi operation, kích thước response, số query bị từ chối vì độ sâu/chi phí. - Tham số ảnh hưởng lớn: giới hạn độ sâu, chi phí, số alias, số token khi parse, kích thước body, timeout, số phần tử tối đa mỗi trang, introspection, che message lỗi, cache document đã validate, cache persisted query.
- Capacity planning theo chi phí, không theo số request: 1 request GraphQL có thể rẻ như 1 câu SQL hoặc đắt như 2 000 câu. Đặt ngân sách chi phí cho từng operation quan trọng.
- Người trực lúc 3 giờ sáng cần: đọc được
errors[].path, biết operation nào của client phiên bản nào gây tải, tìm được resolver chậm từ trace. Không có những thứ đó thì "API chậm" là một câu không điều tra được.
7. So sánh và ma trận quyết định
| Tiêu chí | REST theo resource | BFF (REST theo màn hình) | GraphQL | gRPC |
|---|---|---|---|---|
| Ai quyết hình dạng dữ liệu | Server | Server, theo từng màn hình | Client | Server (file .proto) |
| Màn hình phức tạp cần mấy lượt | Nhiều (lab: 2) | 1 | 1 | Tuỳ thiết kế |
| Cache HTTP/CDN | Tốt | Tốt | Kém (cần GET + persisted query) | Không |
| Hợp đồng có kiểu | Tuỳ (OpenAPI) | Tuỳ | Bắt buộc, introspection | Bắt buộc, codegen |
| Hiệu năng thô | Tốt | Tốt | Có overhead mỗi field | Tốt nhất (binary, HTTP/2) |
| Bảo mật mặc định | Dễ: bề mặt cố định theo endpoint | Dễ | Khó: bề mặt tổ hợp, phải tự giới hạn | Dễ |
| Độ phức tạp vận hành | Thấp | Trung bình (nhiều endpoint) | Cao (DataLoader, cost, metric riêng) | Trung bình |
| Nhiều loại client cùng dùng | Phải thoả hiệp | Mỗi client 1 BFF | Tốt nhất | Hợp nội bộ |
| Streaming / realtime | SSE, WebSocket tự làm | Như REST | Subscription (có trạng thái, khó scale) | Streaming 2 chiều |
| Trình duyệt gọi thẳng | Có | Có | Có | Cần gRPC-Web/proxy |
| Hệ sinh thái, tuyển người | Rộng nhất | Rộng | Khá rộng; người vận hành giỏi hiếm hơn | Hẹ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
- Quyền lực dịch chuyển: frontend tự chủ hơn (không chờ backend thêm endpoint), backend mất quyền kiểm soát chi phí từng request. Phải bù bằng guardrail (giới hạn độ sâu/chi phí, persisted query) và observability — nếu không, backend "trực" cho những query mình không viết.
- Schema là sản phẩm, cần chủ sở hữu: thiết kế schema theo nhu cầu của client (demand-oriented), không phải chép cấu trúc bảng DB. Mỗi thay đổi schema nên đi qua review như một thay đổi API công khai.
- Onboarding: dev backend mới cần thời gian để quen resolver, DataLoader, nullability, cost limit (ước lượng của tôi: 2–4 tuần để tự tin, không phải số đo). Đầu tư sớm vào codegen, schema lint, test đếm số SQL.
- Vendor lock-in: spec mở (GraphQL Foundation). Lock-in nằm ở công cụ: ví dụ
@apollo/gatewayvà@apollo/compositiondùng license Elastic-2.0 (kiểm tra bằngnpm viewngày 01/10/2026), trong khi@apollo/server,@apollo/subgraph, Hive Gateway là MIT. - Exit strategy: giữ resolver mỏng — chỉ gọi xuống service/domain layer. Khi đó cùng một service có thể mở thêm REST/BFF bất cứ lúc nào. Nếu dùng persisted query, danh sách query đã biết trước chính là danh sách endpoint REST tương ứng — chuyển đổi được từng cái một.
Đ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ế
- 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.
- Mutation nhiều field dùng như transaction — lab mục 4: tiền mất giữa chừng.
- 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. - Monitor bằng HTTP status: resolver lỗi vẫn trả 200 (bài 5 có lab).
- 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.
- 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:
- GitHub mở GraphQL API công khai nhưng phải dựng cả một mô hình chi phí: 5 000 điểm/giờ cho mỗi user,
mỗi lần gọi tối đa 500 000 node,
first/lastbắt buộc trong khoảng 1–100, request quá 10 giây bị cắt (docs GitHub "Rate limits and query limits for the GraphQL API", đọc ngày 01/10/2026). Đó là cái giá thật của "client tự quyết query" trên API công khai. - Matt Bessey, "Why, after 6 years, I'm over GraphQL" (bessey.dev, 05/2024): sau 6 năm dùng production, tác giả không còn khuyên dùng cho đa số team — lý do chính là bề mặt tấn công, phân quyền, rate limit, N+1 và độ kết dính. Bài này lan rộng trên Hacker News; đáng đọc để chuẩn bị câu "khi nào không dùng".
- Netflix (Netflix Tech Blog, "How Netflix Scales its API with GraphQL Federation", 2020) là ví dụ ngược lại: nhiều team, nhiều domain, dùng federation để mỗi team sở hữu một phần schema — bối cảnh đúng của GraphQL.
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ằngfindBreakingChanges— 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.