Queries & Mutations
Patterns for query arguments, pagination, mutation inputs, payloads, error handling, optimistic concurrency, and relationships.
All examples below use the membership checkout flow as a reference. The same patterns apply to every domain.
Mutation Naming: Flat vs Namespaced
We use flat, prefixed mutations following the Shopify pattern (membershipCheckoutCreate, membershipCheckoutDetailsUpdate) rather than Apollo's namespacing pattern with nested types.
ADR
See ADR-002: Flat Mutation Naming for the full decision record.
Why Not Namespacing?
Apollo GraphQL documents a namespacing pattern where mutations are nested under namespace types:
# Apollo namespacing pattern (we don't use this)
type Mutation {
membershipCheckout: MembershipCheckoutMutations!
}
type MembershipCheckoutMutations {
create(input: CreateInput!): Payload!
detailsUpdate(input: DetailsUpdateInput!): Payload!
}While this looks cleaner, it has drawbacks:
Violates GraphQL spec: The spec requires that "resolution of fields other than top-level mutation fields must always be side effect-free and idempotent." Namespaced mutations violate this because the actual mutating fields are no longer at the root level.
Loses serial execution: Root-level mutations execute serially; nested mutations execute in parallel. This can cause race conditions:
graphql# Our pattern - serial execution guaranteed mutation { membershipCheckoutCreate(input: {...}) { ... } membershipCheckoutDetailsUpdate(input: {...}) { ... } # waits for create } # Namespacing - parallel execution (race condition risk) mutation { membershipCheckout { create(input: {...}) { ... } detailsUpdate(input: {...}) { ... } # runs in parallel } }Federation complexity: Flat mutations are simpler to distribute across subgraphs. Each subgraph owns its mutations directly without coordinating on shared namespace types.
Why Flat Prefixed Mutations Work
The {resource}{Action} pattern provides the same organizational benefits without the drawbacks:
- Grouping: All
membershipCheckout*mutations sort together alphabetically - Discoverability: Type
membershipCheckoutin the Explorer to see all related mutations - Spec-compliant: Serial execution guaranteed, no race conditions
- Proven at scale: Used by large companies like Shopify and GitHub
Query Naming: Flat vs Namespaced
Like mutations, we use flat, prefixed queries rather than namespaced types.
Why Not Namespacing?
Query namespacing nests fields under a synthetic type:
# Namespaced pattern (we don't use this)
type Query {
identity: IdentityQueries!
}
type IdentityQueries {
user: User
}This pattern has two problems:
Federation performance: The router must resolve the namespace field (
identity) as a subgraph fetch before it can plan any of the nested field fetches. This adds an extra round-trip to every query. With flat root fields, the router plans and dispatches all fetches in a single pass.Cross-subgraph coupling:
IdentityQueriesis a synthetic type owned by one subgraph. Any other subgraph that wants to add a field under the same namespace must own or extend that type, coupling subgraphs on a type with no real domain meaning.
With flat queries, each subgraph extends Query directly with no coordination required:
# Our pattern - each subgraph extends Query independently
extend type Query {
user: User
}
extend type Query {
membershipCheckout(id: ID!): MembershipCheckout
}Why Flat Prefixed Queries Work
The same {resource} / {resources} naming pattern used for mutations provides discoverability without the federation coupling:
- Federation performance: the router plans all fetches in a single pass; no extra round-trip per namespace field
- No cross-subgraph coupling: subgraphs extend
Querydirectly without coordinating on shared namespace types - Grouping: all
academyTeam*queries sort together alphabetically - Discoverability: type
academyTeamin the Explorer to see all related queries
Type Extension is Not Namespacing
Extending a domain type across feature files is a different concept and is encouraged. Each feature owns its slice of a shared type independently:
# teams/typedefs.ts
type AcademyTeam {
id: ID!
name: String!
}
# standings/typedefs.ts - each feature extends the type independently
extend type AcademyTeam {
standings: [AcademyStandingPoule!]
seasonStandings(input: AcademyTeamSeasonStandingsInput!): [AcademyStandingRanking!]
}
# matches/typedefs.ts
extend type AcademyTeam {
matches(input: AcademyTeamMatchesInput!): [AcademyMatch!]
}The root entry point is still flat (academyTeam), but once you have an AcademyTeam in hand, its related data is modeled as fields on the type. This is the correct pattern per the Relationships section below.
Input Types
Mutations always take a single input argument typed as a dedicated input object.
extend type Mutation {
membershipCheckoutCreate(input: MembershipCheckoutCreateInput!): MembershipCheckoutPayload!
membershipCheckoutDetailsUpdate(
input: MembershipCheckoutDetailsUpdateInput!
): MembershipCheckoutPayload!
membershipCheckoutAddressUpdate(
input: MembershipCheckoutAddressUpdateInput!
): MembershipCheckoutPayload!
membershipCheckoutChildDetailsUpdate(
input: MembershipCheckoutChildDetailsUpdateInput!
): MembershipCheckoutPayload!
}Why:
- Forward-compatible: add optional fields without breaking existing clients.
- Codegen-friendly: produces a clean TypeScript interface per mutation.
- Self-documenting: the input type name describes the operation.
Naming
| Operation | Mutation Pattern | Input Pattern | Example |
|---|---|---|---|
| Create | {resource}Create | {Resource}CreateInput | membershipCheckoutCreate(input: MembershipCheckoutCreateInput!) |
| Update section | {resource}{Section}Update | {Resource}{Section}UpdateInput | membershipCheckoutDetailsUpdate(input: MembershipCheckoutDetailsUpdateInput!) |
| Add/Remove items | {resource}{Items}Add | {Resource}{Items}AddInput | cartLinesAdd(input: CartLinesAddInput!) |
| Action | {resource}{Action} | {Resource}{Action}Input | membershipCheckoutComplete(input: MembershipCheckoutCompleteInput!) |
| Query (single) | {resource} | N/A | membershipCheckout(id: ID!) |
| Query (list) | {resources} | {Resource}FiltersInput | matches(filters: MatchFiltersInput) |
The pattern is: resource first (singular, camelCase), action second. Omit redundant words for brevity.
Mutation Payloads
Every mutation returns a typed payload with three fields:
"""
Returned on success or validation failure.
The checkout is always returned (even with validation errors) so the client
can show inline field errors without re-fetching.
"""
type MembershipCheckoutPayload {
"""
The membership checkout. Null only if creation itself failed.
"""
membershipCheckout: MembershipCheckout
"""
Field-level validation errors. Empty on success.
"""
userErrors: [CheckoutUserError!]!
"""
Non-blocking warnings. Empty if no warnings.
"""
warnings: [CheckoutWarning!]!
}The Contract
| Field | Type | On success | On validation failure | On structural failure |
|---|---|---|---|---|
membershipCheckout | Nullable | The created/updated resource | The resource (so client can show inline errors) | null |
userErrors | [Error!]! | Empty [] | Non-empty | Non-empty |
warnings | [Warning!]! | Empty or non-empty | Empty or non-empty | Empty or non-empty |
The result field is nullable because when userErrors is non-empty, there may be no result to return. This aligns with the nullability rule: nullable when the absence is a legitimate state.
Why Warnings?
Some issues are non-blocking. The client should display them but not prevent the user from proceeding:
type CheckoutWarning {
"""
Machine-readable warning code.
"""
code: CheckoutWarningCode!
"""
Human-readable warning message.
"""
message: String!
}
enum CheckoutWarningCode {
PRICE_CHANGED
FAMILY_DISCOUNT_PENDING_VERIFICATION
MEMBERSHIP_OFFER_EXPIRING_SOON
ADDRESS_UNVERIFIED
}No Union Types for Errors
All errors, including structural ones like SESSION_NOT_FOUND, go through userErrors. Do not use GraphQL union types or the top-level errors array for business logic errors. Reserve the top-level errors array for transport failures (auth, network, server crash).
Exception
Union types are appropriate when a mutation can fail in ways that have genuinely different shapes; for example, a rate-limit error that carries a retryAfterSeconds field. In that case, model only that mutation's result as a union. See ADR-004 for the full decision.
Error Handling
Per-Mutation Error Types
Each mutation defines its own error type and code enum. Do not use a single shared error type or code enum across mutations.
"""
Error codes for the membershipCheckoutDetailsUpdate mutation.
"""
enum MembershipCheckoutDetailsUpdateErrorCode {
# Field validation errors (field path populated)
EMAIL_INVALID
EMAIL_MISMATCH
BIRTH_DATE_INVALID
BIRTH_DATE_IN_FUTURE
CONSENT_REQUIRED
FIRST_NAME_TOO_SHORT
LAST_NAME_TOO_LONG
# Structural errors (field path empty)
SESSION_NOT_FOUND
SESSION_EXPIRED
VERSION_CONFLICT
}
type MembershipCheckoutDetailsUpdateError {
"""
Machine-readable error code. Used for i18n lookup and exhaustive handling on the client.
"""
code: MembershipCheckoutDetailsUpdateErrorCode!
"""
Path to the field that caused the error (e.g. ["email"]). Empty for structural errors.
"""
field: [String!]!
"""
Developer-facing description of the error. Not intended for display to end users.
"""
message: String
}The enum values map 1:1 to the domain's Core.*ValidationError values, preserving their full specificity at the schema boundary. Generic codes like INVALID or TOO_LONG that apply to many fields are not used; each error is named after the specific field and reason.
Why Per-Mutation Enums
- Schema is the contract. A client introspects
MembershipCheckoutDetailsUpdateErrorCodeand sees the exhaustive list of errors this mutation can return. No external documentation needed. - Codegen exhaustiveness. Codegen produces a TypeScript union of literal string types. A
switchoncodegives compile-time exhaustiveness; if a new error code is added, the build fails until all clients handle it. - Clients own their messages. Clients key their i18n messages on
code, notmessage. Themessagefield is a developer hint, not a display string. - Form mapping is direct.
codeidentifies the error,fieldidentifies the form field. No string parsing required.
ADR
See ADR-004: GraphQL Mutation Error Handling for the full decision record.
Client Usage
const { data } = await membershipCheckoutDetailsUpdate({ input });
const { membershipCheckout, userErrors, warnings } = data.membershipCheckoutDetailsUpdate;
// 1. Handle blocking errors
if (userErrors.length > 0) {
for (const err of userErrors) {
if (err.field.length > 0) {
// Field-level: highlight the specific input
form.setFieldError(err.field, t(err.code));
} else {
// Structural: show a toast or banner
toast.error(t(err.code));
}
}
return;
}
// 2. Show non-blocking warnings
for (const warn of warnings) {
toast.warn(t(warn.code));
}
// 3. Success
navigateToNextStep(membershipCheckout);Error Categories
| Category | Where | Example | Client handling |
|---|---|---|---|
| Field validation | payload.userErrors with field path | EMAIL_INVALID, BIRTH_DATE_IN_FUTURE | Inline field error |
| Structural error | payload.userErrors with empty field | SESSION_EXPIRED, VERSION_CONFLICT | Toast / redirect |
| Warning | payload.warnings | PRICE_CHANGED, ADDRESS_UNVERIFIED | Non-blocking banner |
| Transport error | Top-level errors array | Auth failure, network error, server crash | Global error handler |
Optimistic Concurrency
For resources that are edited in multiple steps (like a checkout), include a version: Int! field on the resource and require it in every mutation input:
type MembershipCheckout {
id: String!
version: Int!
# ...
}
input MembershipCheckoutDetailsUpdateInput {
checkoutId: String!
version: Int!
# ...
}The server increments version on every successful write. If the client sends a stale version, the server returns a VERSION_CONFLICT error. The client can then re-fetch and retry.
This prevents lost updates when multiple tabs or devices edit the same resource.
Pagination
Relay Connections
Use cursor-based connections for any list that can grow:
type Query {
matches(first: Int, after: String): MatchConnection!
}
type MatchConnection {
edges: [MatchEdge!]!
nodes: [Match!]!
pageInfo: PageInfo!
}
type MatchEdge {
node: Match!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}Cursor-based pagination is real-time safe (inserts don't shift pages), performant (cursors map to database keys), and the industry standard (Shopify, GitHub, Stripe).
When Simple Lists Are Fine
Return a plain [Type!]! when the result set is small and bounded:
type Query {
standings(competitionId: ID!): [Standing!]! # always ~20 teams
}Avoid Offset Pagination
Offset/limit breaks when items are inserted or deleted between page fetches.
Relationships
Model relationships as fields on types, not as separate root queries.
# Good -- relationship is a field
type User {
membership: Membership
}
type Team {
matches(first: Int, after: String): MatchConnection!
}
# Bad -- relationship as a separate query
type Query {
userMembership(userId: ID!): Membership
teamMatches(teamId: ID!): [Match!]!
}For one-to-many relationships, use connections (see Pagination).