Vận hành GraphQL production — HTTP và cache, giám sát, tiến hoá schema, federation nhiều team

JD ghi "kinh nghiệm GraphQL" thường có nghĩa là công ty đã chạy GraphQL production một thời gian, với nhiều team và nhiều phiên bản app. Câu hỏi khi đó không còn là "viết resolver thế nào" mà là: dashboard có thấy lỗi không, cache ở đâu, sửa schema mà không làm vỡ app cũ thế nào, và khi 6 team cùng góp vào một schema thì ai sở hữu cái gì. Bài cuối của series đo những chuyện đó trên Apollo Server 5 và Apollo Federation 2, cộng với công cụ so sánh schema của graphql-js 17.

Thuật ngữ dùng trong bài
GraphQL over HTTP (spec riêng mô tả cách gửi GraphQL qua HTTP: method, content type, status code) · APQ (Automatic Persisted Queries — client gửi hash của query; server chưa có thì client gửi lại cả query để server nhớ) · trusted documents (danh sách query đăng ký lúc build — allowlist) · CDN (mạng máy chủ cache đặt gần người dùng) · @cacheControl (directive gợi ý thời gian cache cho từng field; server gộp lại thành header Cache-Control) · normalized cache (cache phía client lưu từng object theo __typename + id) · schema registry (nơi lưu các phiên bản schema và thống kê field nào còn được dùng) · breaking / dangerous / safe change (chắc chắn làm vỡ client cũ / có thể làm client chạy sai / an toàn) · expand → migrate → contract (thêm cái mới, chuyển client sang, rồi mới xoá cái cũ) · federation (nhiều service, mỗi service sở hữu một phần schema, ghép thành một đồ thị) · subgraph / supergraph (schema của một service / schema tổng sau khi ghép) · router (gateway) (server đứng trước, nhận query, chia việc cho các subgraph) · entity / @key (type có khoá, nhiều subgraph cùng góp field cho nó) · reference resolver (__resolveReference — hàm của subgraph biến { __typename, id } thành object đầy đủ) · query plan (kế hoạch router lập: gọi subgraph nào, theo thứ tự nào) · composition (bước ghép các subgraph thành supergraph và kiểm tra xung đột) · schema stitching (cách cũ: gateway tự ghép schema của nhiều server).
Kiểm chứng: tài liệu qua Context7: Apollo Server docs (APQ, gateway setup, IntrospectAndCompose), graphql.org (serving over HTTP, debug errors, FAQ versioning), graphql-js 17 (schema evolution: findBreakingChanges, findSchemaChanges; upgrade guide v16→v17), Hive federation-composition (README). Đọc trực tiếp: GitHub Docs "Breaking changes" của GraphQL API; blog Apollo "Federation 2 is now generally available" (04/2022); Netflix Tech Blog "How Netflix Scales its API with GraphQL Federation" (2020); license qua GitHub API và npm view ngày 01/10/2026. Lab chạy thật: graphql-js 17.0.2 (so sánh schema); Apollo Server 5.5.1, @apollo/subgraph 2.15.1, @apollo/gateway 2.14.4, graphql 16.14.2 trên Node 22.23.3. Code: labs/graphql/node/05-schema-evolution.mjs, labs/graphql/federation/.

Trả lời 30 giây

Vận hành GraphQL khác REST ở bốn chỗ. Giám sát: URL luôn là /graphql và lỗi resolver trả HTTP 200, nên metric phải tính theo operationName và đếm mảng errors — lab của tôi có operation lỗi 100% mà không có một HTTP 5xx nào. Cache: POST không cache được ở CDN, nên dùng GET + persisted query (hash ngắn) + header Cache-Control sinh từ @cacheControl; nhớ là một field không có gợi ý cache sẽ biến cả response thành no-store, và cache APQ phải dùng chung giữa các instance. Tiến hoá schema không cần version: thêm field mới, @deprecated field cũ, đo xem còn ai dùng, chỉ xoá khi về 0; chặn breaking change trong CI bằng so sánh schema. Nhiều team: federation — mỗi team một subgraph, router lập query plan và gọi _entities để ghép. Cái giá: thêm lượt mạng nối tiếp, N+1 dời vào reference resolver, một subgraph chết làm data thành null nếu field ở ranh giới là non-null, và rủi ro license — Apollo Router dùng Elastic License 2.0, có router thay thế MIT/Apache.

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

