Skip to content

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:

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

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

  2. 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
      }
    }
  3. 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 membershipCheckout in 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:

graphql
# Namespaced pattern (we don't use this)
type Query {
	identity: IdentityQueries!
}

type IdentityQueries {
	user: User
}

This pattern has two problems:

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

  2. Cross-subgraph coupling: IdentityQueries is 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:

graphql
# 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 Query directly without coordinating on shared namespace types
  • Grouping: all academyTeam* queries sort together alphabetically
  • Discoverability: type academyTeam in 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:

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

graphql
extend type Mutation {
	membershipCheckoutCreate(input: MembershipCheckoutCreateInput!): MembershipCheckoutPayload!
	membershipCheckoutDetailsUpdate(
		input: MembershipCheckoutDetailsUpdateInput!
	): MembershipCheckoutPayload!
	membershipCheckoutAddressUpdate(
		input: MembershipCheckoutAddressUpdateInput!
	): MembershipCheckoutPayload!
	membershipCheckoutChildDetailsUpdate(
		input: MembershipCheckoutChildDetailsUpdateInput!
	): MembershipCheckoutPayload!
}

Why:

  1. Forward-compatible: add optional fields without breaking existing clients.
  2. Codegen-friendly: produces a clean TypeScript interface per mutation.
  3. Self-documenting: the input type name describes the operation.

Naming

OperationMutation PatternInput PatternExample
Create{resource}Create{Resource}CreateInputmembershipCheckoutCreate(input: MembershipCheckoutCreateInput!)
Update section{resource}{Section}Update{Resource}{Section}UpdateInputmembershipCheckoutDetailsUpdate(input: MembershipCheckoutDetailsUpdateInput!)
Add/Remove items{resource}{Items}Add{Resource}{Items}AddInputcartLinesAdd(input: CartLinesAddInput!)
Action{resource}{Action}{Resource}{Action}InputmembershipCheckoutComplete(input: MembershipCheckoutCompleteInput!)
Query (single){resource}N/AmembershipCheckout(id: ID!)
Query (list){resources}{Resource}FiltersInputmatches(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:

graphql
"""
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

FieldTypeOn successOn validation failureOn structural failure
membershipCheckoutNullableThe created/updated resourceThe resource (so client can show inline errors)null
userErrors[Error!]!Empty []Non-emptyNon-empty
warnings[Warning!]!Empty or non-emptyEmpty or non-emptyEmpty 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:

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

graphql
"""
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 MembershipCheckoutDetailsUpdateErrorCode and 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 switch on code gives 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, not message. The message field is a developer hint, not a display string.
  • Form mapping is direct. code identifies the error, field identifies the form field. No string parsing required.

ADR

See ADR-004: GraphQL Mutation Error Handling for the full decision record.

Client Usage

typescript
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

CategoryWhereExampleClient handling
Field validationpayload.userErrors with field pathEMAIL_INVALID, BIRTH_DATE_IN_FUTUREInline field error
Structural errorpayload.userErrors with empty fieldSESSION_EXPIRED, VERSION_CONFLICTToast / redirect
Warningpayload.warningsPRICE_CHANGED, ADDRESS_UNVERIFIEDNon-blocking banner
Transport errorTop-level errors arrayAuth failure, network error, server crashGlobal 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:

graphql
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:

graphql
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:

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

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