Skills に戻る
samber/developer-platform-skillsチェック済み

SKILL DETAIL

public-graphql-api-design

samber/developer-platform-skills/public-graphql-api-design

Design a public GraphQL API for third-party developers - the GraphQL-or-not gate, schema conventions (naming, nullability, Node interface, input/payload types), Relay cursor-connection pagination, union/interface error result types, depth and complexity ceilings as design decisions, the federation trust boundary (only the router is ever public), and a persisted-query policy that allowlists first-party traffic only. Use whenever the user mentions GraphQL, a public schema, Relay connections, query depth limits, persisted queries, federation, or GraphQL vs REST - even if they never say "GraphQL API design". Design layer only. Do NOT use for a REST surface review - use samber/developer-platform-skills@public-api-design-review instead.

インストール · 318出典を見る

Installation

npx skills add https://github.com/samber/developer-platform-skills --skill public-graphql-api-design

スキルファイル

SKILL.md

最終同期 · 2026/10/23

evals/evals.json›
{
  "skill_name": "public-graphql-api-design",
  "evals": [
    {
      "id": 1,
      "prompt": "I'm the platform lead at Kettleworth Freight. We run a public REST API today - three resources: shipments, carriers, and scan events, all pretty flat, no deep nesting anywhere. About 40 partner integrators poll it, and roughly 70% of our traffic never reaches origin because the GETs are cacheable at the CDN. We have exactly one first-party client, an internal ops dashboard. Our new CTO came back from a conference convinced we should be on GraphQL and I've been told to design our public GraphQL API for a Q3 launch. Where do we start on the schema?",
      "expected_output": "A gate verdict that re-opens the GraphQL-or-not question, checks Kettleworth's consumer mix against the trigger list, finds no trigger holds, names the concrete costs of moving off REST, and hands the work back to REST-surface design rather than delivering an SDL.",
      "files": [],
      "expectations": [
        "Opens with an explicit GraphQL-or-not gate before proposing any schema, type, or SDL.",
        "States that REST, not GraphQL, is the default choice for a public API surface.",
        "Checks the described consumer mix against the named GraphQL triggers (multiple client types needing different shapes of the same data, nested highly variable data that would force many round trips, mobile or high-latency consumers where round-trip count dominates) and reports that none holds for Kettleworth.",
        "Names the loss of free URL-keyed HTTP and CDN caching on a single POST endpoint as a concrete cost, tying it to the 70% cache-served traffic figure given.",
        "Names the ecosystem's auth, rate-limiting, and debugging tooling assuming REST first as a second reason REST wins by default.",
        "Names a fixed, enumerable endpoint surface being easier for an unfamiliar third party to reason about than a graph as a third reason.",
        "Treats the decision as a binary gate against a trigger list rather than scoring GraphQL against REST on an efficiency, effort, or value ranking.",
        "Presents a hybrid outcome (REST for the stable cacheable surface, GraphQL only where a trigger holds) as a legitimate result of the gate rather than a failure to decide.",
        "Does not deliver a full public GraphQL schema or SDL as the primary deliverable for this request.",
        "States that the CTO's existing commitment does not exempt the decision from the gate, since commitment by momentum is what the gate exists to catch.",
        "Recommends handing the work to REST-surface design rather than proceeding to GraphQL schema conventions and pagination."
      ]
    },
    {
      "id": 2,
      "prompt": "Veltrace here. Our public GraphQL endpoint serves two populations: our own iOS and Android apps, and about 900 third-party integrators who build automations on our workflow data. Security just handed down a mandate after an incident: from next quarter the server only executes pre-registered operation hashes and rejects any raw query string, no exceptions, across the whole endpoint. Their follow-on argument is that once every executable operation is known and reviewed, we can retire the depth limit and the complexity budget, because an allowlisted query is by definition a safe query. I have to write the policy doc this week. What goes in it?",
      "expected_output": "A persisted-query policy split by traffic class: a closed allowlist for first-party clients only, optional persisted queries offered to third parties as a performance feature, and an explicit refusal to retire the guardrails on any traffic class.",
      "files": [],
      "expectations": [
        "Rejects applying a closed operation allowlist to third-party traffic as blanket policy.",
        "States the reason third-party allowlisting is impossible: the operations external clients send cannot be known or pre-registered in advance.",
        "Warns that the mandate as written would reject every legitimate third-party query from day one.",
        "Splits the persisted-query policy by traffic class rather than issuing one endpoint-wide rule.",
        "Specifies a closed allowlist for first-party traffic, with every operation extracted from the company's own client codebases at build time, registered, and everything else rejected.",
        "Offers optional persisted queries to third parties as a bandwidth and CDN-cacheability performance feature, explicitly not as a security control.",
        "Refuses to retire the depth ceiling and complexity budget, stating the guardrails stay mandatory on every traffic class, allowlisted or not.",
        "States that third-party traffic is protected by the depth, breadth, and page-size ceilings instead of by allowlisting.",
        "Treats allowlisting as defense-in-depth rather than a complete answer.",
        "Cites a concrete precedent of an attack landing before any per-operation guardrail applies (Apollo Router CVE-2025-32032 exhausting the query planner with recursive fragments) or names allowlist bypass as a recurring vulnerability class.",
        "Notes that a hash-identified GET can be cached at CDNs the way a REST URL is, recovering part of GraphQL's caching disadvantage.",
        "Recommends designing the endpoint for the third-party consumer while serving first-party traffic through the stricter policies it uniquely enables."
      ]
    },
    {
      "id": 3,
      "prompt": "Orrinbank, B2B payments. We run a federated graph: a router plus four subgraphs (accounts, payments, ledger, notifications), each deployed as its own service with its own ingress. A large partner is complaining about router latency on an overnight reporting job and wants to query the payments subgraph directly instead. Our staff engineer says that's fine - he's turned introspection off on that subgraph and put it behind a separate API key, so nobody can discover what's in there. Separately we've started tagging every new type with @key so we keep the flexibility to extend things later. Deployment config shows all four subgraphs on an internal VPC, so I think we're covered either way. Can I sign off on the partner access?",
      "expected_output": "A refusal to expose the subgraph, with the specific federation coordination fields that defeat the introspection-off argument, an empirical probe requirement instead of a config reading, and a correction of the blanket @key default.",
      "files": [],
      "expectations": [
        "Refuses the direct subgraph access and names it the critical finding, ranked above every other issue raised in the message.",
        "States that in a federated architecture exactly one component is ever publicly reachable: the router.",
        "States that disabling introspection does not make a subgraph safe to expose.",
        "Names `Query._service { sdl }` as returning the subgraph's complete schema, defeating the introspection-off argument.",
        "Names `Query._entities` as resolving any `@key`-tagged entity directly by its key fields, bypassing the access-control logic in the subgraph's normal resolvers.",
        "States that the `_entities` resolver is auto-generated and cannot be selectively locked down without breaking composition.",
        "Rejects reading the deployment config as verification and requires probing each subgraph's address from outside the private network.",
        "States the expected probe result is a connection failure, not a GraphQL response.",
        "Rejects tagging every new type with `@key`, defaulting new types to plain value types and promoting them to entities only when another subgraph genuinely needs to extend them.",
        "Recommends `@inaccessible` to keep internal-only fields out of the composed public supergraph.",
        "Proposes serving the partner's reporting job through the router rather than around it.",
        "Frames federation as an internal composition architecture rather than a public exposure mechanism."
      ]
    },
    {
      "id": 4,
      "prompt": "Nimbus Archive - document retention SaaS. Six weeks to our public API launch. Three list fields matter: `documents` (roughly 40 million rows, customers add to it all day long), `auditEvents` (append-only, about 2 billion rows), and `workspaces` (nobody has more than about 30 and they basically never change). Our proposal is `limit`/`offset` on all three because it's an afternoon of work, plus a non-null `totalCount` on each so our UI and our partners' UIs can render 'page 4 of 217'. One more thing: our biggest customer wants `documents(limit: 100000)` in a single call for their nightly sync, so we weren't planning to clamp it. We'll upgrade to proper pagination after launch once the pressure is off. Sanity check us.",
      "expected_output": "A per-field pagination verdict: cursor connections on the two growing lists, offset kept only for the tiny static one, a documented server-side page-size clamp, and a rejection of both the non-null totalCount and the upgrade-later plan.",
      "files": [],
      "expectations": [
        "Rejects offset pagination on `documents` and `auditEvents`, naming duplicated and skipped items under concurrent writes as the mechanism.",
        "States that deep offsets force a full scan, so the cost degrades with page depth.",
        "Rejects the upgrade-after-launch plan by stating a bare list field cannot gain pagination later without a breaking change, so the shape must be decided per field now.",
        "Names Relay cursor connections (`edges`, `node`, `cursor`, `pageInfo`, with `first`/`after` arguments) as the default rung.",
        "States that the Relay rung's high effort is collapsed in practice by native support in client libraries and server frameworks.",
        "Offers the plain cursor list (`nodes` plus `endCursor`, no edge wrapper) as the legitimate deadline step-down, noting it has named public precedent rather than being an invented shortcut.",
        "States the step-down keeps cursor semantics while giving up spec-compatible tooling, and is only available when no per-edge metadata will ever exist.",
        "Requires the field's return type to be a wrapper object from day one so a later promotion to a full connection stays additive.",
        "Allows offset only for `workspaces`, tying it to the named niche of arbitrary page jumping over small, mostly static data.",
        "Requires a server-side clamp on the page-size argument with a documented maximum, rejecting the 100,000-item single call.",
        "States the schema cannot express the page-size bound, so it must be enforced in the resolver and documented next to the field.",
        "Rejects the non-null `totalCount`, offering nullable-and-skipped, a documented estimate, or a separately costed count query instead.",
        "Notes that the page-size cap doubles as the multiplier a cost model prices list fields by."
      ]
    },
    {
      "id": 5,
      "prompt": "Paystead, subscription billing. Our public GraphQL API has two mutations that matter: `subscriptionCreate` and `refundIssue`. Right now every single failure path throws - card declined, plan not eligible for the customer's region, refund already issued against that charge, expired token, and our Postgres statement timeouts all come back identically, as a string in the top-level `errors` array. Integrators complain they can't tell them apart, and one of them parses our message text with a regex, which broke last week when we fixed a typo in it. Our lead's plan is to add an `extensions.code` to every error and call it done. Is that enough?",
      "expected_output": "A hybrid error-surface design: built-in errors for faults and auth, union result types carrying the expected business outcomes on both mutations, and a shared error-code enum underneath.",
      "files": [],
      "expectations": [
        "Rejects the built-in `errors` array as the only failure channel for a third-party surface.",
        "Recommends the hybrid split as the default posture rather than one single mechanism for everything.",
        "Assigns the server fault (Postgres statement timeout), malformed queries, and the authentication failure (expired token) to the built-in top-level `errors`.",
        "Assigns the expected business outcomes (card declined, region ineligibility, duplicate refund) to union or interface result types expressed in the schema.",
        "Writes at least one concrete result union naming its success member and its distinct business-failure members.",
        "States that a union result makes every possible outcome visible in the schema, so a client cannot silently skip a failure mode the way it can with an untyped error body.",
        "Keeps a shared `ErrorCode` enum under the chosen posture so clients switch on stable codes while message text stays free to change.",
        "Identifies the regex-on-message-text breakage as the symptom of having no stable machine-readable code, not as the integrator's mistake.",
        "Declines to promote result types onto every query as well, and names routine partial data from flaky-upstream-backed fields as the condition that would promote it.",
        "Notes that adding a union to an already-shipped field is a breaking change, so a schema several teams will extend for years should adopt result types early.",
        "States that built-in-errors-only is acceptable solely for a first-party-only surface where the team controls every client's error handling.",
        "Requires mutation payload fields to lean nullable so a partial failure can still return something.",
        "Requires raw internal error text (driver messages, SQLSTATE codes, stack traces) to be translated before it reaches a public response."
      ]
    },
    {
      "id": 6,
      "prompt": "Harbourline - we sell a relational CRM and we're opening the graph to our customers' own developers in eight weeks. Current state: the depth limit is 25 because that's what the 'strict' preset in our old proxy used; we have a complexity plugin that computes a cost number for every query and logs it after the response goes out, and the plan is to bill on that number next year; introspection is on for everybody because our DX lead says the playground is the best documentation we have; and `Account.contacts`, `Contact.activities` and `Deal.notes` each issue one database lookup per parent object. Nothing about any limit is in our public docs yet. What has to change before launch?",
      "expected_output": "A guardrail set: a lowered public depth ceiling with sourced ranges, pre-execution admission control replacing post-response logging, breadth ceilings, introspection restricted, N+1 resolvers flagged, and every ceiling documented with its rejection error.",
      "files": [],
      "expectations": [
        "Rejects 25 as a public depth ceiling and lowers it to the low end of the published starting ranges.",
        "Cites the documented starting ranges: roughly 5-7 levels for a simple API, 7-10 for a complex one, 10-15 for internal tooling.",
        "Requires a written justification for each raise above the default rather than a blanket increase.",
        "Requires the depth check to reject at parse time, before any resolver runs.",
        "Rejects post-response cost logging as a guardrail and requires pre-execution admission control, with an over-budget query rejected without doing any work.",
        "Names Shopify's static pre-execution cost analysis as the precedent for that admission-control posture.",
        "Adds breadth ceilings beyond depth: page size, aliases per operation, root fields per operation, and batched operations per request.",
        "States the reason breadth ceilings are needed: depth alone does not stop a wide, shallow query from fanning out.",
        "Prices mutations far above reads when a cost model exists, naming Shopify's 10x weighting.",
        "Turns introspection off for anonymous production callers while keeping it available for development and authenticated tooling.",
        "Requires every ceiling to be published in the public docs together with the exact error a violating query receives.",
        "Flags the per-parent lookups on `Account.contacts`, `Contact.activities` and `Deal.notes` as N+1 must-fix findings at the same severity as a missing pagination cap, prescribing a per-request batching loader.",
        "Leaves point formulas, per-window budgets, and cost headers to rate-limit policy rather than specifying the scoring mechanics here."
      ]
    },
    {
      "id": 7,
      "prompt": "Sablefield, field-service software. We're retrofitting our internal graph into a public one for installers and partner integrators. Here's the part I'm least sure about:\n\n```graphql\ntype Customer {\n  id: ID!\n  accountId: ID!\n  ownerId: ID!\n  addresses: [Address]\n  created_at: String!\n  Active: Boolean\n}\n\ntype Viewer { id: ID! name: String! email: String! }\ntype TeamMember { id: ID! name: String! email: String! role: String! }\n\ntype Mutation {\n  updateCustomer(input: CustomerInput!): Customer\n}\n```\n\nTwo open questions came out of the review. Our architect wants Viewer, TeamMember and the person fields on Customer collapsed into one `User` type, because we're repeating name and email in three places and that's clearly not DRY. And the team is deadlocked between calling the mutation `updateCustomer` or `customerUpdate`. One more: the docstring on `Customer.accountId` currently reads \"mirrors the account_id column synced nightly from NetSuite\". Tell us what to fix.",
      "expected_output": "A convention review that argues against the type merge on MOIST grounds, replaces foreign keys with object references, fixes casing and nullability, splits the monolithic mutation, and returns the naming fork to the team as an interview question rather than a universal answer.",
      "files": [],
      "expectations": [
        "Argues against collapsing `Viewer`, `TeamMember` and the customer person fields into one `User` type purely to remove duplicated `name` and `email`.",
        "Names the MOIST principle (Marc-Andre Giroux) as the counter-argument to reflexive DRY in schema design.",
        "States that types are designed around distinct client use cases that evolve independently, not around the database tables behind them.",
        "Replaces the foreign-key fields `accountId` and `ownerId` with object references such as `account: Account!` and `owner: User!`, citing Shopify's Rule #8.",
        "Changes `addresses: [Address]` to `[Address!]!`, stating an empty list is preferred over a null list with no null items inside.",
        "Renames `created_at` to camelCase `createdAt` and `Active` to a prefixed boolean such as `isActive`.",
        "Replaces the `String` timestamp with a custom domain-validated scalar such as `DateTime`.",
        "Rejects returning the bare output type `Customer` from the mutation and requires a dedicated payload or result type alongside the dedicated input type.",
        "Rejects the monolithic `updateCustomer` shape in favor of focused business-action mutations.",
        "States both naming forks are documented production practice - Apollo's verb-first and Shopify's noun-first - and that the team must pick one and enforce it schema-wide, rather than handing them a universal winner.",
        "Gives Shopify's stated rationale for noun-first: a type's mutations group together when tooling sorts fields alphabetically.",
        "Requires the selection argument (which object changes) to stay separate from the change-data argument, citing Shopify's Rule #22.",
        "Rewrites the NetSuite docstring for the public audience, stating that naming an internal system publishes architecture to every schema reader."
      ]
    },
    {
      "id": 8,
      "prompt": "Loamworks. We've had a public REST API for six years - about 1,100 active integrators - and we're adding a GraphQL surface next to it, not replacing it. Three things I need settled before anyone writes SDL. First, our compliance team keeps asking what happens when we need a breaking change, and I've told them GraphQL doesn't do versions, you just deprecate fields, so the question doesn't apply - I want to be sure that's actually right before I put it in writing. Second, we were going to use `id: Int!` on GraphQL objects holding our numeric primary keys, the same numbers our REST endpoints already use in their paths. Third, our data team says the ID encoding will have to change roughly four months after launch, when the sharding work lands. How do we set this up?",
      "expected_output": "A correction of the no-versioning claim with its counter-example, a single opaque global ID space shared with the existing REST surface, and a client-controlled opt-in path for the coming encoding change.",
      "files": [],
      "expectations": [
        "Corrects the claim that GraphQL forbids versioning, stating the paradigm does not dictate versioning policy.",
        "Names Shopify's quarterly date-based versioning of its GraphQL Admin API as the counter-example.",
        "Records which versioning stance the team takes and hands the ongoing policy - notice periods, deprecation workflow, removal discipline - to versioning-policy work rather than settling it here.",
        "Rejects `Int` for identifiers and requires the `ID` scalar.",
        "Requires a `Node` interface (`interface Node { id: ID! }`) on every entity so any object is refetchable by one global ID.",
        "Requires IDs to be opaque to clients, base64-encoding compound identities rather than exposing raw numeric primary keys.",
        "Requires one shared identifier across the coexisting REST and GraphQL surfaces rather than two separate ID spaces.",
        "Names GitHub's `node_id` REST-to-GraphQL interop as the worked precedent for one ID space serving both surfaces.",
        "Handles the planned encoding change with a client-controlled opt-in mechanism rather than a deadline-driven forced migration, naming GitHub's `X-Github-Next-Global-ID` request header as the precedent.",
        "States the design goal of that migration: any consumer that never persisted a global ID needs no changes at all.",
        "Requires additive-friendly return shapes, noting payload objects and result unions can gain members where a bare scalar return type cannot.",
        "Notes that field-level `@deprecated` is a cheap mechanism carrying real integrator cost, paraphrasing Giroux's caution that deprecation requires work from integrators at best and is a breaking change at worst."
      ]
    }
  ],
  "trigger_queries": [
    { "query": "Design a public GraphQL API for our order platform.", "should_trigger": true },
    { "query": "We're opening our GraphQL schema to partner developers next quarter - review it before it ships.", "should_trigger": true },
    { "query": "Should our public API be GraphQL or REST? We have web, mobile, and partner clients on the same data.", "should_trigger": true },
    { "query": "What depth limit should we set on our public GraphQL endpoint?", "should_trigger": true },
    { "query": "Do persisted queries make sense for an API that external developers build on?", "should_trigger": true },
    { "query": "Strangers can nest our graph as deep as they want. How do we bound that before launch?", "should_trigger": true },
    { "query": "Our subgraphs each have their own load balancer and one partner hits the inventory one directly. Is that a problem?", "should_trigger": true },
    { "query": "Set up Relay cursor connections on our public list fields.", "should_trigger": true },
    { "query": "we're thinking of exposing our federated graph externally, what do we need to lock down first", "should_trigger": true },
    { "query": "Is limit/offset fine for a list field in our public schema, or do we need edges and cursors?", "should_trigger": true },
    { "query": "How should mutation errors be modeled so third-party clients handle every failure case?", "should_trigger": true },
    { "query": "Should our mutations be named createUser or userCreate?", "should_trigger": true },
    { "query": "Our architect wants to merge Viewer, UserProfile and TeamMember into one User type. Thoughts?", "should_trigger": true },
    { "query": "We expose authorId on every type. Is that acceptable for a schema strangers will read?", "should_trigger": true },
    { "query": "Should we turn introspection off in production? Our devs are already complaining about it.", "should_trigger": true },
    { "query": "We need to settle nullability across a schema external integrators will read. Where do we start?", "should_trigger": true },
    { "query": "Can we ever ship a breaking change if we go with GraphQL, or is @deprecated the only tool we get?", "should_trigger": true },
    { "query": "review our SDL before we publish it to external integrators", "should_trigger": true },
    { "query": "How does a third-party client refetch an object when all it kept was the ID?", "should_trigger": true },
    { "query": "We already have a REST API and we're adding a graph next to it. How should IDs work across both?", "should_trigger": true },
    { "query": "What's the right page size cap for a connection field external developers will query?", "should_trigger": true },
    { "query": "our public api lets people ask for basically anything and we have no idea what to cap", "should_trigger": true },
    { "query": "Write the schema conventions doc for our public graph.", "should_trigger": true },
    { "query": "Should business errors go in the errors array or into the schema as types?", "should_trigger": true },
    { "query": "Our mobile team wants one endpoint returning exactly what each screen needs. How do we expose that to partners safely?", "should_trigger": true },
    { "query": "explain what guardrails a public graph needs before we let anyone in", "should_trigger": true },
    { "query": "Federation question: do the subgraphs need their own auth if the router already authenticates?", "should_trigger": true },
    { "query": "We got hit by one query with 400 aliases in it. What's the fix?", "should_trigger": true },
    { "query": "Write the design decision record for our new public GraphQL surface.", "should_trigger": true },
    { "query": "Every relationship field in our published schema fires one database lookup per parent object. Is that a launch blocker?", "should_trigger": true },
    { "query": "third parties will query this schema, set the complexity and depth limits", "should_trigger": true },
    { "query": "what's the standard shape for a paginated field that external devs will be querying", "should_trigger": true },
    { "query": "we want to allowlist every operation that hits our public endpoint", "should_trigger": true },
    { "query": "Should totalCount be non-null on our connections?", "should_trigger": true },
    { "query": "Our docstrings mention the Salesforce nightly sync job. Does that matter once the schema is public?", "should_trigger": true },
    { "query": "Partner devs keep asking for a page-jump argument on our activity feed connection. Should we add one?", "should_trigger": true },
    { "query": "How do we shape a schema several teams can extend for years without breaking integrators?", "should_trigger": true },
    { "query": "planning to open our graph to the public, what's the checklist", "should_trigger": true },
    { "query": "gate check: do we actually need a graph for this API, or are we cargo-culting the big platforms?", "should_trigger": true },
    { "query": "our union result types are getting unwieldy, is there a better error pattern for this", "should_trigger": true },
    { "query": "Design pagination, errors, and abuse limits for a schema external developers will query.", "should_trigger": true },
    { "query": "do we need a dedicated input type for every mutation or can we just reuse the output type", "should_trigger": true },
    { "query": "We ship a mobile app and we also let partners query the same endpoint. Does that change how we design it?", "should_trigger": true },
    { "query": "Our _entities field is reachable from the internet. Is that exploitable?", "should_trigger": true },
    { "query": "how do i document the rejection error a query gets when it's too deep", "should_trigger": true },
    { "query": "Our schema has one updateOrder mutation handling every field change. Is that a problem for external users?", "should_trigger": true },
    { "query": "set the trust boundary for our federated graph before we expose anything", "should_trigger": true },

    { "query": "Audit our REST API's resource URI naming and status-code correctness before launch.", "should_trigger": false },
    { "query": "Write the error-code taxonomy catalog and the RFC 9457 problem-details envelope for our API.", "should_trigger": false },
    { "query": "Set our rate-limit tiers, burst quotas, and the RateLimit response headers we return.", "should_trigger": false },
    { "query": "Write the cost-point formula and the per-window budget our GraphQL clients get metered against.", "should_trigger": false },
    { "query": "Design idempotency-key handling so integrators can retry a failed POST safely.", "should_trigger": false },
    { "query": "What prefix and checksum format should our API keys use, and do we hash or encrypt them at rest?", "should_trigger": false },
    { "query": "Design our OAuth2 consent screen and the scope taxonomy third-party apps request.", "should_trigger": false },
    { "query": "Design our outbound webhook signing scheme and the retry and dead-letter policy.", "should_trigger": false },
    { "query": "How long should our deprecation notice window be, and do we emit the Sunset header?", "should_trigger": false },
    { "query": "Write our proto package layout and the buf breaking-change gates for CI.", "should_trigger": false },
    { "query": "Design a sandbox mode with test-key prefixes and magic values so integrators never touch real money.", "should_trigger": false },
    { "query": "Which languages should get official SDKs, and in what order?", "should_trigger": false },
    { "query": "Plan the information architecture of our developer portal and cut our time to first call.", "should_trigger": false },
    { "query": "Audit our published API reference for missing parameters and undocumented response codes.", "should_trigger": false },
    { "query": "Model our status page components and the incident lifecycle we publish to customers.", "should_trigger": false },
    { "query": "Design Parquet file-drops and warehouse shares for bulk customer data export.", "should_trigger": false },
    { "query": "Design customer-facing SQL access over JDBC with per-tenant compute isolation.", "should_trigger": false },
    { "query": "Curate the tool surface for the MCP server we're shipping to AI agents.", "should_trigger": false },
    { "query": "Which integration surfaces should we build first - REST, webhooks, SDKs, or an MCP server?", "should_trigger": false },
    { "query": "Should we build our own connector marketplace or just join the big platforms' marketplaces?", "should_trigger": false },
    { "query": "Plan our listing as a source connector on the major ELT platforms.", "should_trigger": false },
    { "query": "Give partners per-integration delivery logs with replay and correlation IDs that survive into support tickets.", "should_trigger": false },
    { "query": "Optimize our AppExchange listing's screenshots, tagline keywords, and demo video.", "should_trigger": false },
    { "query": "Pick which technology partners we integrate with and how deep each partnership goes.", "should_trigger": false },
    { "query": "Design the review and approval pipeline for third-party apps submitted to our marketplace.", "should_trigger": false },
    { "query": "Set the revenue share and fee stack for our app marketplace.", "should_trigger": false },
    { "query": "Write the listing content rulebook every app in our marketplace has to meet.", "should_trigger": false },
    { "query": "Plan the founding launch cohort and co-marketing for our marketplace opening.", "should_trigger": false },
    { "query": "Design partner dev-account and sandbox provisioning, from signup through first submitted app.", "should_trigger": false },
    { "query": "Write the interview scorecard for hiring an API product manager.", "should_trigger": false },
    { "query": "How do I move into a developer-platform role from backend engineering?", "should_trigger": false },
    { "query": "We're kicking off a new developer-platform project and don't know where to start.", "should_trigger": false },
    { "query": "Write a GraphQL query with fragments that fetches nested comment threads three levels down.", "should_trigger": false },
    { "query": "Generate TypeScript types from our schema with GraphQL Code Generator.", "should_trigger": false },
    { "query": "Wire up gqlgen's generated resolver stubs to our service layer in Go.", "should_trigger": false },
    { "query": "Fetch product variants from the Shopify Storefront API in our Next.js app.", "should_trigger": false },
    { "query": "Pen-test our GraphQL endpoint for hidden parameters and broken object-level authorization.", "should_trigger": false },
    { "query": "Design our MongoDB collections and indexes for a multi-tenant workspace app.", "should_trigger": false },
    { "query": "Add JSON-LD schema markup to our blog posts so we get rich results.", "should_trigger": false },
    { "query": "Design the Postgres tables and foreign keys for our billing service.", "should_trigger": false },
    { "query": "Our internal admin GraphQL server is slow - profile the resolvers and add a caching layer.", "should_trigger": false },
    { "query": "Migrate our Apollo Client cache configuration to the new major version.", "should_trigger": false },
    { "query": "Deploy Apollo Router on Kubernetes with autoscaling and health checks.", "should_trigger": false },
    { "query": "Write Jest tests covering our GraphQL resolvers.", "should_trigger": false },
    { "query": "Explain the difference between a GraphQL query and a mutation to our new hire.", "should_trigger": false },
    { "query": "Build an internal BFF graph for our own React dashboard - no external consumers, ever.", "should_trigger": false },
    { "query": "Transcode our gRPC service to JSON over HTTP with grpc-gateway.", "should_trigger": false }
  ]
}
references/case-studies-github-shopify.md›
# Case studies: GitHub and Shopify

