Skip to main content

Class 3: Relationships & Nested Resolution

Duration: 65 minutes | Difficulty: Intermediate | Prerequisites: Class 2 completed


What You'll Learn

By the end of this class, you will:

  • Understand why some relationships need their own entity (junction tables with data)
  • Build a Cast model where actors are linked to movies with character names
  • See how GraphQL resolves nested queries step by step
  • Add search functionality to your API
  • Understand the difference between @ManyToMany and a junction entity

The Problem: Actors Are More Than Just a List

In Class 2 (Github repo here), we modeled directors as a simple many-to-many relationship between Movie and Person. That worked perfectly because the relationship itself carries no additional information - Christopher Nolan directed Inception, and that's the whole story.

But casting is different. When Morgan Freeman appears in The Shawshank Redemption, he doesn't just "appear in it" - he plays Red. When he appears in Se7en, he plays Detective William Somerset. The relationship between a person and a movie carries its own data: the character name. A simple @ManyToMany can't store that extra information, because join tables only hold foreign keys.

This is where junction entities come in. Instead of a join table that JPA manages silently behind the scenes, we create a full entity - MovieCast - that sits between Movie and Person and holds the character name alongside both foreign keys.

This is a pattern you'll see repeatedly in real-world applications: any time a relationship carries its own attributes, you need an explicit junction entity rather than a transparent @ManyToMany.

Step 1: Update the GraphQL Schema

Let's start by defining what our API will look like. In a schema-first approach, we design the contract that clients will use before writing any implementation code:

📁 src/main/resources/graphql/schema.graphqls

type Query {
"""Find a movie by its unique identifier"""
movie(id: ID!): Movie
"""List all movies in the database"""
movies: [Movie!]!
"""Search movies by title (case-insensitive partial match)"""
searchMovies(title: String!): [Movie!]!
}

"""The primary genre category for movies"""
enum Genre {
ACTION
ANIMATION
COMEDY
CRIME
DOCUMENTARY
DRAMA
FANTASY
HORROR
MUSICAL
MYSTERY
ROMANCE
SCIFI
THRILLER
WAR
WESTERN
}

"""A movie in the database"""
type Movie {
id: ID!
title: String!
releaseYear: Int!
genre: Genre!
"""Average rating on a scale of 0-10"""
rating: Float
directors: [Person!]!
cast: [MovieCast!]!
}

"""A person involved in the film industry (actor, director, etc.)"""
type Person {
id: ID!
name: String!
birthYear: Int
nationality: String
}

"""An actor's role in a specific movie, linking an actor to a character"""
type MovieCast {
id: ID!
characterName: String!
person: Person!
}

Notice something important about MovieCast in the GraphQL schema: it exposes person (the actor) and characterName, but not movie. That's because we'll always access cast entries through a movie - you query a movie, then ask for its cast. Exposing movie on MovieCast would create a circular reference in the schema that, while technically valid in GraphQL, doesn't serve any useful purpose from this direction.

Also notice we added searchMovies(title: String!) to the Query type. This gives clients a way to search movies by title - something we'll wire up shortly using a Spring Data derived query method.

info

Like the movies query, searchMovies returns an unbounded list. In Class 11, we'll replace these with paginated versions. For now, returning all results keeps the focus on relationships and resolution.

Step 2: Create the MovieCast Entity

Now that we've defined the schema contract, let's implement the types we described. We'll start with the MovieCast junction entity that sits between Movie and Person:

📁 src/main/java/com/graphqlguy/moviedb/movie/MovieCast.java

package com.graphqlguy.moviedb.movie;

import com.graphqlguy.moviedb.person.Person;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Entity
@Builder
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
public class MovieCast {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "movie_id")
private Movie movie;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "person_id")
private Person person;

private String characterName;

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MovieCast that = (MovieCast) o;
return id != null && id.equals(that.getId());
}

@Override
public int hashCode() {
return getClass().hashCode();
}
}

We include the same equals/hashCode pattern we established in Class 2 - every JPA entity gets it.

There are a few more design decisions here worth explaining:

FetchType.LAZY on both relationships. By default, @ManyToOne uses EAGER fetching, which means every time you load a MovieCast, JPA would immediately load the full Movie and Person objects too - even if you don't need them. With LAZY, JPA loads only the MovieCast row itself and creates proxy objects for movie and person. The actual data is only fetched from the database when you call a method on those proxies (like person.getName()). This is almost always what you want, because GraphQL already has its own mechanism for deciding which fields to resolve. Loading everything eagerly would defeat the purpose of GraphQL's selective field resolution.

Why MovieCast lives in the movie package. This entity is fundamentally about a movie's cast - it's owned by the movie side of the relationship. When we add TV shows later, they'll have their own TvShowCast in the tvshow package.

Step 3: Create the Repository

📁 src/main/java/com/graphqlguy/moviedb/movie/MovieCastRepository.java

package com.graphqlguy.moviedb.movie;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface MovieCastRepository extends JpaRepository<MovieCast, Long> {

List<MovieCast> findByMovieId(Long movieId);
}

We add findByMovieId because we'll need to load all cast members for a given movie. Spring Data derives the query from the method name: it generates SELECT * FROM movie_cast WHERE movie_id = ? automatically.

Step 4: Add the Cast Relationship to Movie

Now we need to tell JPA that a Movie owns a collection of MovieCast entries, matching the cast: [MovieCast!]! field we defined in the schema:

📁 src/main/java/com/graphqlguy/moviedb/movie/Movie.java

package com.graphqlguy.moviedb.movie;

import com.graphqlguy.moviedb.person.Person;
import com.graphqlguy.moviedb.shared.Genre;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.OneToMany;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.util.ArrayList;
import java.util.List;

@Entity
@Builder
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
public class Movie {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private String title;

private int releaseYear;

@Enumerated(EnumType.STRING)
private Genre genre;

private Double rating;

@ManyToMany
@JoinTable(
name = "movie_directors",
joinColumns = @JoinColumn(name = "movie_id"),
inverseJoinColumns = @JoinColumn(name = "person_id")
)
@Builder.Default
private List<Person> directors = new ArrayList<>();

@OneToMany(mappedBy = "movie", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<MovieCast> cast = new ArrayList<>();

// equals/hashCode omitted for brevity - same pattern as before
}

The new cast field uses @OneToMany because one movie has many cast entries. Let's look at the annotations:

  • mappedBy = "movie" tells JPA that the MovieCast.movie field owns this relationship. Without this, JPA would try to create another join table, which isn't what we want - MovieCast already has a movie_id foreign key column.

  • cascade = CascadeType.ALL means that when we save or delete a Movie, JPA automatically saves or deletes its cast entries too. This is convenient for operations like "delete a movie and all its cast records."