Khi GraphQL rời giai đoạn thử, bốn vấn đề vận hành xuất hiện gần như theo cùng một thứ tự ở mọi công ty:

  1. Không thấy lỗi. Mọi request là POST /graphql 200. Dashboard HTTP xanh trong khi người dùng thấy màn hình lỗi.
  2. Mất cache HTTP. Hình dạng response do client quyết, request là POST — CDN không giúp được gì.
  3. Sửa schema sợ vỡ app cũ. GraphQL chọn hướng không version: graphql.org viết GraphQL "encourages a versionless approach to API development by providing tools for continuous schema evolution". Nhưng "không version" chỉ an toàn khi có quy trình và công cụ.
  4. Một schema, nhiều team. Schema nguyên khối trở thành nút thắt: mọi team sửa cùng một repo, cùng một lịch deploy. Cách đầu tiên là schema stitching (gateway tự ghép schema của nhiều server). Apollo đưa ra Federation năm 2019 và Federation 2 GA tháng 4/2022. Netflix mô tả cách họ chuyển sang federation năm 2020: bắt đầu bằng việc coi cả monolith là một subgraph, rồi tách dần từng domain.

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

  1. Schema là một sản phẩm có người dùng. Mỗi field là một lời hứa với mọi client đang chạy ngoài kia, kể cả app bản cũ không ai cập nhật. Tiến hoá bằng cộng thêm; chỉ xoá khi có dữ liệu chứng minh không còn ai dùng.
  2. Federation là một đồ thị có nhiều chủ. Mỗi subgraph sở hữu một phần type và field. Entity (type có @key) là chỗ nối giữa các subgraph. Router biến một query thành một kế hoạch nhiều lượt gọi — và mọi chi phí, mọi lỗi của federation đều nằm trong kế hoạch đó.
Router thực thi query plan qua hai subgraph Client gửi một query cho router. Router gọi subgraph posts lấy 20 bài viết, phần author chỉ có __typename và id. Router gọi tiếp subgraph users bằng _entities với 20 representation để lấy tên. Router ghép lại và trả một response. Có 2 lượt gọi subgraph nối tiếp. client router / gateway lập query plan subgraph posts sở hữu Post, góp User.posts subgraph users sở hữu User (name) ① client gửi 1 query: posts(first: 20) { title author { name } } ② Fetch 1 → posts: lấy 20 post, author chỉ có { __typename, id } ③ posts trả 20 tham chiếu User (5 user khác nhau) ④ Flatten → users: _entities với 20 representation — router KHÔNG gộp trùng ⑤ users trả name cho từng representation ⑥ router ghép, trả 1 response — 2 lượt gọi subgraph nối tiếp
Hình 1 — Theo đúng query plan in ra ở lab 07 (mục 3.5). Client chỉ thấy một request, nhưng phía sau là hai lượt gọi nối tiếp; query lồng qua lại nhiều subgraph thì số lượt tăng theo. Đây là "chi phí ẩn" của federation.

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

3.1 HTTP: mã trả về phụ thuộc vào lỗi xảy ra ở bước nào

# Apollo Server 5.5.1 (lab 06, mục A)
cú pháp sai "{ ok "                    → HTTP 400 | "Syntax Error: Expected Name, found <EOF>."      code GRAPHQL_PARSE_FAILED
field không tồn tại "{ nope }"         → HTTP 400 | "Cannot query field \"nope\" on type \"Query\"."  code GRAPHQL_VALIDATION_FAILED
resolver ném lỗi "{ boom }"            → HTTP 200 | errors[0].code INTERNAL_SERVER_ERROR, data.boom = null
một phần lỗi "{ ok boom }"             → HTTP 200 | data.ok = "ok", data.boom = null
accept: application/graphql-response+json, "{ nope }" → HTTP 400

Quy luật: lỗi trước khi chạy (parse, validate) → 4xx; lỗi trong khi chạy → 200 kèm errors. graphql.org giải thích lý do: response có data khác null là một response GraphQL hợp lệ, nên vẫn là 2xx. Media type mới application/graphql-response+json (spec GraphQL over HTTP) cho phép server dùng status khác 2xx an toàn hơn, vì proxy không bao giờ tự sinh ra media type đó. Hệ quả vận hành ở mục 3.2. Batching mảng và CSRF đã đo ở bài 4.

