Photo by Eric Er on Unsplash

On this page

This chapter follows Building a GraphQL server with NestJS.

A schema that mirrors database tables one-to-one often forces clients to stitch fields that product design already treats as one screen. Query-driven design starts from UI or API consumer needs, then maps fields to services and storage.

Start from client operations

Collect the GraphQL documents your first clients need (or write them in design review). If a screen loads upcomingEvents { name location { weather { temperature } } }, the schema should expose that graph directly instead of forcing five REST-shaped queries.

Keep internal microservice boundaries inside resolvers; the public schema stays stable while backends split or merge.

Naming conventions

Element Convention
Fields and arguments camelCase
Types and inputs PascalCase
Enum values ALL_CAPS

Mutations should be verbs (createPost, archiveOrder). Queries are nouns or noun phrases (post, searchPosts). Consistency helps codegen and logs.

Nullability strategy

Prefer non-null for fields clients always need when the parent exists. Use nullable fields for optional enrichment (weather on Location) so one failed dependency does not bubble away the whole parent if you mark the parent nullable carefully.

Adding ! to an formerly optional output field is breaking for clients that must handle null. Removing ! changes error bubbling—also risky.

Cursor connections for pagination

Offset pagination (limit/offset) is simple but performs poorly on large tables. Relay cursor connections standardize pagination:

type Query {
posts(first: Int, after: String): PostConnection!
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}

Clients pass after: endCursor from the previous page. NestJS and other libraries offer @nestjs/graphql plugins or helpers to generate connection types.

Mutation input and payload pattern

Group write arguments in a single input and return a payload with the changed entity:

input UpdateEmailInput {
userId: ID!
email: String!
}
type UpdateEmailPayload {
user: User
errors: [UserError!]
}
type Mutation {
updateEmail(input: UpdateEmailInput!): UpdateEmailPayload!
}

Returning the updated user avoids a second round trip. Listing errors in the payload suits expected validation failures.

Errors as data versus the errors array

GraphQL’s top-level errors array fits unexpected failures and partial null bubbling. Domain errors (email taken, insufficient stock) are often clearer as union or interface results:

union CreateOrderResult = Order | ValidationError
type ValidationError {
message: String!
field: String
}

Clients branch with __typename without treating business rules as transport exceptions. Reserve errors for truly exceptional resolver throws and log them server-side.

Next: GraphQL clients compared.

Check your understanding

Question 1 of 6

0 of 6 checked

  1. 1. What does query-driven design prioritize?

Search articles

Type a word to search published articles.

Open the search page