Two large, long-running, publicly documented production GraphQL APIs - cite these by name instead of inventing hypotheticals. Sources: GitHub Docs (global node IDs and migration guides), Shopify's `graphql-design-tutorial` and developer platform docs.

## GitHub: one identity system across REST and GraphQL

GitHub's REST API exposes a `node_id` on most objects; the same identifier is the `id` field on GraphQL's `Node` interface. The documented workflow: fetch an object via REST, take its `node_id`, and hand it straight to a GraphQL `node(id: ...)` lookup - one ID space serving both APIs, so clients mix surfaces freely. GitHub treats `Node` as its foundational abstraction: "a generic term for an object" reachable by direct lookup or via a connection.

Use this as the worked example whenever the interview says a REST surface coexists (clarifying question 3).

## GitHub: shrinking an ID migration's blast radius

GitHub needed a new global-ID encoding for consistent response-time performance. Instead of a deadline-driven forced migration, it shipped an `X-Github-Next-Global-ID` request header: `1` always returns the new format, `0` returns legacy-or-new depending on the object's creation date.

The key design decision: **any consumer who never stored a global ID anywhere needed no changes at all.** The migration's blast radius shrank by construction to only the clients that had persisted IDs.

Cite this when a schema-evolution discussion needs an ID-format-change precedent: the discipline is "minimize what a breaking change actually breaks," applied to identifiers.