3.2 Giám sát theo operation, không theo URL

Một plugin Apollo Server ghi một dòng metric cho mỗi request:

const metricsPlugin = {
  async requestDidStart() {
    const t0 = performance.now();
    let errors = [];
    return {
      async didEncounterErrors(ctx) { errors = ctx.errors.map((e) => e.extensions?.code ?? 'UNKNOWN'); },
      async willSendResponse(ctx) {
        emit({ operation: ctx.operationName ?? '(không tên)', http: ctx.response.http.status ?? 200,
               errorCodes: errors, ms: Math.round(performance.now() - t0) });
      },
    };
  },
};
=== A. Metric theo operationName: HTTP luôn 200, lỗi chỉ thấy khi đọc errors[] ===
   {"operation":"ProductPage","http":200,"errorCodes":[],"ms":14}
   {"operation":"Checkout","http":200,"errorCodes":["UNKNOWN"],"ms":8}
   {"operation":"Checkout","http":200,"errorCodes":["UNKNOWN"],"ms":3}
   {"operation":"(không tên)","http":200,"errorCodes":[],"ms":2}
  tổng hợp: {"ProductPage":{"requests":1,"http5xx":0,"withErrors":0},"Checkout":{"requests":2,"http5xx":0,"withErrors":2},…}

Checkout lỗi 2/2 lần, http5xx = 0. Một dashboard dựng từ log nginx sẽ báo "0% lỗi". Hai chi tiết thực tế lab lộ ra:

Thêm vào đó: thời gian theo field (graphql-js 17 phát sự kiện trên node:diagnostics_channel — bài 2, mục 3.12), và tên + phiên bản client (gửi qua header, vd quy ước apollographql-client-name/-version của Apollo) — để biết lỗi đến từ app bản nào.

3.3 Cache: ba tầng, mỗi tầng một cái bẫy

Tầng HTTP / CDN: GET + @cacheControl

type Query {
  product(id: ID!): Product @cacheControl(maxAge: 60)
  me: User @cacheControl(maxAge: 30, scope: PRIVATE)
  flaky: String                                  # không có gợi ý cache
}

# lab 06b, mục B
GET  { product }               → HTTP 200 | cache-control: max-age=60, public
POST { product }               → HTTP 200 | cache-control: max-age=60, public
GET  { product me }  (PRIVATE) → HTTP 200 | cache-control: max-age=30, private
GET  { product flaky }         → HTTP 200 | cache-control: no-store

Server tính chính sách cho cả response = chặt nhất trong mọi field: maxAge nhỏ nhất, có một field PRIVATE thì cả response private, có một field không gợi ý thì no-store. Bẫy nổ trễ: ai đó thêm một field "nhỏ" không có @cacheControl vào query của trang chủ → tỉ lệ trúng cache CDN rơi về 0 mà không có lỗi nào. Và dù POST cũng nhận header, CDN thường chỉ cache GET — nên muốn cache CDN phải gửi query bằng GET, và để URL không quá dài thì dùng persisted query.

APQ: hash thay cho chuỗi query

# lab 06, mục F–G
1) GET chỉ có hash                             → HTTP 200 | PersistedQueryNotFound (code PERSISTED_QUERY_NOT_FOUND)
2) GET hash + query (đăng ký)                  → HTTP 200 | {"data":{"ok":"ok"}}
3) GET chỉ có hash (lần sau)                   → HTTP 200 | {"data":{"ok":"ok"}}
hash sai với query                             → HTTP 400 | "provided sha does not match query"
đăng ký trên instance A                        → HTTP 200 | {"data":{"ok":"ok"}}
hash-only tới instance B (sau load balancer)   → HTTP 200 | PersistedQueryNotFound
APQ với hai instance, mỗi instance có cache riêng trong RAM Client gửi hash tới instance A, A chưa có nên trả PersistedQueryNotFound. Client gửi hash kèm query, A lưu vào cache RAM của mình. Lần sau gửi hash, A trúng cache. Nhưng khi load balancer chuyển request sang instance B, B có cache riêng còn trống nên lại trả PersistedQueryNotFound và client phải gửi lại cả query. client LB instance A cache APQ (RAM) sha256 ecf4… → { ok } trúng instance B cache APQ (RAM) (trống) NOT_FOUND ① GET chỉ có hash → A chưa biết hash: PERSISTED_QUERY_NOT_FOUND ② client gửi lại hash + cả query → A lưu vào cache RAM của riêng A ③ lần sau chỉ gửi hash → A trúng cache, URL ngắn, CDN cache được ④ load balancer chuyển sang B → cache của B trống: NOT_FOUND ⑤ client phải gửi lại cả query — thêm 1 lượt, mỗi instance phải "học" lại từ đầu
Hình 2 — Theo đúng lab 06 mục F–G. Không có lỗi nào hiện ra cho người dùng: APQ client tự gửi lại. Cái giá là thêm một lượt mạng và băng thông — lớn nhất ngay sau mỗi lần deploy (instance mới, cache trống) hoặc khi scale ra nhiều instance. Chữa: cache APQ dùng chung (Redis/Memcached) thay cho RAM từng process.