  • orphanRemoval = true means that if we remove a MovieCast from the cast list, JPA deletes it from the database. Without this, removing the entry from the list would just set the foreign key to null, leaving an orphaned row.

Step 5: Search, the Simpler Way First

Before we write any code for search, Spring for GraphQL has a near-zero-ceremony approach: Query by Example via @GraphQlRepository. Let's see how far it gets us before reaching for a derived query method.

The Auto-Wired Approach

Spring for GraphQL ships with a built-in trick: annotate a Spring Data repository with @GraphQlRepository and have it extend QueryByExampleExecutor, and Spring auto-registers a data fetcher for any top-level query whose return type matches the entity. No controller method required.

Try it:

📁 src/main/java/com/graphqlguy/moviedb/movie/MovieRepository.java

package com.graphqlguy.moviedb.movie;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.repository.query.QueryByExampleExecutor;
import org.springframework.graphql.data.GraphQlRepository;

@GraphQlRepository
public interface MovieRepository extends JpaRepository<Movie, Long>,
QueryByExampleExecutor<Movie> {
}

That's it. No @QueryMapping in a controller, no handwritten query. Restart the app and open GraphiQL.

Try an exact title match:

query {
searchMovies(title: "The Godfather") {
title
releaseYear
}
}

It works. Spring takes the title argument, builds a Movie probe (every field null except title = "The Godfather"), and calls findAll(Example.of(probe)) under the hood. Null fields are ignored by QBE, so only title acts as a filter. One annotation, one extra interface, and a typed data fetcher appears out of thin air.

What's Actually Going On

A careful reader will pause here. We already have a MovieController from Class 2 with @QueryMapping methods for movies and movie(id). We just slapped @GraphQlRepository on the repository. Why didn't anything conflict?

The auto-registration rule, straight from the Spring for GraphQL docs:

Repositories annotated with @GraphQlRepository are automatically registered for queries where no DataFetcher is already registered and the return type matches the repository's domain type.

Applied to our schema (src/main/resources/graphql/schema.graphqls):

QueryReturn typeAlready mapped in controller?Outcome
movies[Movie!]!Yes, MovieController.movies() at line 21Controller wins
movie(id)MovieYes, MovieController.movie() at line 26Controller wins
searchMovies(title)[Movie!]!NoAuto-wired by @GraphQlRepository

So the annotation only kicks in for the one query you did not bind in the controller. The two queries you already wrote stay bound to the controller. That is why Spring picks the QBE auto-fetcher for searchMovies and leaves movies and movie(id) alone. Not magic, just a "no existing fetcher, return type matches the domain entity" check.

Where It Falls Apart

Now try a partial match:

query {
searchMovies(title: "Godfather") { title }
}

Empty array. And:

query {
searchMovies(title: "the godfather") { title }
}

Also empty.

QBE matches exact property values. There is no LIKE, no case-insensitivity, no range queries, and no way to filter across relationships. For a search UI where users type "godfather" and expect to find both Godfather movies, that's a dealbreaker.

More generally, QBE runs out of steam the moment you need:

  • Partial or case-insensitive text matching (what we want here)
  • Range queries (min/max rating, release year between)
  • Filters on related entities (movies by a director's nationality)
  • Boolean OR between predicates
  • Custom sort orders beyond a simple property name
When QBE Does Fit

Query by Example is legitimately useful for simple admin lookups and internal tools where exact match is exactly what you want: finding a user by email, looking up an order by reference number, listing products by SKU. If every filter is "this field equals this value," @GraphQlRepository + QueryByExampleExecutor can remove the controller entirely. We just need more power for movie search.

Revert and Do It Properly

Strip the QBE wiring back out of the repository:

📁 src/main/java/com/graphqlguy/moviedb/movie/MovieRepository.java

package com.graphqlguy.moviedb.movie;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface MovieRepository extends JpaRepository<Movie, Long> {

List<Movie> findByTitleContainingIgnoreCase(String title);
}

Three changes: we dropped @GraphQlRepository, dropped the QueryByExampleExecutor<Movie> from the extends clause, and added a derived query method.

findByTitleContainingIgnoreCase is a derived query method-Spring Data reads the method name and generates the SQL automatically. Containing becomes LIKE %value%, and IgnoreCase makes it case-insensitive. No handwritten SQL required, but unlike QBE, we now get partial and case-insensitive matching.

Spring Data AOT (Later)

As your project accumulates derived methods and custom @Query statements, Spring Data's startup-time parsing of each one adds up. You can move that work to build time with Spring Data AOT repositories for faster startup, plus every @Query JPQL string gets validated at compile time, so typos become build failures instead of 3 AM production alerts. We enable it as part of production setup in Class 13.

Step 6: Update the Controller

📁 src/main/java/com/graphqlguy/moviedb/movie/MovieController.java

package com.graphqlguy.moviedb.movie;

import com.graphqlguy.moviedb.person.Person;
import lombok.RequiredArgsConstructor;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.graphql.data.method.annotation.SchemaMapping;
import org.springframework.stereotype.Controller;

import java.util.List;

@Controller
@RequiredArgsConstructor
public class MovieController {

private final MovieRepository movieRepository;

@QueryMapping
List<Movie> movies() {
return movieRepository.findAll();
}

@QueryMapping
Movie movie(@Argument Long id) {
return movieRepository.findById(id).orElse(null);
}

@QueryMapping
List<Movie> searchMovies(@Argument String title) {
return movieRepository.findByTitleContainingIgnoreCase(title);
}

@SchemaMapping
List<Person> directors(Movie movie) {
return movie.getDirectors();
}

@SchemaMapping
List<MovieCast> cast(Movie movie) {
return movie.getCast();
}
}

We added two things: a searchMovies query mapping and a cast schema mapping. The searchMovies method delegates to our repository's derived query method - Spring Data handles the SQL generation. The cast method follows exactly the same pattern as directors: it receives the parent Movie object and returns its cast list.

Step 7: Understanding Nested Resolution

This is where GraphQL gets really interesting. Let's trace exactly what happens when a client sends this query:

query {
movie(id: 1) {
title
directors {
name
}
cast {
characterName
person {
name
nationality
}
}
}
}

GraphQL resolves this query as a tree, level by level:

There's a subtle but important detail here: at step 7, when GraphQL needs to resolve MovieCast.person, we haven't written a @SchemaMapping for it. So how does it work?

Spring GraphQL has a built-in behavior: if a field in the schema matches a getter on the Java object, it resolves automatically. MovieCast has a getPerson() method (generated by Lombok's @Getter), and the schema field is named person, so Spring GraphQL calls the getter directly. Since we marked the person relationship as FetchType.LAZY, JPA creates a proxy - and when Spring GraphQL calls getPerson().getName(), the proxy triggers the actual database query to load the person.

This means you only need explicit @SchemaMapping methods when:

  • The field name doesn't match a getter (rare with Lombok)
  • You need custom logic beyond a simple getter
  • You want to optimize loading (which we'll cover in the BatchMapping class)
The Five Phases of GraphQL Execution

Stepping back from this specific trace, every GraphQL request goes through the same sequence:

  1. Parse & validate - The query string becomes an AST, and field names plus argument types are checked against the schema. Invalid queries fail here, before any resolver runs.
  2. Resolve root - The engine calls the root resolver (@QueryMapping method) for the top-level field.
  3. Resolve children - For each field requested on the returned object, the engine looks for a matching @SchemaMapping method or a getter with the same name.
  4. Recurse - Step 3 repeats down the tree, one level of the query at a time, until every requested field has been resolved. (Resolving a level at a time is exactly what lets @BatchMapping batch all the siblings on that level in Class 8.)
  5. Assemble - The resolved values are serialized into a JSON response mirroring the exact shape of the query.

This is the fundamental difference between GraphQL and REST: the client defines the shape, and the server lazily resolves exactly what was asked for, no more, no less.

When Does This Become a Problem?

Right now, if you query all 13 movies and ask for their cast, each MovieCast.person triggers a separate SQL query. With 20 cast entries, that's 20 individual database hits. This is the infamous N+1 problem - and we'll solve it elegantly in Class 8 with @BatchMapping. For now, with our small dataset, it works fine and keeps the code simple.

Step 8: Seed Cast Data

Update the DataInitializer to create actors and assign them to movies with character names:

📁 src/main/java/com/graphqlguy/moviedb/config/DataInitializer.java

package com.graphqlguy.moviedb.config;

import com.graphqlguy.moviedb.movie.Movie;
import com.graphqlguy.moviedb.movie.MovieCast;
import com.graphqlguy.moviedb.movie.MovieCastRepository;
import com.graphqlguy.moviedb.movie.MovieRepository;
import com.graphqlguy.moviedb.person.Person;
import com.graphqlguy.moviedb.person.PersonRepository;
import com.graphqlguy.moviedb.shared.Genre;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

import java.util.List;

@Component
@RequiredArgsConstructor
public class DataInitializer implements CommandLineRunner {

private final MovieRepository movieRepository;
private final PersonRepository personRepository;
private final MovieCastRepository movieCastRepository;

@Override
public void run(String... args) {
// Directors
Person frankDarabont = createAndSavePerson("Frank Darabont", 1959, "Hungarian-American");
Person francisFordCoppola = createAndSavePerson("Francis Ford Coppola", 1939, "American");
Person christopherNolan = createAndSavePerson("Christopher Nolan", 1970, "British-American");
Person robertZemeckis = createAndSavePerson("Robert Zemeckis", 1952, "American");
Person sidneyLumet = createAndSavePerson("Sidney Lumet", 1924, "American");
Person martinScorsese = createAndSavePerson("Martin Scorsese", 1942, "American");
Person davidFincher = createAndSavePerson("David Fincher", 1962, "American");
Person sergioLeone = createAndSavePerson("Sergio Leone", 1929, "Italian");
Person jamesCameron = createAndSavePerson("James Cameron", 1954, "Canadian");
Person stanleyKubrick = createAndSavePerson("Stanley Kubrick", 1928, "American");
Person clintEastwood = createAndSavePerson("Clint Eastwood", 1930, "American");

// Actors
Person morganFreeman = createAndSavePerson("Morgan Freeman", 1937, "American");
Person timRobbins = createAndSavePerson("Tim Robbins", 1958, "American");
Person marlonBrando = createAndSavePerson("Marlon Brando", 1924, "American");
Person alPacino = createAndSavePerson("Al Pacino", 1940, "American");
Person leonardoDiCaprio = createAndSavePerson("Leonardo DiCaprio", 1974, "American");
Person tomHanks = createAndSavePerson("Tom Hanks", 1956, "American");
Person henryFonda = createAndSavePerson("Henry Fonda", 1905, "American");
Person arnoldSchwarzenegger = createAndSavePerson("Arnold Schwarzenegger", 1947, "Austrian-American");
Person jackNicholson = createAndSavePerson("Jack Nicholson", 1937, "American");
Person bradPitt = createAndSavePerson("Brad Pitt", 1963, "American");
Person rayLiotta = createAndSavePerson("Ray Liotta", 1954, "American");
Person robertDeNiro = createAndSavePerson("Robert De Niro", 1943, "American");
Person christianBale = createAndSavePerson("Christian Bale", 1974, "British-American");
Person matthewMcConaughey = createAndSavePerson("Matthew McConaughey", 1969, "American");

// Movies with cast
Movie shawshank = createAndSaveMovie("The Shawshank Redemption", 1994, Genre.DRAMA, 9.3, List.of(frankDarabont));
createAndSaveCastEntry(shawshank, timRobbins, "Andy Dufresne");
createAndSaveCastEntry(shawshank, morganFreeman, "Red");

Movie godfather = createAndSaveMovie("The Godfather", 1972, Genre.CRIME, 9.2, List.of(francisFordCoppola));
createAndSaveCastEntry(godfather, marlonBrando, "Don Vito Corleone");
createAndSaveCastEntry(godfather, alPacino, "Michael Corleone");

Movie godfather2 = createAndSaveMovie("The Godfather Part II", 1974, Genre.CRIME, 9.0, List.of(francisFordCoppola));
createAndSaveCastEntry(godfather2, alPacino, "Michael Corleone");
createAndSaveCastEntry(godfather2, robertDeNiro, "Young Vito Corleone");

Movie forrest = createAndSaveMovie("Forrest Gump", 1994, Genre.DRAMA, 8.8, List.of(robertZemeckis));
createAndSaveCastEntry(forrest, tomHanks, "Forrest Gump");

Movie angryMen = createAndSaveMovie("12 Angry Men", 1957, Genre.DRAMA, 9.0, List.of(sidneyLumet));
createAndSaveCastEntry(angryMen, henryFonda, "Juror 8");

Movie inception = createAndSaveMovie("Inception", 2010, Genre.SCIFI, 8.8, List.of(christopherNolan));
createAndSaveCastEntry(inception, leonardoDiCaprio, "Dom Cobb");

Movie interstellar = createAndSaveMovie("Interstellar", 2014, Genre.SCIFI, 8.6, List.of(christopherNolan));
createAndSaveCastEntry(interstellar, matthewMcConaughey, "Cooper");

Movie darkKnight = createAndSaveMovie("The Dark Knight", 2008, Genre.ACTION, 9.0, List.of(christopherNolan));
createAndSaveCastEntry(darkKnight, christianBale, "Bruce Wayne");

Movie goodfellas = createAndSaveMovie("Goodfellas", 1990, Genre.CRIME, 8.7, List.of(martinScorsese));
createAndSaveCastEntry(goodfellas, rayLiotta, "Henry Hill");
createAndSaveCastEntry(goodfellas, robertDeNiro, "James Conway");

Movie se7en = createAndSaveMovie("Se7en", 1995, Genre.THRILLER, 8.6, List.of(davidFincher));
createAndSaveCastEntry(se7en, bradPitt, "Detective David Mills");
createAndSaveCastEntry(se7en, morganFreeman, "Detective William Somerset");

Movie goodBadUgly = createAndSaveMovie("The Good, the Bad and the Ugly", 1966, Genre.WESTERN, 8.8, List.of(sergioLeone));
createAndSaveCastEntry(goodBadUgly, clintEastwood, "Blondie");

Movie t2 = createAndSaveMovie("Terminator 2: Judgment Day", 1991, Genre.SCIFI, 8.6, List.of(jamesCameron));
createAndSaveCastEntry(t2, arnoldSchwarzenegger, "The Terminator");

Movie shining = createAndSaveMovie("The Shining", 1980, Genre.HORROR, 8.4, List.of(stanleyKubrick));
createAndSaveCastEntry(shining, jackNicholson, "Jack Torrance");

Movie unforgiven = createAndSaveMovie("Unforgiven", 1992, Genre.WESTERN, 8.2, List.of(clintEastwood));
createAndSaveCastEntry(unforgiven, clintEastwood, "William Munny");
createAndSaveCastEntry(unforgiven, morganFreeman, "Ned Logan");
}

private Person createAndSavePerson(String name, int birthYear, String nationality) {
return personRepository.save(Person.builder().name(name).birthYear(birthYear).nationality(nationality).build());
}

private Movie createAndSaveMovie(String title, int year, Genre genre, double rating, List<Person> directors) {
Movie movie = Movie.builder()
.title(title).releaseYear(year).genre(genre).rating(rating)
.build();
movie.getDirectors().addAll(directors);
return movieRepository.save(movie);
}

private void createAndSaveCastEntry(Movie movie, Person person, String characterName) {
movieCastRepository.save(MovieCast.builder().movie(movie).person(person).characterName(characterName).build());
}
}

Notice that Clint Eastwood appears as both a director (Unforgiven) and an actor (The Good, the Bad and the Ugly and Unforgiven). Because we use a unified Person model rather than separate Actor and Director classes, the same person can fill any role. The MovieCast junction entity captures what a person did in a specific movie, while the movie_directors join table captures who directed it. One person, multiple relationships - exactly how it works in the real film industry.

Step 9: Run and Test

Restart your application and open GraphiQL at http://localhost:8080/graphiql.

Query a Movie with Full Cast

query {
movie(id: 1) {
title
directors {
name
}
cast {
characterName
person {
name
nationality
}
}
}
}

Response:

{
"data": {
"movie": {
"title": "The Shawshank Redemption",
"directors": [
{ "name": "Frank Darabont" }
],
"cast": [
{
"characterName": "Andy Dufresne",
"person": { "name": "Tim Robbins", "nationality": "American" }
},
{
"characterName": "Red",
"person": { "name": "Morgan Freeman", "nationality": "American" }
}
]
}
}
}

This is one of GraphQL's most powerful features. In a single request, we've fetched a movie, its directors, its cast with character names, and the personal details of each actor. With REST, this would typically require multiple requests to different endpoints, or a single endpoint that over-fetches data for clients that only need the movie title.

Search for Movies

query {
searchMovies(title: "god") {
title
releaseYear
genre
}
}

Response:

{
"data": {
"searchMovies": [
{ "title": "The Godfather", "releaseYear": 1972, "genre": "CRIME" },
{ "title": "The Godfather Part II", "releaseYear": 1974, "genre": "CRIME" }
]
}
}

The search is case-insensitive thanks to findByTitleContainingIgnoreCase. Searching for "god" matches both "The Godfather" entries.

The Selective Power of GraphQL

Try this query that only asks for titles:

query {
movies {
title
}
}

Now compare it with this query that asks for everything:

query {
movies {
title
genre
rating
directors { name nationality }
cast { characterName person { name } }
}
}

Both queries hit the same endpoint (/graphql), but the first one only triggers the movies() resolver. The second triggers movies(), then directors() for each movie, then cast() for each movie, then loads each person. The server does exactly as much work as the client requires - no more, no less.

When Things Go Wrong: Null Propagation

Now for the rule that catches every GraphQL developer off guard at least once. When a non-null field errors during resolution, GraphQL doesn't just null that field. It can't, because the schema says the field can't be null. Instead, the error propagates up the tree until it hits the nearest nullable ancestor, and that whole ancestor becomes null.

The Rule

  1. A resolver throws an exception or returns null for a non-null (!) field.
  2. GraphQL walks up the parent chain looking for the first nullable ancestor.
  3. That ancestor is set to null, and an error entry is added to the errors array.
  4. Everything that was going to be resolved inside that ancestor is discarded.

Look at our schema:

type Movie {
id: ID!
title: String! # non-null
rating: Float # nullable
directors: [Person!]! # non-null list of non-null people
cast: [MovieCast!]! # non-null list of non-null cast entries
}

type Query {
movie(id: ID!): Movie # nullable
movies: [Movie!]! # non-null list of non-null movies
}

Scenario 1: A Non-Null Leaf Errors

Suppose Movie.title resolves to null (maybe a bad migration, maybe a bug in a @SchemaMapping). The schema says title: String!. GraphQL walks up:

  • Movie.title is non-null -> can't null it, propagate up
  • Movie itself is nullable from Query.movie, so it nulls

Response for query { movie(id: 1) { title directors { name } } }:

{
"data": {
"movie": null
},
"errors": [
{
"message": "Cannot return null for non-nullable field Movie.title",
"path": ["movie", "title"]
}
]
}

Notice what's gone: not just title, but the entire Movie object. The directors request never even ran. The client sees movie: null and a single error pointing at movie.title. This is correct behavior per the spec, but it's the single biggest source of "why did my whole object disappear" surprises.

Scenario 2: The Error Has Nowhere to Hide

Now suppose the same thing happens in a list query. movies is [Movie!]!-a non-null list of non-null movies. If one movie's title fails to resolve:

  • Movie.title errors -> propagate
  • Movie is non-null inside the list ([Movie!]) -> propagate
  • The list movies is non-null ([Movie!]!) -> propagate
  • Query.movies has no nullable ancestor... data itself becomes null

Response for query { movies { title } }:

{
"data": null,
"errors": [
{
"message": "Cannot return null for non-nullable field Movie.title",
"path": ["movies", 3, "title"]
}
]
}

One broken movie in position 3, and the entire response is gone. Twelve perfectly good movies vanished because one had a null title.

Scenario 3: Nullable Saves the Day

Change the list to [Movie!] (nullable list items):

type Query {
movies: [Movie] # nullable items
}

Now the same failure:

  • Movie.title errors -> propagate
  • Movie is nullable inside the list -> null it, stop propagating

Response:

{
"data": {
"movies": [
{ "title": "The Shawshank Redemption" },
{ "title": "The Godfather" },
{ "title": "The Godfather Part II" },
null,
{ "title": "Inception" }
]
},
"errors": [
{
"message": "Cannot return null for non-nullable field Movie.title",
"path": ["movies", 3, "title"]
}
]
}

One broken movie is null, the other twelve survive. The blast radius is contained.

Design Rule: Nullable First

Non-null (!) is a promise to the client that the field will always be there. If you can't keep that promise under realistic failure modes (database hiccups, lazy-loading edge cases, missing related entities), don't make it. Start fields as nullable and only add ! when you're genuinely sure.

This is especially true for list elements. [Movie!]! feels tidy until one bad row takes down your entire list query.

How to Debug Null Propagation

When a whole object or list mysteriously goes null:

  1. Check the errors array; the path tells you exactly which field triggered the propagation
  2. Trace that field's type in your schema; it will have a !
  3. Look at your resolver; it is returning null or throwing an exception
  4. Fix the resolver (preferred), or relax the nullability (pragmatic)

Exercises

Exercise 1: Find Shared Actors

Morgan Freeman appears in two of our movies. Write a query that fetches all movies with their cast, and identify which actor appears in multiple films.

Solution
query {
movies {
title
cast {
person { name }
characterName
}
}
}

Look through the results - Morgan Freeman appears as "Red" in The Shawshank Redemption and as "Detective William Somerset" in Se7en. Al Pacino and Robert De Niro also appear in multiple films.

Exercise 2: Add a Search by Genre

We already have searchMovies(title). Try adding a moviesByGenre(genre: Genre!): [Movie!]! query that filters movies by genre. You'll need to:

  1. Add the query to the schema
  2. Add a repository method
  3. Add a @QueryMapping method
Solution

schema.graphqls - add to Query:

moviesByGenre(genre: Genre!): [Movie!]!

MovieRepository.java - add:

List<Movie> findByGenre(Genre genre);

MovieController.java - add:

@QueryMapping
public List<Movie> moviesByGenre(@Argument Genre genre) {
return movieRepository.findByGenre(genre);
}

Note how the Genre enum argument works seamlessly - Spring GraphQL deserializes the GraphQL enum value into the Java enum automatically.

Exercise 3: Understand Lazy Loading

Enable SQL logging by setting show-sql: true in application.yaml. Then run the two queries from the "Selective Power" section above and compare the SQL output in your terminal. Notice how the first query (title only) generates far fewer SQL statements.

Common Issues

Issue: "Could not initialize proxy - no Session"

Error: LazyInitializationException: could not initialize proxy - no Session Solution: JPA tried to load a lazy relationship after its Hibernate session was closed. The reason our tutorial code does not normally hit this is Spring Boot's Open Session in View default (spring.jpa.open-in-view=true), which keeps the EntityManager open across the entire HTTP request, so lazy associations resolved by @SchemaMapping methods still find an active session. Spring GraphQL itself does not hold a JPA transaction open across resolvers; OSIV is what makes the simple lazy-loading path work here. If you see this error, check that you are resolving the relationship inside a request thread (not a background CompletableFuture or @Async task), and consider either using JOIN FETCH / @EntityGraph to load the data eagerly, or switching to the @BatchMapping approach from Class 8 (which is required once you turn OSIV off in production).

Issue: Cast entries don't appear

Error: Cast list is empty even though DataInitializer runs Solution: Make sure you're saving MovieCast entries after the movie has been persisted. The createAndSaveCastEntry() method in DataInitializer receives an already-saved Movie object, so its ID is set. If you try to create a MovieCast before the movie has an ID, JPA can't set the foreign key.

Issue: Circular references in JSON

Error: Stack overflow or infinite recursion Solution: This can happen if Jackson tries to serialize JPA entities with bidirectional relationships. GraphQL doesn't have this problem because it only serializes the fields the client requests. But if you're logging full entity objects, consider using @ToString.Exclude on relationship fields.

Summary

In this class, you learned:

  • Junction entities (MovieCast) model relationships that carry their own data, like character names - something a plain @ManyToMany join table cannot do
  • Nested field resolution works as a tree: GraphQL resolves parent objects first, then children, then grandchildren, calling @SchemaMapping methods or getters at each level
  • Fields are only resolved when requested - if a client doesn't ask for cast, the cast resolver never runs
  • Spring GraphQL auto-resolves fields that match getter names, so you only need explicit @SchemaMapping methods for custom logic or optimization
  • Search queries are straightforward - add a schema field, a repository method, and a @QueryMapping
  • Null propagation means errors on non-null fields bubble up to the nearest nullable ancestor, and can null out entire objects or lists. Start fields nullable, add ! only when you can truly guarantee a value

What's Next?

In Class 4: Mutations, we'll learn how to modify data through GraphQL:

  • Create, update, and delete movies
  • Design input types for clean mutation APIs
  • Handle the @MutationMapping annotation
  • Create persons through the API

Your API is about to become read-write!