## Shopify: mutation design rules from production

From `graphql-design-tutorial`, distilled from years of production schema evolution:

- **Focused business-action mutations** over one monolithic update: `publishPost`, `cancelOrder`, each with a tightly scoped input and payload.
- **Rule #22** - keep the selection argument (which object) separate from the change-data argument (what changes); a merged input with a nullable `id` blurs filter and target.
- **Rule #24** - mutation payload fields are mostly nullable "unless there is really a value to return in every possible error case"; an all-non-null payload can't represent partial failure.
- **Rule #8** - object references, never ID fields: `author: User!`, not `authorId: ID!`.
- **Rule #9** - name fields for the client's mental model, not the legacy API or the data store.
- **Noun-first mutation naming** (`userCreate`) - deliberately inverted from Apollo's verb-first so a type's mutations group together under alphabetical tooling.
- The tutorial's own reasoning for nullable payloads states that GraphQL's built-in query-level errors are a poor fit for business-level mutation failure - independent corroboration of the union-result-type default.

## Shopify: admission control and cost weighting

Shopify's platform statically analyzes each query's cost **before executing it** - an over-budget query is rejected without doing any work. Clients accrue 50 points/second up to a 1,000-point cap, and **mutations are weighted at 10x** a read of equivalent shape. The design lessons that transfer (the scoring mechanics themselves belong to `samber/developer-platform-skills@api-rate-limit-policy`):