Nhắc lại từ bài 4: APQ không phải allowlist — lab đăng ký được một query lạ bất kỳ. Nếu cần chặn query lạ, dùng trusted documents (danh sách đăng ký lúc build). Cả hai cùng cho URL ngắn và cache được; chỉ trusted documents cho bảo mật.

Tầng server và tầng client

3.4 Tiến hoá schema không cần version

graphql-js có sẵn công cụ so sánh hai schema. Lab 05 dựng v1 và v2 với những thay đổi hay gặp:

=== findBreakingChanges(v1, v2) — client cũ CHẮC CHẮN vỡ ===
  REQUIRED_ARG_ADDED           A required argument Query.product(locale:) was added.
  ARG_CHANGED_KIND             Argument Query.search(term:) has changed type from String to String!.
  FIELD_REMOVED                Field Product.name was removed.
  FIELD_CHANGED_KIND           Field Product.price changed type from Int! to Int.
  REQUIRED_INPUT_FIELD_ADDED   A required field ReviewInput.body was added.

=== findDangerousChanges(v1, v2) — client cũ CÓ THỂ chạy sai ===
  ARG_DEFAULT_VALUE_CHANGE     Query.search(first:) has changed defaultValue from 10 to 20.
  OPTIONAL_ARG_ADDED           An optional argument Query.search(sort:) was added.
  VALUE_ADDED_TO_ENUM          Enum value OrderStatus.REFUNDED was added.
  TYPE_ADDED_TO_UNION          Brand was added to union type SearchHit.
  OPTIONAL_INPUT_FIELD_ADDED   An optional field ReviewInput.note was added.

=== findSchemaChanges(v1, v2) — thêm cả thay đổi an toàn (graphql-js 17) ===
  TYPE_ADDED                   Brand was added.
  FIELD_ADDED                  Field Product.title was added.
  FIELD_CHANGED_KIND_SAFE      Field Product.description changed type from String to String!.

=== Query của app mobile bản cũ (đã cài trên máy người dùng) validate với schema mới ===
  - Cannot query field "name" on type "Product".
  - Argument "Query.product(locale:)" of type "String!" is required, but it was not provided.

=== Thêm giá trị enum là "dangerous": code client cũ switch đủ case sẽ ném lỗi khi gặp giá trị lạ ===
  PAID → Đã thanh toán
  REFUNDED → app crash: Trạng thái không biết: REFUNDED

Ba quy tắc rút ra, đáng học thuộc vì interviewer hay hỏi:

Expand, migrate, contract: chỉ xoá field cũ khi không còn ai dùng Tháng 0 thêm field title và đánh dấu name là deprecated. Tỉ lệ request còn dùng name giảm dần qua các tháng: 100, 80, 55, 30, 12, 3, 0 phần trăm. Nếu xoá name ở tháng 2 thì app bản cũ lỗi Cannot query field name. Chỉ xoá ở tháng 6 khi tỉ lệ về 0. Đổi tên Product.name → title (số liệu minh hoạ) ■ % request còn dùng name ■ % request đã dùng title tháng 0123 456 ① thêm title + @deprecated(name) xoá sớm ở đây? Cannot query field "name" → 55% request của app cũ lỗi ③ xoá name khi về 0% ở mọi phiên bản app còn hỗ trợ ② migrate: client chuyển dần sang title; đo usage từng field theo client + phiên bản
Hình 3 — Số phần trăm là minh hoạ; thông báo lỗi là output thật của lab 05 khi validate query của app cũ với schema đã xoá field. Với app mobile, "về 0" phải tính trên mọi phiên bản app còn được hỗ trợ, không chỉ bản mới nhất — vì người dùng không cập nhật app.

