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
@ManyToManyand 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.
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 theMovieCast.moviefield owns this relationship. Without this, JPA would try to create another join table, which isn't what we want -MovieCastalready has amovie_idforeign key column. -
cascade = CascadeType.ALLmeans that when we save or delete aMovie, JPA automatically saves or deletes its cast entries too. This is convenient for operations like "delete a movie and all its cast records." -
orphanRemoval = truemeans that if we remove aMovieCastfrom thecastlist, 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
@GraphQlRepositoryare automatically registered for queries where noDataFetcheris already registered and the return type matches the repository's domain type.
Applied to our schema (src/main/resources/graphql/schema.graphqls):
| Query | Return type | Already mapped in controller? | Outcome |
|---|---|---|---|
movies | [Movie!]! | Yes, MovieController.movies() at line 21 | Controller wins |
movie(id) | Movie | Yes, MovieController.movie() at line 26 | Controller wins |
searchMovies(title) | [Movie!]! | No | Auto-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
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.
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)
Stepping back from this specific trace, every GraphQL request goes through the same sequence:
- 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.
- Resolve root - The engine calls the root resolver (
@QueryMappingmethod) for the top-level field. - Resolve children - For each field requested on the returned object, the engine looks for a matching
@SchemaMappingmethod or a getter with the same name. - 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
@BatchMappingbatch all the siblings on that level in Class 8.) - 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.
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
- A resolver throws an exception or returns
nullfor a non-null (!) field. - GraphQL walks up the parent chain looking for the first nullable ancestor.
- That ancestor is set to
null, and an error entry is added to theerrorsarray. - 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.titleis non-null -> can't null it, propagate upMovieitself is nullable fromQuery.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.titleerrors -> propagateMovieis non-null inside the list ([Movie!]) -> propagate- The list
moviesis non-null ([Movie!]!) -> propagate Query.movieshas no nullable ancestor...dataitself 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.titleerrors -> propagateMovieis 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.
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.
When a whole object or list mysteriously goes null:
- Check the
errorsarray; thepathtells you exactly which field triggered the propagation - Trace that field's type in your schema; it will have a
! - Look at your resolver; it is returning
nullor throwing an exception - 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:
- Add the query to the schema
- Add a repository method
- Add a
@QueryMappingmethod
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@ManyToManyjoin table cannot do - Nested field resolution works as a tree: GraphQL resolves parent objects first, then children, then grandchildren, calling
@SchemaMappingmethods 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
@SchemaMappingmethods 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
@MutationMappingannotation - Create persons through the API
Your API is about to become read-write!