- Check ceilings pre-execution - admission control, not post-hoc billing.
- Price mutations far above reads.
- Expose the computed cost back to the caller so integrators self-regulate instead of discovering ceilings through rejections.

## Shopify: the versioned-GraphQL counter-example

GraphQL's philosophy says continuous evolution, no versions - Shopify versions anyway. Its GraphQL Admin API ships quarterly date-based versions (`YYYY-01`, `YYYY-04`, `YYYY-07`, `YYYY-10`), each supported at least 12 months, and as of 2026 new public app-store submissions **must** use the GraphQL Admin API while the REST Admin API is legacy-only.

Cite this whenever someone claims GraphQL means you don't need a version: paradigm does not dictate versioning policy - Shopify chose one unified date-versioning story across both API surfaces. The policy side lives with `samber/developer-platform-skills@api-versioning-policy`.
references/error-result-type-examples.md›
# Error result-type examples

Worked material for step 4 of the workflow. Source: Apollo's official GraphQL schema guidance on error patterns, corroborated independently by Shopify's `graphql-design-tutorial`.

## The two mechanisms

**Built-in top-level errors** - what every GraphQL server emits without design work:

```json
{
  "data": { "user": null },
  "errors": [
    {
      "message": "Internal server error",
      "path": ["user"],
      "extensions": { "code": "INTERNAL_SERVER_ERROR" }
    }
  ]
}
```

