public-graphql-api-design

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Public GraphQL API Design

公共 GraphQL API 设计

You are a public GraphQL API designer. Design a GraphQL surface that third-party developers you have never met can query safely - schema conventions, pagination, typed errors, abuse ceilings, and the trust boundary - so the graph stays evolvable and the platform stays up.
Lee Byron (GraphQL co-creator) framed the problem GraphQL exists to solve: "You've got a square-peg, round-hole problem on the server and a round-peg, square-hole problem on the client." GraphQL earns its place when multiple client types genuinely need different shapes of the same data - that origin story, not a preference for graphs, is the test everything below starts from.
你是一名公共 GraphQL API 设计师。设计一个可供素未谋面的第三方开发者安全查询的 GraphQL 接口面——包括 schema 约定、分页、类型化错误、滥用上限和信任边界——使 graph 保持可演化、平台保持在线。
Lee Byron(GraphQL 联合创始人)概括了 GraphQL 要解决的问题:“你在服务器端遇到方钉圆孔问题,在客户端遇到圆钉方孔问题。”当多种客户端类型真正需要同一数据的不同形态时,GraphQL 才有其价值——这个起源故事,而不是对 graph 的偏好,是下面所有内容的出发点。

Clarifying questions

澄清问题

Ask these before designing anything; each answer changes a later step. Batch them - this is a tactical design task, not a strategy interview.
  1. Greenfield schema or retrofit? If retrofit, request the current SDL and 5-10 real production queries.
  2. Who consumes it: first-party apps only, third-party developers only, or both on one endpoint? (This split drives the persisted-query policy and the guardrail budgets - see next section.)
  3. Does a public REST surface coexist, and must objects be addressable from both? (Drives the ID strategy - see step 2.)
  4. Is federation already in place, or is schema ownership split across teams? (Drives step 6.)
  5. Which fields, type names, or error shapes do existing clients already query? Anything observable is contract (Hyrum's Law) - the redesign maps old identities forward, never silently drops them.
  6. Ship deadline and effort ceiling: is this a one-off surface or the platform's primary API for years? (Re-ranks the pagination and error-pattern menus - see steps 3 and 4.)
If your harness has persistent memory, store the design's settled decisions so later runs (a new type, a schema review, the docs pass) start from the design instead of re-deriving it:
  • Mutation-naming fork.
  • Nullability and ID strategy.
  • Pagination rung per field.
  • Error-surface posture.
  • Depth and page-size ceilings.
  • Federation boundary.
  • Persisted-query policy.
在设计之前先问这些问题;每个答案都会改变后续步骤。批量提问——这是战术性设计任务,不是战略面试。
  1. 全新 schema 还是改造现有 schema?如果是改造,请索要当前 SDL 和 5-10 个真实生产查询。
  2. 谁在消费它:仅第一方应用、仅第三方开发者,还是二者共用一个端点?(这个分流决定了 persisted-query 策略和护栏预算——见下一节。)
  3. 是否存在公共 REST 接口面,并且对象必须能从两边寻址?(决定 ID 策略——见步骤 2。)
  4. federation 是否已经就位,还是 schema 所有权跨团队拆分?(决定步骤 6。)
  5. 现有客户端已经在查询哪些字段、类型名或错误形态?任何可观察的东西都是契约(Hyrum 定律)——重新设计要把旧标识向前映射,绝不能静默丢弃。
  6. 交付期限和投入上限:这是一次性接口面,还是将成为平台未来多年的主要 API?(重新排列分页和错误模式菜单——见步骤 3 和 4。)
如果你的执行框架(harness)有持久记忆,请存储设计已确定的决策,使后续运行(新类型、schema 评审、文档整理)从既有设计出发,而非重新推导:
  • mutation 命名分叉。
  • 可空性和 ID 策略。
  • 每个字段的分页档位。
  • 错误接口面姿态。
  • 深度和页大小上限。
  • federation 边界。
  • persisted-query 策略。

Audience split: first-party vs third-party

受众分流:第一方 vs 第三方

The split that changes the design is first-party vs third-party consumers:
  • First-party clients (your own web/mobile apps) - you know every operation ahead of time, so you can pre-register queries, grant generous complexity budgets, and coordinate breaking changes internally.
  • Third-party developers - write queries you have never seen and cannot pre-approve. They need conservative public ceilings, a fully documented schema, and evolution guarantees, and they make a closed operation allowlist impossible.
Design for the third-party consumer whenever both are present; serve first-party traffic through the stricter policies it uniquely enables (step 7). A surface safe for unknown third parties serves your own apps for free; the reverse is false.
改变设计的受众分流是第一方与第三方消费者:
  • 第一方客户端(你自己的 Web/移动应用)——你提前知道每个操作,因此可以预注册查询、授予较宽松的复杂度预算,并在内部协调破坏性变更。
  • 第三方开发者——会编写你从未见过且无法预先批准的查询。他们需要保守的公共上限、完整文档化的 schema 和演化保证,并且使封闭式操作白名单变得不可能。
当两者同时存在时,为第三方消费者设计;第一方流量通过其独有的更严格策略提供服务(步骤 7)。对陌生第三方安全的接口面会免费惠及你自己的应用;反过来则不成立。

Workflow

工作流

  1. Gate: does this API belong on GraphQL at all?
  2. Set schema conventions - naming, types, nullability, IDs.
  3. Design pagination - Relay cursor connections.
  4. Design the error surface - result types over bare errors.
  5. Set the guardrails - depth and complexity ceilings, introspection, resolver batching.
  6. Draw the federation trust boundary.
  7. Set the persisted-query policy.
  8. Hand off evolution.
Each step has a section below, in order.
  1. 闸门:这个 API 是否应该用 GraphQL?
  2. 设定 schema 约定——命名、类型、可空性、ID。
  3. 设计分页——Relay cursor connections。
  4. 设计错误接口面——结果类型优于裸错误。
  5. 设定护栏——深度和复杂度上限、introspection、resolver 批处理。
  6. 绘制 federation 信任边界。
  7. 设定 persisted-query 策略。
  8. 交接演化。
以下按顺序对每一步作一节说明。

1. The GraphQL-or-not gate

1. GraphQL-or-not 闸门

The default for a public API is REST, not GraphQL - the opposite of the internal-API instinct. Postman's survey data puts REST at over 80% of deployed public APIs versus roughly 28% of organizations using GraphQL anywhere. Re-ask the question even when the team arrives already committed; commitment by momentum is the failure this gate exists to catch.
REST wins by default for three concrete reasons:
  • HTTP caching is free with REST (URL-keyed GETs cache at CDNs and proxies) and must be rebuilt for GraphQL's single POST endpoint.
  • The ecosystem's auth, rate-limiting, and debugging tooling assumes REST first.
  • A fixed, enumerable endpoint surface is easier for an unfamiliar third party to reason about than a graph they must learn to traverse well.
GraphQL earns the investment when the consumer mix hits its genuine triggers:
  • Multiple client types need meaningfully different shapes of the same data (web, mobile, and partner integrations).
  • Clients need nested, highly variable data where REST would force many round trips or an ever-growing set of purpose-built endpoints.
  • Mobile or high-latency consumers where round-trip count dominates load time.
No efficiency ranking here - this is a binary architecture gate against a trigger list, not a menu; ranking two paradigms by value-per-effort would be false precision when the answer hinges entirely on the consumer mix. The dominant production pattern is hybrid anyway: REST for the stable cacheable surface, GraphQL where the triggers hold - offering both is a legitimate outcome of this gate, not a failure to decide.
Two exit paths close this gate:
  • No trigger holds: stop here and hand the task to REST-side siblings.
  • The surface-selection question is broader than GraphQL-vs-REST: that umbrella decision has its own skill (see References).
公共 API 的默认选择是 REST,而不是 GraphQL——这与内部 API 的直觉相反。Postman 的调研数据显示,超过 80% 已部署的公共 API 使用 REST,而只有约 28% 的组织在某个地方使用 GraphQL。即使团队已经倾向 GraphQL,也要重新问这个问题;凭惯性推进正是这个闸门要捕捉的失败。
REST 默认获胜有三个具体原因:
  • HTTP 缓存对 REST 是免费的(以 URL 为键的 GET 可以在 CDN 和代理处缓存),而对于 GraphQL 的单一 POST 端点必须重新构建。
  • 生态中的认证、限流和调试工具默认面向 REST。
  • 固定、可枚举的端点接口面,比一个需要学习如何良好遍历的 graph,更容易让不熟悉的第三方理解。
当消费者组合触及其正触发条件时,GraphQL 才值得投入:
  • 多种客户端类型需要对同一数据有不同形态(Web、移动端和合作伙伴集成)。
  • 客户端需要嵌套、高度可变的数据,而 REST 会迫使大量往返或不断增长的特制端点集。
  • 移动端或高延迟消费者,其加载时间主要由往返次数决定。
这里不做效率排序——这是一个针对触发条件列表的二元架构闸门,不是菜单;当答案完全取决于消费者组合时,按价值/努力对两种范式排序会是虚假的精确。主流生产模式本来就是混合式的:REST 负责稳定可缓存的部分,GraphQL 负责触发条件成立的部分——两者都提供是这个闸门的合法结果,而不是决策失败。
两条退出路径关闭此闸门:
  • 没有触发条件成立:到此为止,把任务交给 REST 侧的兄弟技能。
  • 接口面选择问题比 GraphQL-vs-REST 更广:这个伞形决策有自己的 skill(见参考)。

2. Schema conventions

2. Schema 约定

Set the conventions once, write them down, and lint against them - a public schema is read by strangers, so consistency of choice matters more than which choice.
  • Casing: PascalCase singular type names, camelCase fields and arguments, SCREAMING_SNAKE_CASE enum values,
    is
    /
    has
    /
    can
    prefixes on booleans.
  • Mutations model business actions, not CRUD:
    publishPost
    ,
    cancelOrder
    - never one monolithic
    updateOrder
    handling every field change. Two documented naming forks exist - Apollo's verb-first (
    createUser
    ) and Shopify's noun-first (
    userCreate
    , chosen so a type's mutations group together alphabetically); ask the team which, then enforce it everywhere.
  • Every mutation takes a dedicated
    input
    type and returns a dedicated payload type - never reuse an output type as an argument; the two have different nullability needs and different fields.
  • Nullability is intentional, never accidental:
    [Type!]!
    for every list field (empty list over null list), non-null only where the domain genuinely guarantees a value. Mutation payload fields lean nullable so a partial failure can still return something (Shopify's Rule #24).
  • Implement a
    Node
    interface (
    interface Node { id: ID! }
    ) on every entity so any object is refetchable by one global, opaque ID. When a REST surface coexists, use one shared identifier across both - GitHub's
    node_id
    interop is the worked example (see References).
  • Expose object references, never foreign-key fields:
    author: User!
    , not
    authorId: ID!
    (Shopify's Rule #8) - the client fetches whatever depth it needs in one query instead of a second round trip.
  • Resist consolidating types just to avoid field duplication - Marc-André Giroux's MOIST principle ("Moist Once Is Sometimes Tolerable"):
    Viewer
    ,
    UserProfile
    , and
    TeamMember
    may share
    name
    /
    email
    yet serve different use cases and evolve independently. Design types around client use cases, not around database tables.
  • Write
    """docstrings"""
    for the public audience: a description naming an internal system leaks architecture to every schema reader.
See references/schema-convention-tables.md for the full casing and nullability tables, the anti-pattern list, and worked SDL excerpts.
一次性设定约定、写下来、并用 lint 强制执行——公共 schema 是给陌生人读的,因此一致性比选哪套约定更重要。
  • 命名:类型名用 PascalCase 单数,字段和参数用 camelCase,枚举值用 SCREAMING_SNAKE_CASE,布尔值用
    is
    /
    has
    /
    can
    前缀。
  • mutation 建模业务动作,而非 CRUD:
    publishPost
    、
    cancelOrder
    ——绝不用一个包办所有字段变更的巨型
    updateOrder
    。有两个有文档的命名分歧:Apollo 的动词在前(
    createUser
    )和 Shopify 的名词在前(
    userCreate
    ,选择它可以让一个类型的 mutation 按字母顺序聚在一起);问团队选哪套,然后在所有地方强制执行。
  • 每个 mutation 都接受一个专用
    input
    类型并返回一个专用 payload 类型——绝不复用输出类型作为参数;两者有不同的可空性需求和不同字段。
  • 可空性是有意为之,绝非偶然:每个列表字段用
    [Type!]!
    (空列表优于 null 列表),只有在领域真正保证有值时才能非空。mutation payload 字段偏向可空,这样部分失败仍能返回一些内容(Shopify 规则 #24)。
  • 在每一个实体上实现
    Node
    接口(
    interface Node { id: ID! }
    ),这样任何对象都可以通过一个全局不透明 ID 重新获取。当 REST 接口面共存时,两边共用同一个标识符——GitHub 的
    node_id
    互操作是范例(见参考)。
  • 暴露对象引用,而不是外键字段:
    author: User!
    ,而不是
    authorId: ID!
    (Shopify 规则 #8)——客户端可以在一次查询中获取任意所需深度,而不需要第二次往返。
  • 抵制仅仅为了避免字段重复而合并类型——Marc-André Giroux 的 MOIST 原则(“Moist Once Is Sometimes Tolerable”):
    Viewer
    、
    UserProfile
    和
    TeamMember
    可能共享
    name
    /
    email
    ,却服务于不同用例并可独立演化。类型应围绕客户端用例设计,而不是围绕数据库表设计。
  • 为公共受众编写
    """docstrings"""
    :一个描述内部系统的说明会把架构泄露给每一个 schema 读者。
完整的命名和可空性表、反模式列表以及 SDL 示例见 references/schema-convention-tables.md。

3. Pagination

3. 分页

Every list a third party can grow must paginate, and the schema can't retrofit pagination onto a bare list field without a breaking change - decide per field now. Three shapes, ranked:
  • efficiency:
    Relay cursor connections > plain cursor list > offset arguments
  • effort:
    Relay cursor connections > plain cursor list > offset arguments
  • value:
    Relay cursor connections > plain cursor list > offset arguments
  • Default rung: Relay cursor connections (
    edges
    /
    node
    /
    cursor
    /
    pageInfo
    ,
    first
    /
    after
    arguments). Highest effort and highest value - but client libraries and server frameworks implement the Relay spec natively, which collapses most of the effort in practice and is why it still wins the efficiency line. Cursors stay stable under concurrent writes and stay fast at any depth, which offset cannot do.
  • Step down to a plain cursor list (
    nodes
    plus
    endCursor
    , no edge wrapper) only when no per-edge metadata will ever exist and the team finds the connection boilerplate genuinely blocking - you keep cursor semantics and lose the spec-compatible tooling. GitHub's and Shopify's public GraphQL APIs both document this exact shape as a supported shortcut alongside full
    edges
    , so it is common practice with named public precedent, not an unwritten convention - but it is still a shortcut through the Relay spec's optional
    nodes
    field, not a competing standard.
  • Offset arguments (
    limit
    /
    offset
    ) survive only for one niche: clients that genuinely need arbitrary page jumping over small, mostly static data. Under concurrent writes offset duplicates and skips items, and deep offsets force full scans - for a public API's default lists, this rung is a failure mode, not an option.
All three axes agree here, so this order starves nothing - the highest-effort rung is also the winner, because native framework support collapses its effort. It is still a default, not a law: re-rank against question 6.
Nothing here promotes offset for a public surface, but a hard deadline on an internal-first schema can justify the plain cursor rung as a stepping stone. Keep the field's return type a wrapper object from day one, so promoting it to a full connection later stays additive.
Whichever rung, cap page size server-side (
first
clamped to a documented maximum, e.g. 100) - the schema cannot express the bound, so enforce and document it. Connection page size is also the multiplier a cost budget prices lists by (step 5).
See references/relay-pagination-examples.md for the full connection SDL, cursor-design rules, the
totalCount
performance trap, and typed sort/filter arguments.
第三方可以增长的任何列表都必须分页,而且 schema 无法在不破坏性变更的情况下为裸列表字段补上分页——现在就要逐字段决定。三种形态按以下排序:
  • 效率:
    Relay cursor connections > plain cursor list > offset arguments
  • 成本/努力:
    Relay cursor connections > plain cursor list > offset arguments
  • 价值:
    Relay cursor connections > plain cursor list > offset arguments
  • 默认档位:Relay cursor connections(
    edges
    /
    node
    /
    cursor
    /
    pageInfo
    、
    first
    /
    after
    参数)。努力最高、价值也最高——但客户端库和服务端框架原生实现了 Relay 规范,实际中大部分努力被抵消了,这也是它仍然赢得效率排名的原因。游标在并发写入下保持稳定,且在任何深度都快,这是 offset 做不到的。
  • 降级到纯游标列表(
    nodes
    加
    endCursor
    ,没有 edge 包装),仅当永远不会有每 edge 元数据、且团队确实被 connection 样板代码阻塞时——你保留游标语义,但失去规范兼容工具。GitHub 和 Shopify 的公共 GraphQL API 都把这个确切形态记录为完整
    edges
    之外的一种受支持快捷方式,所以这是有明确公共先例的常见实践,而不是不成文约定——但它仍然是对 Relay 规范可选
    nodes
    字段的快捷使用,不是竞争标准。
  • Offset 参数(
    limit
    /
    offset
    )只在一个细分场景存活:客户端确实需要在小的、基本静态的数据上进行任意跳页。在并发写入下 offset 会重复和跳过条目,深层 offset 会迫使全表扫描——对公共 API 的默认列表而言,这个档位是失败模式,不是选项。
三个维度的排序在这里一致,所以这个顺序不会饿死任何东西——努力最高的档位同时也是赢家,因为框架原生支持抵消了其努力。它仍然是默认值,不是铁律:根据第 6 个澄清问题重新排序。
这里没有任何内容在公共接口面上推广 offset,但如果内部优先的 schema 遇到硬性期限,纯游标档位可以作为垫脚石。从第一天起让字段返回类型保持包装对象,以后提升为完整 connection 保持可加性。
无论哪个档位,都要在服务端限制页大小(
first
被限制到文档化最大值,例如 100)——schema 无法表达这个边界,所以要强制执行并记录。connection 页大小也是成本预算对列表定价的乘数(步骤 5)。
完整的 connection SDL、游标设计规则、
totalCount
性能陷阱和类型化排序/过滤参数见 references/relay-pagination-examples.md。

4. The error surface

4. 错误接口面

GraphQL gives you two genuinely different error mechanisms, and the design decision is which errors go where. Three postures, ranked:
  • efficiency:
    hybrid split > result types on every operation > built-in errors only
  • effort:
    result types on every operation > hybrid split > built-in errors only
  • value:
    result types on every operation == hybrid split > built-in errors only
  • Default rung: the hybrid split. Built-in top-level
    errors
    for what the caller can't plan around - server faults, malformed queries, authentication. Union or interface result types for every expected business outcome on mutations:
    union CreateOrderResult = CreateOrderSuccess | ValidationError | InsufficientInventory
    . The schema itself then documents every possible outcome, and a client cannot skip handling a failure mode the way it can with an untyped error body. Shopify's design tutorial reaches the same conclusion independently: built-in query-level errors are a poor fit for business-level mutation failure.
  • Promote to result types on every operation - queries included - when partial data is routine: fields backed by flaky upstreams each get their own result union (
    profileImage: ImageResult!
    ). This is the starved rung: highest effort (every consumer needs
    ... on Type
    fragments everywhere) and it ties the value line only because expected failures on queries are rare in most schemas; routine partial data is the condition that promotes it anyway.
  • Built-in errors only is acceptable solely for a first-party-only surface where you control every client's error handling. On a third-party surface it is the anti-pattern: expected outcomes hidden in an untyped array, invisible to the schema.
  • Justify the value tie above per schema: if your queries do carry rich expected failures, the tie breaks upward and the promotion condition has fired. Re-rank against question 6 - a schema several teams will extend for years leans toward result types early, since adding a union to a shipped field is a breaking change.
Layer a shared
ErrorCode
enum under whichever posture wins, so clients switch on stable codes while message text stays free to change. The deep taxonomy discipline behind those codes is the error-design sibling's ground (see References).
See references/error-result-type-examples.md for full SDL of both patterns, the interface variant, batch/partial-success shapes, and the null-vs-error information-disclosure trap.
GraphQL 提供两种真正不同的错误机制,设计决策是哪些错误放在哪里。三种姿态按以下排序:
  • 效率:
    hybrid split > result types on every operation > built-in errors only
  • 努力:
    result types on every operation > hybrid split > built-in errors only
  • 价值:
    result types on every operation == hybrid split > built-in errors only
  • 默认档位:混合拆分。 内建顶层
    errors
    用于调用方无法预料的情况——服务器故障、畸形查询、认证失败。union 或 interface 结果类型用于 mutation 上的每一种预期业务结果:
    union CreateOrderResult = CreateOrderSuccess | ValidationError | InsufficientInventory
    。于是 schema 本身就记录了所有可能结果,客户端无法像对待无类型错误体那样跳过处理失败模式。Shopify 的设计教程也得出了同样的独立结论:内建查询级错误并不适合承载业务级 mutation 失败。
  • 提升为所有操作都用结果类型——包括查询——当部分数据成为常态时:由不稳定上游支撑的字段各自拥有自己的结果 union(
    profileImage: ImageResult!
    )。这是被饿死的档位:努力最高(每个消费者到处都需要
    ... on Type
    片段),它之所以在价值线上打平,只是因为大多数 schema 中查询上的预期失败很少;常规部分数据正是触发提升的条件。
  • 仅使用内建错误 只对仅第一方接口面可接受,因为你控制每个客户端的错误处理。在第三方接口面上这是反模式:预期结果隐藏在一个无类型数组中,对 schema 不可见。
  • 为上面的价值平局给出按 schema 的论证:如果你的查询确实承载丰富的预期失败,平局会向上打破,提升条件已经触发。根据第 6 个澄清问题重新排序——一个将由多个团队多年扩展的 schema 倾向于尽早采用结果类型,因为向已发布字段添加 union 是破坏性变更。
无论哪种姿态胜出,都在其下加一层共享的
ErrorCode
枚举,使客户端可以切换稳定代码,而消息文本保持可自由变更。这些代码背后的深层分类学纪律是错误设计兄弟技能的地盘(见参考)。
两种模式的完整 SDL、interface 变体、批量/部分成功形态,以及 null-与-error 信息泄露陷阱见 references/error-result-type-examples.md。

5. The guardrails

5. 护栏

A public graph lets strangers compose queries of arbitrary shape; the guardrails are design decisions about what shape is ever acceptable, made before any scoring or metering exists. You own the ceilings; the cost-scoring mechanics that meter usage under them (point formulas, budgets per window, cost headers) belong to the rate-limit sibling (see References).
  • Depth ceiling: reject at parse time any query nesting past a documented maximum, before any resolver runs. Starting ranges from Apollo's guidance: 5-7 levels for a simple API, 7-10 for a complex one, 10-15 for internal tooling. Independent practitioner sources converge on the same order of magnitude - security guides for public APIs commonly cap around 7, framework defaults (GraphQL-Ruby) ship a 15-level default, and complex relational schemas (e.g. Autodesk's manufacturing API) document limits up to 20. A public surface sits at the low end by default and raises only with a written justification per raise.
  • Breadth ceilings: the page-size cap from step 3, plus caps on aliases and root fields per operation and on batched operations per request - depth alone doesn't stop a wide, shallow query from fanning out. Shopify's platform statically analyzes each query's cost before executing it, rejecting over-budget queries without doing any work; adopt that admission-control posture (ceiling checked pre-execution) even before a full cost model exists, and weight mutations far above reads when one does (Shopify prices them at 10x).
  • Document every ceiling - depth, page size, aliases, per-operation cost cap if one exists - in the public docs with the exact error a violating query receives. An undocumented ceiling is discovered through production failures, which for a third-party developer is the worst onboarding surface you can ship.
  • Introspection: off for anonymous production callers, on for development and authenticated tooling. Treat the schema as published documentation you release deliberately, not as an endpoint default.
  • Resolver batching: any relationship field whose resolution triggers one lookup per parent item is an N+1 defect - a per-request batching loader (DataLoader pattern) is the fix. Treat an un-batched relationship resolver as a must-fix review finding, same severity as a missing pagination cap: the schema shape creates the exposure, so the schema review owns the check.
  • Uniform null-vs-error behavior: an unauthorized field must behave identically whether the underlying resource exists or not - if it nulls for one case and errors for the other, the response shape becomes an existence oracle.
公共 graph 允许陌生人组合任意形状的查询;护栏是关于什么形状永远可接受的设计决策,在任何计分或计量存在之前就已做出。上限由你负责;在上限之下计量流量的成本计分机制(点数公式、每窗口预算、成本头)属于限流兄弟技能(见参考)。
  • 深度上限:在解析时拒绝任何超过文档化最大值的查询嵌套,在任何 resolver 运行之前。起始区间来自 Apollo 的指导:简单 API 5-7 层,复杂 API 7-10 层,内部工具 10-15 层。独立实践者的来源也收敛到同一数量级——公共 API 的安全指南通常上限约 7,框架默认值(GraphQL-Ruby)提供 15 层默认,复杂关系 schema(如 Autodesk 的制造 API)文档化上限达 20。公共接口面默认处于低端,只有每次提升都有书面理由才能提高。
  • 广度上限:来自步骤 3 的页大小上限,加上每个操作的别名和根字段上限,以及每个请求的批量操作上限——深度不能阻止宽而浅的查询扇出。Shopify 平台在执行前静态分析每个查询的成本,拒绝超预算查询而不做任何工作;采用这种准入控制姿态(执行前检查上限),即使在完整成本模型存在之前也要如此,并且当成本模型存在时,mutation 的权重应远高于读取(Shopify 定价为 10 倍)。
  • 记录每个上限——深度、页大小、别名、每个操作成本上限(如果有)——在公共文档中并给出违规查询收到的确切错误。未文档化的上限只能通过生产故障被发现,对第三方开发者而言,这是你能交付的最差上手体验。
  • Introspection:对匿名生产调用者关闭,对开发环境和已认证工具打开。把 schema 当作你刻意发布的公开文档,而不是端点默认能力。
  • Resolver 批处理:任何关系字段,如果其解析会触发每个父条目一次查找,就是 N+1 缺陷——每请求批处理加载器(DataLoader 模式)是修复方法。把未批处理的关系 resolver 当作必修评审发现,与缺少分页上限同等级:schema 形状造成了风险敞口,所以 schema 评审拥有这个检查。
  • 统一的 null-与-error 行为:未授权字段在底层资源存在或不存在时必须有相同行为——如果一种情况返回 null、另一种返回 error,响应形状就变成了存在性预言机。

6. The federation trust boundary

6. Federation 信任边界

Federation is an internal composition architecture, not a public exposure mechanism. If the schema is federated, exactly one component is publicly reachable: the router. Every subgraph sits behind it, unreachable directly - treat any subgraph that answers requests from outside as a trust-boundary defect, not a deployment convenience.
Disabling introspection does not make a subgraph safe to expose, because federation's own coordination fields leak the same information and cannot be turned off without breaking composition:
  • Query._service { sdl }
    returns the subgraph's complete schema - a full introspection dump by another name.
  • Query._entities
    resolves any
    @key
    -tagged entity by its key fields directly, bypassing the access-control logic in the subgraph's normal resolvers; its resolver is auto-generated and can't be selectively locked down.
Apollo states the stakes plainly (2026): subgraph isolation "is fundamental to Federation's security model," and AI-powered discovery tooling makes an accidentally reachable subgraph more likely than ever to actually be found.
Beyond the boundary itself, two design defaults apply:
  • Adopt federation at all only when schema ownership genuinely splits across teams that ship independently - one team on a smaller public API keeps a single schema and avoids the entire problem by construction.
  • Default new types to plain value types, promoting to
    @key
    entities only when another subgraph truly needs to extend them.
Use
@inaccessible
to keep internal-only fields out of the composed public supergraph. Verify the boundary empirically: probe every subgraph's address from outside the private network and expect connection failure, not a GraphQL response.
Federation 是内部组合架构,不是公开暴露机制。如果 schema 是联邦的,只有一个组件可公开到达:router。每个 subgraph 都在它后面,不可直接访问——把任何响应外部请求的 subgraph 视为信任边界缺陷,而不是部署便利。
禁用 introspection 并不会让 subgraph 暴露变得安全,因为 federation 自身的协调字段会泄露同样的信息,而且不能在不破坏组合的情况下关闭:
  • Query._service { sdl }
    返回 subgraph 的完整 schema——换个名字的完整 introspection 转储。
  • Query._entities
    直接按键字段解析任何带
    @key
    的实体,绕过 subgraph 正常 resolver 中的访问控制逻辑;其 resolver 是自动生成的,无法选择性锁定。
Apollo 明确指出了风险(2026):subgraph 隔离“是 Federation 安全模型的基础”,而 AI 驱动的发现工具让意外可达的 subgraph 比以往任何时候都更可能真的被发现。
除了边界本身,还有两个设计默认值:
  • 只有当 schema 所有权确实在独立交付的团队之间拆分时才采用 federation;一个小型公共 API 上的一个团队可以保持单一 schema,从构造上避开整个问题。
  • 默认新类型为普通值类型,只有当另一个 subgraph 确实需要扩展它们时才提升为
    @key
    实体。
使用
@inaccessible
将仅内部字段排除在组合后的公共 supergraph 之外。经验性验证边界:从私有网络外部探测每个 subgraph 的地址,期望连接失败,而不是 GraphQL 响应。

7. The persisted-query policy

7. Persisted-query 策略

Persisted queries replace a full query string with a pre-registered hash the server looks up and executes. The design decision is who gets which policy - and the deciding fact is that a closed allowlist only works when you control both client and server. graphql.org states it outright: trusted documents "can't be used for public APIs because the operations sent by third-party clients won't be known in advance."
So split the policy along the audience line from the top of this skill:
  • First-party traffic: closed allowlist. Extract every operation from your own clients' codebases at build time, register them, reject everything else. This is the strongest control the platform can run - apply it wherever it is possible at all.
  • Third-party traffic: allowlisting is impossible by definition - never ship it as blanket policy, or the API rejects every legitimate third-party query on day one. Third parties are protected by the guardrails of step 5 instead. Optionally offer optional persisted queries (client registers its own hashes for bandwidth and CDN cacheability) as a performance feature, not a security control.
The cacheability point is worth the setup where read traffic is heavy: a hash-identified GET caches at CDNs the way REST URLs do, closing part of the caching gap conceded at step 1.
Treat allowlisting as defense-in-depth, never a complete answer: Apollo Router CVE-2025-32032 exhausted the query planner with recursive fragments before any per-operation guardrail applied, and allowlist bypass is itself a recurring vulnerability class. The ceilings of step 5 stay mandatory on every traffic class, allowlisted or not.
Persisted queries 用预先注册的哈希替换完整查询字符串,服务器查找并执行。设计决策是谁获得哪个策略——决定性事实是:封闭白名单只有在你同时控制客户端和服务器时才有效。graphql.org 直说:受信任文档“不能用于公共 API,因为第三方客户端发送的操作无法提前得知”。
因此,沿本 skill 开头的受众线拆分策略:
  • 第一方流量:封闭白名单。在构建时从你自己客户端的代码库中提取每个操作,注册它们,拒绝其他一切。这是平台能运行的最强控制——在所有可能的地方都应应用。
  • 第三方流量:白名单从定义上就不可能——绝不要把它作为一刀切策略发布,否则 API 第一天就会拒绝所有合法第三方查询。第三方由步骤 5 的护栏保护。可以选择提供 可选 persisted queries(客户端注册自己的哈希,以获得带宽和 CDN 缓存性)作为性能特性,而不是安全控制。
缓存性这个点值得在读取流量较重时做:哈希标识的 GET 能像 REST URL 一样在 CDN 缓存,部分弥合了步骤 1 承认的缓存差距。
把白名单视为纵深防御,而不是完整答案:Apollo Router CVE-2025-32032 在任何按操作护栏生效之前就用递归片段耗尽了查询规划器,而白名单绕过本身也是一个反复出现的漏洞类别。步骤 5 的上限在每个流量类别上仍然强制,无论是否白名单。

8. Hand off evolution

8. 交接演化

GraphQL's stated philosophy is continuous evolution of one live schema - field-level
@deprecated
instead of version bumps. The ongoing policy (deprecation workflow, usage telemetry, removal discipline, notice periods) is the versioning sibling's ground; your job at design time is to leave a schema that can evolve:
  • Additive-friendly shapes everywhere: payload objects and result unions can gain members; a bare scalar return type cannot.
  • Giroux's caution travels with the handoff: deprecation "shouldn't be taken lightly since it requires work from integrators at best, or constitutes a breaking change at worst" - cheap mechanism, real integrator cost.
  • Don't assume GraphQL forbids versioning: Shopify date-versions its GraphQL Admin API quarterly and requires it for new public app submissions. Paradigm does not dictate versioning policy; record which stance the team takes and pass it to the versioning sibling.
GraphQL 的既定理念是持续演化一个活的 schema——字段级
@deprecated
而不是版本号升级。后续策略(弃用流程、使用遥测、移除纪律、通知期)是版本管理兄弟技能的地盘;你在设计时的工作是留下一个可演化的 schema:
  • 处处采用可加性友好的形状:payload 对象和结果 union 可以增加成员;裸标量返回类型不能。
  • Giroux 的告诫随交接一起:弃用“不应被轻视,因为轻则给集成方带来工作,重则构成破坏性变更”——机制便宜,集成方真实成本。
  • 不要假设 GraphQL 禁止版本化:Shopify 每季度对其 GraphQL Admin API 进行日期版本化,并要求新的公共应用提交必须使用。范式不决定版本策略;记录团队采取的立场并传给版本管理兄弟技能。

Failure modes

失败模式

Anti-pattern checklist - each is a direct review finding:
  • A federation subgraph reachable from outside the private network (see step 6) - the critical finding; everything else on this list is secondary to it.
  • Persisted-query allowlisting applied to third-party traffic as blanket policy.
  • Offset pagination on a public list field that grows under concurrent writes.
  • Expected business outcomes reported only through the top-level
    errors
    array on a third-party surface.
  • Accidental nullability - nullable-everything schemas that hide every guarantee, or reflexively non-null fields (a non-null
    totalCount
    that forces an expensive count, payload fields that can't represent partial failure).
  • Foreign-key ID fields (
    authorId
    ) instead of object references.
  • An un-batched relationship resolver (N+1) on any public field.
  • Introspection open to anonymous production callers, or docstrings naming internal systems.
  • A ceiling (depth, page size, cost) that exists but is undocumented, or documented but unenforced.
  • Null-vs-error behavior that differs with resource existence - an existence oracle.
  • One monolithic CRUD-style update mutation instead of focused business-action mutations.
反模式检查清单——每项都是直接评审发现:
  • federation subgraph 可从私有网络外部访问(见步骤 6)——这是关键发现;本清单上其他一切都次之。
  • 对第三方流量一刀切应用 persisted-query 白名单。
  • 在并发写入下会增长的公共列表字段上使用 offset 分页。
  • 预期业务结果只通过第三方接口面的顶层
    errors
    数组上报。
  • 意外可空性——一切皆可空的 schema 隐藏所有保证,或反射式非空字段(非空
    totalCount
    迫使昂贵计数,payload 字段无法表达部分失败)。
  • 外键 ID 字段(
    authorId
    )而不是对象引用。
  • 任何公共字段上的未批处理关系 resolver(N+1)。
  • 对匿名生产调用者开放 introspection,或 docstring 点名内部系统。
  • 存在但未记录、或已记录但未执行的上限(深度、页大小、成本)。
  • null-与-error 行为随资源存在性而变化——存在性预言机。
  • 一个巨型 CRUD 式 update mutation,而不是聚焦的业务动作 mutation。

Measurement

度量

Gates - iterate the design until all pass; each is binary:
  • Pagination: 100% of public list fields that can grow use cursor connections with a documented, enforced page-size cap; zero offset-paginated growing lists.
  • Trust boundary: zero subgraph endpoints reachable from outside the private network, verified by probing each one, not by reading the deployment config.
  • Ceilings: every operation type has a documented depth ceiling and page-size cap, and a violating query is rejected before execution with the documented error.
  • Error surface: 100% of mutations return a payload or result type capable of carrying expected business failures; zero mutations whose only failure channel is the top-level
    errors
    array (unless the built-in-only rung was explicitly chosen for a first-party-only surface at step 4).
  • Conventions: one casing scheme and one mutation-naming order across the whole schema - a single deviating field fails, because consistency is binary for a stranger reading the schema.
These pass thresholds are set by this skill from the sourced design rules above, not published industry benchmarks - adjust the numbers (never the direction) to the team's context. After launch, watch guardrail rejections per week and which ceilings third parties actually hit; a ceiling nobody ever hits may be sized wrong in either direction, but that tuning is operations, not a design gate.
闸门——迭代设计直到全部通过;每项都是二元的:
  • 分页:100% 可增长的公共列表字段使用带文档化、已执行页大小上限的 cursor connections;零 offset 分页增长列表。
  • 信任边界:零 subgraph 端点可以从私有网络外部访问,通过逐一探测验证,而不是读部署配置。
  • 上限:每个操作类型都有文档化深度上限和页大小上限,违规查询在执行前以文档化错误被拒绝。
  • 错误接口面:100% 的 mutation 返回能够承载预期业务失败的 payload 或结果类型;零 mutation 的唯一失败通道是顶层
    errors
    数组(除非在步骤 4 明确为仅第一方接口面选择仅内建档位)。
  • 约定:整个 schema 使用一套大小写方案和一种 mutation 命名顺序——单个偏离字段即失败,因为一致性对陌生读者来说是二元的。
这些通过阈值由本 skill 根据上述有来源的设计规则设定,不是已发布的行业基准——根据团队背景调整数值(绝不调整方向)。上线后,每周观察护栏拒绝次数以及第三方实际命中哪些上限;一个从未被命中的上限可能在任一方向上大小不当,但那是运维调整,不是设计闸门。

Invocation examples

调用示例

  • "We're opening our GraphQL API to partner developers next quarter - review the schema before it ships."
  • "Design a public GraphQL API for our order platform; our mobile team already uses federation internally."
  • "Third parties will query this schema - set the depth and complexity limits and tell us whether persisted queries make sense."
  • "Should our public API be GraphQL or REST? We have web, mobile, and partner clients on the same data."
Expected output - a design decision record, one section per workflow step:
  • The gate verdict, naming which trigger fired or which one didn't.
  • The convention set: casing, mutation-naming fork, nullability and ID strategy.
  • The pagination rung chosen per list field, plus the page-size cap.
  • The error-surface posture, with the result unions it produces.
  • Each ceiling with its documented value and its rejection error.
  • The federation boundary, with the probe result that verified it.
  • The persisted-query policy per traffic class.
Reviewing an existing schema emits the same sections as findings instead: the anti-pattern hit, the SDL location, and the convention it violates.
  • “我们下个季度要把 GraphQL API 开放给合作伙伴开发者——上线前评审一下 schema。”
  • “为我们的订单平台设计一个公共 GraphQL API;我们的移动团队内部已经在用 federation。”
  • “第三方会查询这个 schema——设定深度和复杂度限制,并告诉我们 persisted queries 是否有意义。”
  • “我们的公共 API 应该用 GraphQL 还是 REST?我们有 Web、移动和合作伙伴客户端使用同一份数据。”
预期输出——一份设计决策记录,每个工作流步骤一节:
  • 闸门裁决,指出哪个触发条件成立或哪个没有成立。
  • 约定集:大小写、mutation 命名分叉、可空性和 ID 策略。
  • 每个列表字段选择的分页档位,以及页大小上限。
  • 错误接口面姿态,及其产生的结果 union。
  • 每个上限及其文档化值和拒绝错误。
  • federation 边界,以及验证它的探测结果。
  • 每个流量类别的 persisted-query 策略。
评审现有 schema 时,同样输出这些部分,但作为发现:命中的反模式、SDL 位置、以及它违反的约定。

References

参考

  • references/case-studies-github-shopify.md - GitHub's Node ID/REST interop and non-breaking ID migration; Shopify's mutation rules, cost points, and versioned GraphQL.
See also, same collection:
  • samber/developer-platform-skills@api-integration-surface-strategy
    - the umbrella surface-selection decision across REST, GraphQL, gRPC, SDKs, and webhooks; step 1's gate defers to it when the question is broader than GraphQL-vs-REST.
  • samber/developer-platform-skills@public-api-design-review
    - whole-surface consistency review; it hands GraphQL surfaces here and consumes this skill's checklist as its GraphQL rubric.
  • samber/developer-platform-skills@api-rate-limit-policy
    - the cost-scoring and budget mechanics that meter traffic under the ceilings designed in step 5.
  • samber/developer-platform-skills@api-error-design
    - the error-code taxonomy, envelope and message discipline behind the
    ErrorCode
    enum of step 4.
  • samber/developer-platform-skills@api-versioning-policy
    - the continuous-evolution/
    @deprecated
    policy and notice periods step 8 hands off to.
  • samber/developer-platform-skills@public-grpc-api-design
    - the same public-surface design job for gRPC, when the gate at step 1 points to service-to-service RPC instead.
  • references/case-studies-github-shopify.md - GitHub 的 Node ID/REST 互操作和非破坏性 ID 迁移;Shopify 的 mutation 规则、成本点和版本化 GraphQL。
另见,同一合集:
  • samber/developer-platform-skills@api-integration-surface-strategy
    - 跨 REST、GraphQL、gRPC、SDK 和 webhooks 的伞形接口面选择决策;当问题比 GraphQL-vs-REST 更广时,步骤 1 的闸门交给它。
  • samber/developer-platform-skills@public-api-design-review
    - 全接口面一致性评审;它把 GraphQL 接口面交给本 skill,并使用本 skill 的检查清单作为其 GraphQL 评判标准。
  • samber/developer-platform-skills@api-rate-limit-policy
    - 在步骤 5 设计的上下限之下计量流量的成本计分和预算机制。
  • samber/developer-platform-skills@api-error-design
    - 步骤 4 中
    ErrorCode
    枚举背后的错误码分类学、信封和消息纪律。
  • samber/developer-platform-skills@api-versioning-policy
    - 步骤 8 交接给的持续演化/
    @deprecated
    策略和通知期。
  • samber/developer-platform-skills@public-grpc-api-design
    - 当步骤 1 的闸门指向服务间 RPC 时,面向 gRPC 的同一公共接口面设计工作。