Đưa vào CI: so schema của nhánh PR với schema đang chạy production, chặn merge khi có breaking change chưa được duyệt:

// scripts/schema-check.mjs — chạy trong CI, so với schema của nhánh main
import { buildSchema, findBreakingChanges, findDangerousChanges } from 'graphql';
import { readFileSync } from 'node:fs';
const [oldSdl, newSdl] = process.argv.slice(2).map((f) => readFileSync(f, 'utf8'));
const breaking = findBreakingChanges(buildSchema(oldSdl), buildSchema(newSdl));
const dangerous = findDangerousChanges(buildSchema(oldSdl), buildSchema(newSdl));
for (const c of dangerous) console.warn('DANGEROUS', c.type, c.description);
for (const c of breaking) console.error('BREAKING ', c.type, c.description);
process.exit(breaking.length ? 1 : 0);
flowchart LR
  PR["PR đổi schema"] --> B["Dựng schema mới"]
  B --> C["So với schema production"]
  C -->|"breaking"| X["Chặn merge: cần deprecate trước hoặc duyệt có chủ đích"]
  C -->|"dangerous"| W["Cảnh báo + báo trước cho client"]
  C -->|"chỉ an toàn"| OK["Cho merge"]
  OK --> U["Theo dõi usage field đã deprecated"]

Công cụ so sánh của graphql-js chỉ thấy thay đổi về hình dạng. Nó không thấy thay đổi về ý nghĩa: đổi đơn vị của price từ nghìn đồng sang đồng, đổi thứ tự sắp xếp mặc định, đổi khi nào field trả null. Những thay đổi đó cần review của người. Công cụ có thống kê usage và registry: GraphQL Inspector, GraphQL Hive, Apollo GraphOS (chưa đánh giá trong lab này).

Cách GitHub làm (API công khai): báo trước breaking change "at least three months", áp dụng vào ngày đầu quý, và chia đúng hai loại Breaking / Dangerous như graphql-js (GitHub Docs, Breaking changes).

3.5 Federation: query plan, entity và reference resolver

# subgraph users — sở hữu User
extend schema @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key", "@shareable"])
type Query { me: User }
type User @key(fields: "id") { id: ID! name: String! }
# resolver: User.__resolveReference({ id }) → tìm user theo id

# subgraph posts — sở hữu Post, GÓP thêm field User.posts
type Query { posts(first: Int = 20): [Post!]! }
type Post @key(fields: "id") { id: ID! title: String! author: User! writer: User }
type User @key(fields: "id") { id: ID! posts: [Post!]! }
# resolver: Post.author = (p) => ({ __typename: 'User', id: p.authorId })   ← chỉ trả "tham chiếu"
# lab 07 — query { posts(first: 20) { title author { name } } }
QueryPlan {
  Sequence {
    Fetch(service: "posts") {
      { posts(first: 20) { title author { __typename id } } }
    },
    Flatten(path: "posts.@.author") {
      Fetch(service: "users") {
        { ... on User { __typename id } } =>
        { ... on User { name } }
      },
    },
  },
}
request tới subgraph: {"posts":{"http":1,"representations":[]},"users":{"http":1,"representations":[20]}}

Router gọi posts trước, lấy được 20 tham chiếu { __typename: "User", id }, rồi gửi một request _entities(representations: [...]) sang users với cả 20 tham chiếu — dù chỉ có 5 user khác nhau (router không gộp trùng).

N+1 không biến mất — nó dời vào subgraph:

__resolveReference không có DataLoader → "câu SQL" ở subgraph users: 20
__resolveReference qua DataLoader      → "câu SQL" ở subgraph users: 1

Router đã gom thành một request, nhưng thư viện subgraph gọi __resolveReference một lần cho mỗi representation. Viết __resolveReference bằng một câu SELECT … WHERE id = $1 là N+1 ở subgraph. Luôn đi qua DataLoader như bài 3.

