Skip to main content

Class 9: Validation & Execution

Understand how GraphQL validates and executes queries step by step.

Duration: ~10 minutes | Difficulty: Beginner | Prerequisites: Class 8


The Request Lifecycle

Every GraphQL request goes through a defined pipeline:

Let's explore each step in detail.


Step 1: Parsing

The parser converts a query string into an Abstract Syntax Tree (AST).

Input (Query String)

query Movie($id: ID!) {
movie(id: $id) {
title
releaseYear
}
}

Output (AST Representation)

Document
└── OperationDefinition (query "Movie")
├── VariableDefinitions
│ └── VariableDefinition ($id: ID!)
└── SelectionSet
└── Field (movie)
├── Arguments
│ └── Argument (id: $id)
└── SelectionSet
├── Field (title)
└── Field (releaseYear)

Syntax Errors

If the query has invalid syntax, parsing fails:

query MovieTitle {
movie(id: "1" { # Missing )
title
}
}

Error:

{
"errors": [{
"message": "Syntax Error: Expected Name, found \"{\".",
"locations": [{ "line": 2, "column": 17 }]
}]
}

Parsing errors stop the pipeline - no validation or execution occurs.


Step 2: Validation

The validator checks the AST against schema rules. GraphQL defines many validation rules.

The spec permits servers to skip per-request validation when the same request has already been validated and the schema has not changed since (for example, persisted queries). In normal operation, however, every incoming request goes through this step.

Field Validation

Rule: Every field must exist on its parent type.

query MovieWithUnknownField {
movie(id: "1") {
title
nonExistentField # ❌ Invalid
}
}

Argument Validation

Rule: Required arguments must be provided. Argument types must match.

# Schema: movie(id: ID!): Movie

query MovieWithoutId {
movie { # ❌ Missing required argument 'id'
title
}
}

query MovieWithFloatId {
movie(id: 1.5) { # ❌ ID expected, got Float (only String or Int are coercible to ID)
title
}
}

Fragment Validation

Rule: Fragments can only be used on compatible types.

fragment MovieFields on Movie {
title
}

query ActorWithMovieFields {
actor(id: "1") {
...MovieFields # ❌ Can't use Movie fragment on Actor
}
}

Variable Validation

Rule: Variable types must be compatible with their usage.

query MovieWithStringId($id: String!) {    # Variable is String!
movie(id: $id) { # ❌ Argument expects ID!
title
}
}

Directive Validation

Rule: Directives must be used in valid locations.

query MovieWithOperationDirective @include(if: true) {    # ❌ @include is for fields, not operations
movie(id: "1") {
title
}
}

Validation Rules Reference

GraphQL spec defines many validation rules:

CategoryExamples
OperationsNamed operations must be unique
FieldsFields must exist, leaf fields can't have selections
ArgumentsRequired args provided, types match, no duplicates
FragmentsMust be used, no cycles, type conditions valid
ValuesCorrect types, enums valid, required fields present
DirectivesValid locations, required args provided
VariablesDefined before use, types compatible, used at least once

Step 3: Execution

Once validated, the query is executed by resolving each field.

Execution Order

Execution Order
query MovieWithDirectorAndActors {
movie(id: "1") { # 1. Resolve Query.movie
title # 2. Resolve Movie.title
director { # 3. Resolve Movie.director
name # 4. Resolve Director.name
}
actors { # 3. Resolve Movie.actors (parallel)
name # 5. Resolve Actor.name (for each)
}
}
}

Execution rules:

  • Root fields execute first
  • Child fields wait for parent to resolve
  • Sibling fields may execute in parallel
  • List items resolve in parallel

Resolvers

Each field has a resolver function that returns its value:

Field: movie(id: "1")
Resolver: movieResolver(parent, args, context)
- parent: null (root query)
- args: { id: "1" }
- context: { user, dataSources }
- returns: Movie object

Field: Movie.title
Resolver: titleResolver(parent, args, context)
- parent: Movie { id: "1", title: "Inception", ... }
- returns: "Inception" (often just parent.title)

Default Resolvers

The GraphQL specification does not mandate any default resolver behavior; this is an implementation convention. The graphql-js reference implementation (and most servers built on top of it, including Apollo Server) provides a defaultFieldResolver that:

  1. Looks for a property with the same name on the parent object
  2. Calls it as a function if it is one
  3. Returns the value
// These are equivalent:
Movie.title → movie.title
Movie.releaseYear → movie.releaseYear

Custom resolvers are only needed when:

  • Field name differs from property name
  • Data requires transformation
  • Data must be fetched from another source

Parallel vs Sequential Execution

Queries: Parallel

Root query fields may execute in parallel:

query MovieActorAndTopRatedMovies {
movie(id: "1") { title } # Can run in parallel
actor(id: "2") { name } # Can run in parallel
topRatedMovies { title } # Can run in parallel
}

Mutations: Sequential

Root mutation fields execute sequentially (in order):

mutation CreateMovieThenReviewThenUpdate {
first: createMovie(input: {...}) { id } # Executes first
second: createReview(input: {...}) { id } # Waits for first
third: updateMovie(input: {...}) { id } # Waits for second
}

This guarantees mutations execute in the order specified.


Type Resolution

For interfaces and unions, the executor must determine the concrete type:

interface Content {
id: ID!
title: String!
}

type Movie implements Content {
id: ID!
title: String!
duration: Int!
}

type TvShow implements Content {
id: ID!
title: String!
seasons: Int!
}

Query:

query SearchMoviesAndTvShows {
search(query: "matrix") {
__typename # Returns "Movie" or "TvShow"
id
title
... on Movie { duration }
... on TvShow { seasons }
}
}

The server must implement a resolveType function to determine which concrete type each result is.


Execution Context

Resolvers receive a context object containing:

Resolver Context
{
"user": { "id": "123", "role": "admin" },
"dataSources": {
"movieDB": "MovieDataSource",
"actorAPI": "ActorAPIClient"
},
"request": { "headers": "...", "ip": "..." },
"loaders": {
"movieLoader": "DataLoader",
"actorLoader": "DataLoader"
}
}

Context is created once per request and passed to every resolver.


Performance Considerations

The N+1 Problem

query MoviesWithDirectors {
movies { # 1 query: get all movies
title
director { # N queries: get director for each movie
name
}
}
}

Solution: DataLoader for batching (covered in implementation-specific tutorials).

Query Complexity

Deep or wide queries can be expensive:

query DeeplyNestedMovies {
movies {
actors {
movies {
actors {
movies {
# ... deeply nested
}
}
}
}
}
}

Solutions:

  • Query depth limiting
  • Query complexity analysis
  • Timeout limits

Step 4: Response Formation

After execution, results are assembled into the response:

{
"data": {
"movie": {
"title": "Inception",
"director": {
"name": "Christopher Nolan"
}
}
}
}

The response shape exactly mirrors the query shape.

With Errors

If errors occurred during execution:

{
"data": {
"movie": {
"title": "Inception",
"director": null
}
},
"errors": [{
"message": "Director service unavailable",
"path": ["movie", "director"]
}]
}

Extensions

Servers can include additional metadata in extensions:

{
"data": { ... },
"extensions": {
"tracing": {
"duration": 42,
"parsing": 2,
"validation": 5,
"execution": 35
},
"cacheControl": {
"maxAge": 300
}
}
}

Common uses:

  • Performance tracing
  • Cache hints
  • Rate limit information
  • Debug information (development only)

Summary

PhasePurposeErrors
ParseString → ASTSyntax errors
ValidateCheck against schemaValidation errors
ExecuteResolve fieldsExecution errors
RespondForm JSON response-

What's Next?

In the next class, we'll explore Class 10: Schema Design Best Practices - patterns and conventions for designing effective GraphQL schemas.