Skip to content

Schema Conventions

Design for Client Needs

Apollo calls this demand-oriented schema design. The GraphQL schema should model what clients need to display, not mirror the database tables or REST endpoints.

Ask 'what does the client need to render this screen?' before 'what columns does the table have?'

graphql
# Good -- models the client's view of a match day
type Match {
	id: ID!
	homeTeam: Team!
	awayTeam: Team!
	score: Score
	status: MatchStatus!
	venue: Venue
}

# Bad -- mirrors the database row
type Match {
	id: ID!
	home_team_id: Int! # leaks DB foreign key
	away_team_id: Int! # leaks DB foreign key
	home_score: Int # flat columns instead of a Score type
	away_score: Int
	status_code: String! # raw DB value instead of enum
	venue_id: Int # leaks DB foreign key
}

This principle comes from Apollo's schema design guide and can be seen in Shopify and GitHub: the schema is a product, not a data dump.

Naming

ElementConventionExample
TypesPascalCaseMatch, MembershipOption
FieldscamelCasehomeTeam, kickoffTime
Enum valuesSCREAMING_SNAKE_CASESCHEDULED, FULL_TIME
Query fields (single)Singular nounmatch(id: ID!)
Query fields (list)Plural nounmatches(...)
Mutations{resource}{Action}membershipCheckoutCreate, membershipCheckoutAddressUpdate, membershipCheckoutDetailsUpdate
graphql
# Good
type Match {
	id: ID!
	homeTeam: Team!
	kickoffTime: DateTime!
	status: MatchStatus!
}

enum MatchStatus {
	SCHEDULED
	LIVE
	FINISHED
}

Nullability

Default to non-null (!). A field should only be nullable when the absence of a value is a legitimate state.

Nullable?WhenExample
Non-nullThe field always has a valueid: ID!, name: String!, status: MatchStatus!
NullableThe value may not exist yethomeScore: Int (null before kickoff)
NullableThe relationship is optionalUser.membership: Membership (not all users are members)
NullableA section of a multi-step formmemberDetails: MemberDetails (not yet filled in)
NullableMutation payload resultmembershipCheckout: MembershipCheckout (null when creation itself failed)

The last case is important: in mutation payloads, the result field is nullable because when userErrors is non-empty, there may be no result to return.

Lists

Lists must be [Type!]! (non-null list, non-null items). Return [] instead of null.

graphql
type Match {
	id: ID! # always present
	homeScore: Int # null before kickoff
	events: [MatchEvent!]! # empty array if none
}

type MembershipCheckoutPayload {
	membershipCheckout: MembershipCheckout # null only if creation failed
	userErrors: [CheckoutUserError!]! # empty on success
	warnings: [CheckoutWarning!]! # empty if no warnings
}

Custom Scalars

Use semantic scalars instead of generic String or Int.

ScalarReplacesUse for
DateTimeStringISO 8601 timestamps
URLStringAny URL
EmailStringEmail addresses
MoneyIntMonetary values with currency

Abstract Types (Interfaces & Unions)

Use interfaces and unions to model polymorphism. Apollo recommends these for federation because they compose cleanly across subgraphs.

Interfaces

Use an interface when types share common fields:

graphql
interface Searchable {
	id: ID!
	title: String!
	url: String!
}

type Article implements Searchable {
	id: ID!
	title: String!
	url: String!
	author: String!
	publishedAt: DateTime!
}

type Vacancy implements Searchable {
	id: ID!
	title: String!
	url: String!
	department: String!
}

Unions

Use a union when types share no common fields but appear in the same context:

graphql
union SearchResult = Article | Match | Vacancy | Player

In Federation

Interfaces and unions can span subgraphs. The owning subgraph defines the interface; other subgraphs can add implementing types. Use @interfaceObject directive to automatically add fields to every entity that implements the interface.

Descriptions

Every type, field, enum value, and argument must have a triple-quoted description. This makes the schema self-documenting in GraphQL Playground and codegen output.

graphql
type Match {
	"""
	Unique identifier for this match.
	"""
	id: ID!

	"""
	Home team score. Null if the match has not started.
	"""
	homeScore: Int
}

Deprecation

Never remove a field. Deprecate it first with a reason and a removal timeline, then remove it after clients have migrated.

graphql
type Match {
	"""
	Use `homeScore` instead.
	"""
	homeTeamScore: Int @deprecated(reason: "Renamed to homeScore. Removal: 2025-06.")

	homeScore: Int
}