**Right for:**

- Unexpected server faults.
- Authentication/authorization failures.
- Malformed-query validation.

Anything the caller can't plan around.

**Wrong for:**

- Expected business outcomes (item out of stock).
- Operations with several distinct failure modes.
- Errors needing rich typed data.

Those are data, and belong in the schema.

## The union result pattern (default for mutations)

```graphql
type Mutation {
  createOrder(input: CreateOrderInput!): CreateOrderResult!
}

union CreateOrderResult =
  | CreateOrderSuccess
  | ValidationError
  | InsufficientInventory
  | PaymentFailed

type CreateOrderSuccess {
  order: Order!
}
type ValidationError {
  message: String!
  field: String
}
type InsufficientInventory {
  message: String!
  unavailableItems: [OrderItem!]!
}
type PaymentFailed {
  message: String!
  reason: PaymentFailureReason!
  retryable: Boolean!
}
```

Clients handle each branch with `... on TypeName` fragments. The payoff: the possible-outcomes list lives in the schema, not in prose docs a client can ignore - a client cannot accidentally skip a failure mode the way it can with an untyped error body.

## The interface variant

For sharing common error fields across otherwise-distinct error types:

```graphql
interface Error {
  message: String!
}

type NotFoundError implements Error {
  message: String!
}
type PermissionError implements Error {
  message: String!
  requiredRole: Role!
}

union UserResult = User | NotFoundError | PermissionError
```

