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?'
# 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
| Element | Convention | Example |
|---|---|---|
| Types | PascalCase | Match, MembershipOption |
| Fields | camelCase | homeTeam, kickoffTime |
| Enum values | SCREAMING_SNAKE_CASE | SCHEDULED, FULL_TIME |
| Query fields (single) | Singular noun | match(id: ID!) |
| Query fields (list) | Plural noun | matches(...) |
| Mutations | {resource}{Action} | membershipCheckoutCreate, membershipCheckoutAddressUpdate, membershipCheckoutDetailsUpdate |
# 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? | When | Example |
|---|---|---|
| Non-null | The field always has a value | id: ID!, name: String!, status: MatchStatus! |
| Nullable | The value may not exist yet | homeScore: Int (null before kickoff) |
| Nullable | The relationship is optional | User.membership: Membership (not all users are members) |
| Nullable | A section of a multi-step form | memberDetails: MemberDetails (not yet filled in) |
| Nullable | Mutation payload result | membershipCheckout: 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.
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.
| Scalar | Replaces | Use for |
|---|---|---|
DateTime | String | ISO 8601 timestamps |
URL | String | Any URL |
Email | String | Email addresses |
Money | Int | Monetary 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:
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:
union SearchResult = Article | Match | Vacancy | PlayerIn 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.
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.
type Match {
"""
Use `homeScore` instead.
"""
homeTeamScore: Int @deprecated(reason: "Renamed to homeScore. Removal: 2025-06.")
homeScore: Int
}