Mỗi lần đổi subgraph là thêm một lượt nối tiếp: query { me { name posts { title author { name posts { title } } } } } cho kế hoạch 3 bước: users (me) → posts (me.posts, author.posts) → users (tên author). Độ trễ ≥ tổng 3 lượt. Thiết kế ranh giới subgraph sao cho các field hay đi cùng nhau nằm cùng subgraph.

Một subgraph chết thì sao:

HTTP 200 | data = null
  error: request to http://localhost:37739/ failed, reason: connect ECONNREFUSED 127.0.0.1:37739 | code: INTERNAL_SERVER_ERROR
dùng field nullable "writer: User" → HTTP 200 | data = {"posts":[{"title":"post 1","writer":null},{"title":"post 2","writer":null}]}
query KHÔNG cần users → HTTP 200 | data = {"posts":[{"title":"post 1"},{"title":"post 2"}]}
Subgraph users chết: field non-null và field nullable ở ranh giới Bên trái field author kiểu User non-null: lỗi ở author nổi qua post, qua posts, tới data, kết quả data bằng null và mất cả 20 bài. Bên phải field writer kiểu User nullable: chỉ writer thành null, các bài vẫn hiện. author: User! (non-null) writer: User (nullable) data posts: [Post!]! post 1…20 author data posts: [Post!]! post 1…20 writer users: chết users: chết data: null writer: null mất cả 20 bài + lộ URL nội bộ trong errors 20 bài vẫn hiện, chỉ thiếu tên tác giả
Hình 4 — Lab 07 mục D. Null lan truyền (bài 2) gặp federation: field non-null ở ranh giới subgraph biến một subgraph chết thành cả trang trắng. Quy tắc: mọi field mà giá trị phải lấy từ subgraph khác nên nullable. Ngoài ra message lỗi của gateway JS lộ địa chỉ subgraph — phải che như mọi lỗi nội bộ khác (Apollo Router có tuỳ chọn include_subgraph_errors để kiểm soát việc này — chưa dựng lab Router).

Composition bắt xung đột trước khi chạy:

[INVALID_FIELD_SHARING] Non-shareable field "User.name" is resolved from multiple subgraphs: it is resolved from subgraphs "crm"
 and "users" and defined as non-shareable in all of them

Hai team cùng khai báo User.name mà không ai đánh dấu @shareable. Lỗi này nên nổ ở CI (bước compose), không phải lúc deploy router. Vì vậy Apollo ghi rõ trong docs: "We strongly recommend against using IntrospectAndCompose in production" — lab dùng nó cho nhanh, production nên compose sẵn supergraph trong CI (rover, Hive composition) rồi mới đưa cho router.

3.6 Subscription và realtime

Subscription giữ một kết nối lâu dài (WebSocket theo giao thức graphql-ws, hoặc SSE) để server đẩy sự kiện. Nó khác query ở chỗ có trạng thái: mỗi kết nối chiếm bộ nhớ và file descriptor, scale ngang cần pub/sub chung (Redis...) để sự kiện tới đúng instance đang giữ kết nối, deploy phải xử lý kết nối đang mở. Lighthouse làm subscription qua driver kiểu Pusher/Laravel Echo. Phần này chưa dựng lab — nhiều team chọn polling hoặc SSE thường cho đơn giản, chỉ dùng subscription khi độ trễ cập nhật thật sự quan trọng.

3.7 Nâng cấp thư viện: hai bẫy có thật

# graphql 17 đã ra, nhưng Apollo Server 5 vẫn chỉ nhận graphql 16 (labs/graphql/federation/eresolve.sh)
npm error code ERESOLVE
npm error Found: graphql@17.0.2
npm error Could not resolve dependency:
npm error peer graphql@"^16.11.0" from @apollo/server@5.5.1

# @apollo/subgraph 2.9.0 nhận buildSubgraphSchema({ typeDefs, resolvers }); 2.15.1 chỉ nhận mảng hoặc DocumentNode
buildSubgraphSchema({ typeDefs, resolvers }) → TypeError: doc.definitions is not iterable (cannot read property undefined)

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

5. Failure modes