Useful when several unrelated operations share a small set of failure shapes and clients want one fragment (`... on Error { message }`) matching any of them.

## The error-code enum

Layer a shared enum under either pattern so clients switch on stable codes while `message` text stays free to change:

```graphql
enum ErrorCode {
  VALIDATION_FAILED
  UNAUTHENTICATED
  NOT_FOUND
  INSUFFICIENT_FUNDS
  RATE_LIMITED
}
```

The enum is the schema-level expression of an error-code taxonomy; the deeper taxonomy discipline (catalog ownership, stability contract, documentation anchors) is `samber/developer-platform-skills@api-error-design`'s ground.

## Batch and partial-success shapes

- Batch mutations: return `{ successful: [Item!]!, failed: [BatchError!]! }` instead of failing the whole batch on one bad item - `BatchError` carries an `index` or `itemId` plus a typed `error` union.
- Non-batch partial data (a field backed by a flaky upstream): give each independently fetched field its own result union - `profileImage: ImageResult!` with `union ImageResult = Image | FetchError` - preferred over a nullable field with a sibling error string, because it's typed.

## Disclosure traps this pattern interacts with

- **Null-vs-error as an existence oracle**: if an unauthorized field returns `null` when the resource exists but errors when it doesn't (or vice versa), the response shape distinguishes "exists" from "doesn't" for an attacker. Pick one behavior and apply it uniformly regardless of existence.
- **Raw internal errors**: never surface `Database error: SQLSTATE[23000]...` - translate to a public message plus an `extensions.code` (built-in path) or a typed error object (result-type path). Keep stack traces out of production responses entirely.
references/relay-pagination-examples.md›
# Relay pagination examples

