public-graphql-api-design
把第三方开发者使用的公共 GraphQL API 从「要不要上 GraphQL」的判断一路设计到 schema 约定、Relay 游标分页、联合类型错误结果、深度与复杂度上限、联邦信任边界和持久化查询策略。适用于面向外部开发者的 GraphQL 接口设计与评审,让 schema 可演进、平台不被滥用查询拖垮。
npx skills add https://github.com/samber/developer-platform-skills --skill public-graphql-api-designBefore / After 效果对比
2 组团队在没有统一方法时自行摸索:命名与可空性反复推翻,分页和错误结构各写一套,评审三四轮仍定不下来,设计与评审合计往往要耗掉一整个工作日以上。
该 Skill 按门禁、约定、分页、错误面、护栏、信任边界七步推进,待定项一次问清并落档,评审集中在真正的取舍上,设计定稿时间压缩到一两个小时。
Public GraphQL API Design
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.
Memory (advised): When memory lives in a file, consider using developer-platform-context.md; if a different memory system is in use, rely on that instead. The file is an advisory reference, not a mandatory requirement. Separate task info in different sections. Remove finished tasks. Add a date to a task; no date for general project context. Some interview responses may differ between 2 tasks.
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.
- Greenfield schema or retrofit? If retrofit, request the current SDL and 5-10 real production queries.
- 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.)
- Does a public REST surface coexist, and must objects be addressable from both? (Drives the ID strategy - see step 2.)
- Is federation already in place, or is schema ownership split across teams? (Drives step 6.)
- 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.
- 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.
Audience split: first-party vs third-party
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.
Workflow
- Gate: does this API belong on GraphQL at all?
- Set schema conventions - naming, types, nullability, IDs.
- Design pagination - Relay cursor connections.
- Design the error surface - result types over bare errors.
- Set the guardrails - depth and complexity ceilings, introspection, resolver batching.
- Draw the federation trust boundary.
- Set the persisted-query policy.
- Hand off evolution.
Each step has a section below, in order.
1. The GraphQL-or-not gate
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).
2. Schema conventions
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/canprefixes on booleans. - Mutations model business actions, not CRUD:
publishPost,cancelOrder- never one monolithicupdateOrderhandling 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
inputtype 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
Nodeinterface (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'snode_idinterop is the worked example (see References). - Expose object references, never foreign-key fields:
author: User!, notauthorId: 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, andTeamMembermay sharename/emailyet 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.
3. Pagination
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/afterarguments). 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 (
nodesplusendCursor, 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 fulledges, so it is common practice with named public precedent, not an unwritten convention - but it is still a shortcut through the Relay spec's optionalnodesfield, 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.
4. The error surface
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
errorsfor 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 Typefragments 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.
5. The guardrails
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.
- **Intr
...
用户评价 (0)
发表评价
暂无评价
统计数据
用户评分
为此 Skill 评分