Hỏng thế nàoLabHệ quảPhát hiệnChữa
Dashboard xanh, người dùng thấy lỗi06b-AOperation lỗi 100%, 0 HTTP 5xxĐếm errors[] theo operationPlugin metric, mã lỗi chuẩn
APQ trượt ở instance khác06-GThêm 1 lượt mạng mỗi request; nặng sau deployTỉ lệ PERSISTED_QUERY_NOT_FOUNDCache APQ dùng chung
Một field làm cả response no-store06b-BTỉ lệ trúng CDN rơi về 0Theo dõi hit ratio CDN theo operationGợi ý cache cho mọi field công khai; lint schema
Breaking change lọt ra05App bản cũ lỗi "Cannot query field"Lỗi validate tăng theo phiên bản clientSchema check trong CI
Thêm giá trị enum05App cũ crash ở switchCrash report phía clientBáo trước; client có nhánh giá trị lạ
Subgraph chết07-Ddata: null, lộ URL nội bộLỗi gateway theo subgraphField ranh giới nullable; che lỗi subgraph
N+1 ở reference resolver07-B20 câu SQL thay vì 1Đếm SQL ở subgraphDataLoader trong __resolveReference
Xung đột composition07-EKhông ghép được supergraphCI compose@shareable hoặc chuyển quyền sở hữu field
Nâng cấp vỡeresolve, 07-FKhông cài được / chết lúc khởi độngCI cài sạch + smoke testGhim version, nâng theo cả hệ sinh thái

6. Vận hành trong production

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

Kiến trúcSở hữu theo teamLượt mạng thêmĐộ phức tạp vận hànhCô lập lỗiLicense / lock-in
Một server GraphQL (module theo domain)Theo thư mục/CODEOWNERS0ThấpThấp (một process)Tuỳ server
Schema stitchingTheo serviceCóTrung bình; logic ghép nằm ở gatewayTrung bìnhThư viện mở
Federation — Apollo Router / GatewayTheo subgraphTheo query planCaoCao (nếu field ranh giới nullable)Router & gateway: Elastic License 2.0
Federation — router mởTheo subgraphTheo query planCaoCaoHive Gateway MIT; WunderGraph Cosmo Apache-2.0; Grafbase MPL-2.0
BFF theo client (REST)Theo client0–1Trung bìnhCaoKhông

Elastic License 2.0 cho phép dùng nội bộ và sửa đổi, nhưng không cho đem phần mềm cung cấp lại cho bên thứ ba như một dịch vụ được quản lý (managed service). Với đa số công ty dùng nội bộ thì không vướng; với công ty bán nền tảng thì phải đọc kỹ. Hive composition tự giới thiệu là thay thế "drop-in" cho composition của Apollo (README) — nghĩa là supergraph không bị trói vào một hãng.

Dùng federation khi: từ khoảng 3–4 team trở lên cùng sở hữu các domain rõ ràng và schema nguyên khối đã thành nút thắt về lịch deploy. Tránh khi: 1–2 team — một server GraphQL chia module là đủ, đỡ cả một tầng mạng, một hệ thống compose và một nguồn lỗi. Nếu đi theo đường federation, học cách Netflix: biến monolith thành subgraph đầu tiên, rồi tách dần từng phần.

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

ADR — Federation cho API khách hàng
Bối cảnh: 5 team (catalog, cart, order, payment, account) cùng sửa 1 schema; lịch deploy GraphQL là nút thắt.
Quyết định: Federation 2; monolith hiện tại là subgraph đầu tiên; tách dần theo domain. Router: chọn sau khi so license
  (Apollo Router — Elastic-2.0; Hive Gateway — MIT; Cosmo — Apache-2.0) và năng lực vận hành của team platform.
Bắt buộc: compose trong CI (không compose lúc chạy); field lấy từ subgraph khác phải nullable; DataLoader trong mọi
  __resolveReference; metric theo operation + subgraph; schema check chặn breaking change.