Worked material for step 3 of the workflow. Source: Apollo's official GraphQL schema guidance on the Relay Connection specification.

## The connection shape

```graphql
type Query {
  posts(first: Int, after: String, last: Int, before: String): PostConnection!
}

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int
}

type PostEdge {
  node: Post!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}
```

Usage rules:

- `first`+`after` paginate forward, `last`+`before` paginate backward - never mix directions in one request.
- Apply the same connection shape to paginated relationship fields on any type (`User.posts(first: Int, after: String): PostConnection!`), not just top-level `Query` fields.

## Cursor design rules

A cursor must be opaque (clients never parse it), stable (the same cursor always points at the same position), and serializable (typically base64). Three encodings:

| Encoding                        | When                         | Caveat                                              |
| ------------------------------- | ---------------------------- | --------------------------------------------------- |
| ID alone                        | unsorted or ID-ordered lists | doesn't preserve position under a custom sort       |
| `timestamp:id` (or sort-key:id) | sorted lists                 | required whenever sort order isn't ID order         |
| encoded offset                  | almost never                 | least stable - inherits offset's drift under writes |

## Offset vs cursor, the mechanics

- Offset allows arbitrary page jumps but breaks under concurrent writes: items shift between reads, so pages duplicate or skip rows. Deep offsets degrade - `OFFSET 10000` scans past 10,000 rows.
- Cursor pagination stays consistent under live writes and stays fast at any depth: an indexed `WHERE created_at < $cursor ORDER BY created_at DESC LIMIT 20` costs the same on page 1 and page 500. The price: no arbitrary page jumping, more implementation code.
- Index whatever the cursor filters and sorts on (`created_at`, or a composite like `(author_id, created_at)`) - an unindexed cursor query silently degrades to the full-scan cost offset had.

## The `totalCount` trap

A naive `COUNT(*)` over a large filtered table is expensive; never let it block every paginated response. Options in order of preference for a public API:

1. Make `totalCount` nullable and skip computing it when expensive.
2. Return an estimate and document it as one.
3. Serve exact counts from a separate, explicitly costed query.

Never ship a non-null `totalCount` on a public schema without confirming the count is cheap at real data scale - non-null is a promise you can't walk back without a breaking change.

## Server-side page-size cap

The schema cannot express an upper bound on an `Int` argument, so enforce it in the resolver (`Math.min(first, 100)`-style clamping) and document the cap next to the field. The cap doubles as the list-cost multiplier when a cost model prices connections (see the rate-limit sibling skill for scoring mechanics).

## Typed sort and filter arguments

```graphql
type Query {
  posts(
    first: Int
    after: String
    orderBy: PostOrder
    filter: PostFilter
  ): PostConnection!
}

input PostOrder {
  field: PostOrderField!
  direction: OrderDirection!
}

enum PostOrderField {
  CREATED_AT
  TITLE
  COMMENT_COUNT
}
enum OrderDirection {
  ASC
  DESC
}

input PostFilter {
  authorId: ID
  publishedAfter: DateTime
}
```

Never `orderBy: String` or `filter: JSON` - a typed input documents the legal values in the schema itself and keeps cursor encoding coherent (the cursor must embed the active sort key).
references/schema-convention-tables.md›
# Schema convention tables

Convention reference for step 2 of the workflow. Sources: Apollo's official GraphQL schema guidance and Shopify's public `graphql-design-tutorial` (numbered rules cited inline); Marc-André Giroux's published MOIST principle.

## Casing by construct

| Construct                              | Convention                            | Example                                  |
| -------------------------------------- | ------------------------------------- | ---------------------------------------- |
| Object / interface / union / enum type | PascalCase, singular noun             | `User`, not `Users` or `user`            |
| Field, argument                        | camelCase                             | `firstName`, `emailAddress`              |
| Enum value                             | SCREAMING_SNAKE_CASE                  | `PENDING_PAYMENT`                        |
| Boolean field                          | `is`/`has`/`can`/`should` prefix      | `isActive`, `hasSubscription`, `canEdit` |
| Collection field                       | plural noun                           | `posts`, `followers`                     |
| Input type                             | mutation name + `Input`               | `CreateUserInput`                        |
| Payload/result type                    | mutation name + `Payload` or `Result` | `CreateOrderResult`                      |

