GraphQL Schema Design skill for Codex
Nullability, connections and typed errors on purpose. With this skill Codex designs GraphQL types from the queries clients actually send, applies a nullability default table instead of sprinkling exclamation marks, paginates growing lists with connections, gives each mutation its own input, payload and error union, and runs GraphQL Inspector to catch breaking changes before they reach mobile clients that cannot update.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- graphql-schema-design Installs to ~/.agents/skills/graphql-schema-design/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install graphql-schema-design The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project graphql-schema-design Installs to .agents/skills/graphql-schema-design/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/graphql-schema-design.zipunzip -o graphql-schema-design.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) graphql-schema-design PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $graphql-schema-design, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Designs and evolves GraphQL schemas (SDL or code-first) with deliberate nullability, Relay-style connection pagination, input and payload types for mutations, errors modeled as union result types, and safe deprecation. Use when writing or reviewing .graphql/SDL files, adding types, queries or mutations, deciding what should be nullable, adding pagination to a list field, or checking a schema change for breaking clients.
Use it when
- Adding types, queries or mutations to a GraphQL API
- Reviewing nullability or pagination in an existing schema
- Deprecating or removing schema fields safely
- Standardising how mutations report business errors
Use something else when
- Splitting the schema across services or teams: use graphql-federation
- Resolver performance, DataLoader or N+1 issues: use graphql-resolvers
How to check the result
Print the SDL, run graphql-inspector diff against the base branch, and execute real client operations plus a typed-error branch per changed mutation in tests.
The mistake it prevents
Marking fields non-null by default, so one failing backend lookup nulls out an entire parent object and every client screen that depends on it.
A good schema models the product domain for its clients, not the database. Every field’s nullability is a decision, every list that can grow is paginated, every mutation has an input and a payload, and every change is checked for breakage before merge.
Workflow
- [ ] 1. Read the existing schema and conventions
- [ ] 2. Model types from client use cases
- [ ] 3. Decide nullability per field
- [ ] 4. Paginate growing lists with connections
- [ ] 5. Shape mutations: input, payload, errors
- [ ] 6. Run lint and breaking-change checks
1. Read before adding
- Check
AGENTS.md, then find the schema:schema.graphql,*.graphqls, or the code-first definitions (Pothos, Nexus, TypeGraphQL, Strawberry, graphql-ruby, gqlgen, Hot Chocolate). Determine whether SDL is the source of truth or generated. - Note naming conventions, ID strategy (global
NodeIDs or raw database IDs), the pagination style in use, and how errors are already returned. - Print the current SDL for code-first projects (most frameworks have a print/emit command or test snapshot) so the diff is reviewable.
2. Model types from use cases
- Start from the screens or client queries that need the data. Write the query you want clients to send, then the types that make it possible.
- Name types as domain nouns (
Order,Shipment), not tables (order_rows). - Expose relationships as fields (
order.customer), not foreign keys (customerId) alone. Keep the ID field too when clients need it for caching or links. - Use enums for closed sets the server owns; document that clients must handle new values (adding an enum value can break exhaustive client switches).
- Use custom scalars for formats that need validation (
DateTime,URL,EmailAddress) and document their wire format. - Implement the
Nodeinterface (id: ID!globally unique) if clients use Relay or normalized caches such as Apollo Client or urql graphcache.
3. Nullability rules
Non-null (!) is a promise that is breaking to retract. If a non-null field resolves to
null, the error bubbles to the nearest nullable parent and wipes out that subtree.
| Field kind | Default |
|---|---|
id, __typename-like identity fields | ID! |
| Scalars stored on the same row and always present | non-null |
| Fields resolved from another service, cache or slow store | nullable |
| Relationship that can be deleted or hidden by permissions | nullable |
| List fields | [Item!]! (list always present, items never null) |
| Arguments | nullable with a documented default, unless truly required |
Top-level Query fields that fetch by ID | nullable (order(id: ID!): Order) |
When unsure, make output fields nullable: going nullable to non-null later is safe; the reverse breaks clients.
4. Pagination
Any list that can grow beyond roughly 100 items gets a connection:
type Query {
orders(first: Int, after: String, last: Int, before: String, filter: OrderFilter): OrderConnection!
}
type OrderConnection {
edges: [OrderEdge!]!
nodes: [Order!]! # convenience; optional but popular
pageInfo: PageInfo!
totalCount: Int # nullable: expensive, compute only when selected
}
type OrderEdge {
cursor: String!
node: Order!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
- Cap
first/last(e.g. 100) and reject larger values with a validation error. - Cursors are opaque; back them with keyset values, not offsets. See api-pagination.
- Small, bounded lists (
order.lineItemscapped by business rules) can be plain lists.
5. Mutations
One mutation per user intent, named verb-noun: createOrder, cancelOrder,
addLineItem. Avoid generic updateOrder(input: OrderInput) that accepts every field.
input CancelOrderInput {
orderId: ID!
reason: CancellationReason!
clientMutationId: String
}
type CancelOrderPayload {
order: Order
errors: [CancelOrderError!]!
}
union CancelOrderError = OrderNotFound | OrderAlreadyShipped | NotAuthorized
type OrderAlreadyShipped implements UserError {
message: String!
shippedAt: DateTime!
}
interface UserError {
message: String!
}
Error placement rule:
| Failure | Where it goes |
|---|---|
| Expected business outcome the UI must handle (validation, conflict, not allowed) | Typed error in the payload (union or errors list) |
| Malformed query, unknown field, bad argument type | GraphQL validation error (top-level errors) |
| Unexpected server fault, timeout | Top-level errors with extensions.code, field resolves to null |
- Every top-level error should carry
extensions.code(UNAUTHENTICATED,FORBIDDEN,BAD_USER_INPUT,INTERNAL_SERVER_ERROR), never stack traces in production. - Return the mutated object in the payload so normalized caches update without refetch.
- Input objects for “exactly one of these” can use
@oneOfwhere your server and client tooling support it; otherwise validate in the resolver.
6. Evolve without breaking
Safe: adding types, adding nullable fields, adding optional arguments, adding enum values (with the caveat above), and tightening a nullable output field to non-null when it provably never returns null.
Breaking: removing or renaming fields, changing a field type, making an argument required, making an output field nullable (strict clients), making an input field required, removing enum values or union members.
- Deprecate first:
oldField: String @deprecated(reason: "Use displayName. Removal after 2026-09-01."). - Track usage of deprecated fields (APM, Apollo GraphOS field usage, or resolver logging) and remove only when usage is zero for an agreed window.
- Check changes in CI. GraphQL Inspector compares two schemas:
npx @graphql-inspector/cli diff old-schema.graphql schema.graphql
(needs network for npx the first time; in a sandbox without network, ask before
installing or use the version already in devDependencies).
- Lint with
@graphql-eslint/eslint-pluginif the project uses ESLint: naming, descriptions,require-deprecation-reason,no-unreachable-types.
Security and cost
- Enforce a max query depth and a cost/complexity limit; connections multiply cost by
first. - Disable introspection in production only if the API is private and clients use persisted queries; otherwise leave it on and rely on auth and cost limits.
- Prefer persisted queries (trusted documents) for first-party clients.
- Authorization belongs in resolvers or the domain layer, per field, not in the schema shape alone. Resolver batching and N+1 are covered by graphql-resolvers.
Gotchas
[Item](nullable items) forces every client to null-check each element; use[Item!]!.- A non-null field on a federated or remote entity makes one failed backend wipe the whole parent object.
totalCounton huge tables becomes the slowest part of the query; make it opt-in.- Reusing one input type for create and update makes required fields impossible to model.
JSONscalar fields bypass the type system and break code generation; use them only for truly free-form data.- Field arguments are part of the cache key in client caches; changing defaults can silently change cached results.
Verification
- Schema builds and the server starts; print the SDL and review the diff.
graphql-inspector diff(or the framework’s equivalent) reports no unintended breaking changes.- Run a sample of real client operations (from the client repo or persisted query store) against the new schema with the project’s validation tooling.
- Execute at least one query and one mutation per changed type in tests, asserting both the success payload and a typed error branch.
Output template
## Schema change: <name>
- New/changed types: <list>
- Nullability decisions: <field: reason>
- Pagination: <connections added>
- Mutations: <name, input, payload, error union members>
- Breaking-change report: <inspector output summary or "none">
- Deprecations: <field, replacement, removal date> What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "GraphQL Schema Design"
short_description: "Nullability, connections and typed errors on purpose"
default_prompt: "$graphql-schema-design Add an orders query with pagination and filters to our GraphQL schema" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.