Hệ quả: thêm 1 tầng mạng và 1 dịch vụ hạ tầng phải trực; team tự chủ lịch deploy.
Xem lại khi: số team giảm, hoặc p99 tăng quá ngân sách vì số lượt nối tiếp.

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

  1. Version GraphQL theo URL (/v2/graphql) — mất đúng lợi thế "không version", lại gánh hai schema.
  2. Xoá field không có dữ liệu usage — lab 05: app cũ lỗi Cannot query field "name".
  3. Monitor bằng HTTP status — lab 06b.
  4. Cache APQ trong RAM sau load balancer — lab 06 G.
  5. Federation cho hai team — "monolith phân tán": thêm độ trễ và điểm hỏng mà không thêm tự chủ.
  6. Reference resolver không batch — lab 07 B.
  7. Field non-null ở ranh giới subgraph — lab 07 D.
  8. Compose lúc chạy ở production (IntrospectAndCompose) — Apollo khuyên rõ là không.
  9. Trả nguyên lỗi subgraph cho client — lộ địa chỉ nội bộ (lab 07 D).

Bài học công khai: Netflix (Netflix Tech Blog, 2020) — bắt đầu federation bằng cách coi monolith là một subgraph, chuyển field sang các Domain Graph Service từng phần mà không làm vỡ client; GitHub — chính sách breaking change công khai, báo trước ít nhất 3 tháng, theo quý; Apollo — Federation 2 (GA 04/2022) viết lại composition và query planning, tương thích ngược để chuyển từng subgraph một.

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

GraphQL trả 200 khi lỗi — monitor thế nào?
Plugin ghi metric theo operationName, đếm errors[] và mã lỗi; alert theo tỉ lệ operation có lỗi, không theo 5xx.
Cache GraphQL ở CDN được không?
Được với query qua GET + persisted query cho URL ngắn + Cache-Control từ @cacheControl. Cẩn thận: một field không gợi ý là no-store cả response; dữ liệu theo người dùng phải PRIVATE.
Làm sao xoá một field an toàn?
Expand → migrate → contract: thêm field mới, @deprecated field cũ, đo usage theo client + phiên bản, xoá khi về 0; CI chặn breaking change ngoài kế hoạch.
Thêm giá trị enum có phải breaking không?
graphql-js xếp vào "dangerous", nhưng app cũ có thể crash (lab 05). Báo trước như breaking, client phải có nhánh giá trị lạ.
Federation khác schema stitching thế nào?
Stitching: gateway chứa logic ghép. Federation: mỗi subgraph tự khai báo phần mình sở hữu (@key, field góp vào entity), router chỉ lập kế hoạch theo khai báo; xung đột bị bắt ở bước compose.
Federation có N+1 không?
Router gom tham chiếu thành một request _entities, nhưng subgraph gọi reference resolver cho từng tham chiếu — phải dùng DataLoader (lab: 20 → 1).
Một subgraph chết thì trang có trắng không?
Tuỳ nullability ở ranh giới: non-null thì null lan tới data (lab: data = null); nullable thì chỉ thiếu phần đó.
Vendor lock-in với Apollo?
Spec GraphQL và federation có nhiều bên cài đặt; Apollo Router/Gateway dùng Elastic License 2.0, có router MIT/Apache thay thế; giữ subgraph đúng spec và compose bằng công cụ thay được.

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

Môi trường: graphql-js 17.0.2 (lab 05); Apollo Server 5.5.1, @apollo/subgraph 2.15.1, @apollo/gateway 2.14.4 (Elastic-2.0), graphql 16.14.2, DataLoader 2.2.3 (lab 06, 06b, 07); Node 22.23.3 trong node:22-bookworm-slim. Output: labs/graphql/out/05-schema-evolution.txt, 06-apollo-http.txt, 06-apollo-http.production.txt, 06b-apollo-cache-metrics.txt, 06-graphql17-eresolve.txt, 07-federation.txt.

cd labs/graphql/node
./run.sh 05-schema-evolution.mjs

cd ../federation
docker run --rm -v "$PWD":/lab -w /lab node:22-bookworm-slim npm ci     # graphql 16 + Apollo (Apollo chưa nhận graphql 17)
./run.sh 06-apollo-http.mjs
./run.sh 06b-apollo-cache-metrics.mjs
./run.sh 07-federation.mjs
./eresolve.sh                                                            # tái hiện xung đột graphql 17 với @apollo/server 5

Chưa verify: Apollo Router (Rust) và tuỳ chọn include_subgraph_errors; Hive Gateway, Cosmo, Grafbase chạy thật; cache APQ dùng Redis; subscription (graphql-ws, Lighthouse + Pusher); Lighthouse làm subgraph federation; bảng giá các nền tảng quản lý schema.