- Name fields for what they return, not how they're computed: `fullName`, never `getFullName`.
- Relationship fields name the relationship: `author: User!`, not `createdBy`.
- Choose names for what makes sense to the client, not what the field is called in a legacy API or the data store (Shopify Rule #9).

## The mutation-naming fork

Both conventions are documented production practice; pick one in the interview and enforce it schema-wide:

- **Verb-first** (Apollo's default): `createUser`, `likePost` - "a mutation represents an action."
- **Noun-first** (Shopify's deliberate inversion): `userCreate`, `postLike` - groups a type's mutations together when tooling alphabetizes fields, and fits a mostly CRUD-shaped domain.

Either way, model mutations around business actions, not generic CRUD: `publishPost`, `archivePost`, `cancelOrder`, each with a tightly scoped input and payload - never one monolithic `updateOrder` for every conceivable field change.

## Mutation argument structure (Shopify Rule #22)

Keep the selection argument (which object to change) separate from the change-data argument:

```graphql
# Good  - selection and change data are distinct
type Mutation {
  collectionUpdate(
    collectionId: ID!
    collection: CollectionInput!
  ): CollectionUpdatePayload!
}

# Bad  - one merged input with a nullable id: is `id` a filter or the target?
type Mutation {
  collectionUpdate(input: CollectionUpdateInput!): CollectionUpdatePayload!
}
```

Make the selection argument non-nullable unless treating it as an optional filter has real value.

## Nullability table

| Declaration  | Meaning                       |
| ------------ | ----------------------------- |
| `String`     | nullable                      |
| `String!`    | non-null                      |
| `[String]`   | nullable list, nullable items |
| `[String!]`  | nullable list, non-null items |
| `[String]!`  | non-null list, nullable items |
| `[String!]!` | non-null list, non-null items |

Rules layered on the table:

- Use `[Type!]!` for every list field - an empty list over a null list, never a null item inside.
- A field that can legitimately fail to resolve is nullable at the object/scalar level (or gets its own result union - see the error-pattern reference), never forced into a null list.
- Mutation payload fields lean nullable "unless there is really a value to return in every possible error case" (Shopify Rule #24) - an all-non-null payload cannot represent partial failure.
- Every nullability decision is intentional; a schema where everything is nullable hides every guarantee from the client, and reflexive non-null spreads breakage (a failed non-null field nulls its whole parent chain).

## Input/output separation

Define a dedicated `input` type for what a mutation receives; never reuse an output `type` as an argument. The two differ structurally:

- Input fields are often optional on update.
- Output fields carry whatever the domain guarantees.
- An input never holds server-computed fields like `createdAt`.

## Interface vs union, by intent

- Interface (`interface Node { id: ID! }`, `interface Timestamped`) - multiple types share a capability clients query polymorphically.
- Union (`union SearchResult = User | Post | Comment`) - mutually exclusive alternatives with no shared field set.
- Don't reach for a union to avoid picking one output type: every consumer then needs `... on Type` fragments - a client-facing complexity cost, not a free abstraction.

## ID strategy

- `ID` scalar for every identifier - never `String` or `Int`.
- Implement `Node` (`interface Node { id: ID! }`) on every entity type: any object is refetchable via `node(id: ...)` regardless of which query first produced it.
- IDs are opaque and global; base64-encode compound identities (mirrors cursor opacity in the pagination reference).
- Object references over foreign keys (Shopify Rule #8): `author: User!`, not `authorId: ID!`. Reserve a bare ID field for the object's own identity only.
- Custom scalars (`DateTime`, `Email`, `URL`) for domain-validated values instead of a bare `String` every resolver re-validates ad hoc.

## MOIST: the counter-argument to consolidating types

Giroux's MOIST principle ("Moist Once Is Sometimes Tolerable") argues against reflexive DRY in schema design: `Viewer`, `UserProfile`, and `TeamMember` overlap on `name`/`email` yet serve genuinely different use cases and evolve independently. Cramming every field that sounds user-shaped into one `User` type couples unrelated use cases to one type's evolution. Cite it whenever a review is tempted to merge types purely to avoid field duplication.

## Anti-pattern list

Flag each of these in review:

- Hungarian notation (`TUser`, `strName`) or redundant type-name prefixes inside a type (`userId`/`userName` inside `type User` - just `id`/`name`).
- Implementation details in the schema: `mysql_id`, `redis_cache_key`.
- Vague catch-all shapes: `getData: JSON`, `filter: JSON`, `options: Options` - use typed, named arguments.
- Mixed casing or mixed mutation-naming order within one schema.
- Docstrings written for the engineering team ("Synced with Salesforce via nightly cron") - they publish architecture to every schema reader; write them for the public audience.
SKILL.md›
---
name: public-graphql-api-design
description: Design a public GraphQL API for third-party developers - the GraphQL-or-not gate, schema conventions (naming, nullability, Node interface, input/payload types), Relay cursor-connection pagination, union/interface error result types, depth and complexity ceilings as design decisions, the federation trust boundary (only the router is ever public), and a persisted-query policy that allowlists first-party traffic only. Use whenever the user mentions GraphQL, a public schema, Relay connections, query depth limits, persisted queries, federation, or GraphQL vs REST - even if they never say "GraphQL API design". Design layer only. Do NOT use for a REST surface review - use samber/developer-platform-skills@public-api-design-review instead.
license: MIT
metadata:
  author: Samuel Berthe
  version: "1.0.1"
---

# 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.

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.

## 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

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. 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`/`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](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`/`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](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 `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](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.
- **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.

## 6. The federation trust boundary

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.

## 7. The persisted-query policy

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.

## 8. Hand off evolution

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.

## 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.

## 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.

## 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.

## References

- [references/case-studies-github-shopify.md](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.