Class 2: Extending the Movie Schema for Agents
Duration: 60 minutes | Difficulty: Intermediate | Prerequisites: Class 1. The course's Movie Database application (cloned below).
In this lesson the moviedb GraphQL service gains two new operations, recommendMoviesForMood and summarizeMovieReviews. We also audit an existing one, addToWatchlist, line by line, because it is about to become an agent-facing mutation. Each of the three teaches a specific design rule for agent-oriented APIs. The existing MovieFilter input also gains two runtime bounds. That change is small, and it shows a common case: an agent needs a filter that the human-facing UI did not offer. By the end of this lesson we will have a working schema extension and a checklist for the next time you add, or adopt, an operation for an agent to call.
The Starting Point
The Movie Database application began in the Spring GraphQL tutorial and gained a few extra domain objects for this course, the watchlist among them. The main branch is this course's starting point. Clone it and start it even if you completed that tutorial, because the version you built there is missing the additions this course needs:
git clone https://github.com/graphqlguy/graphQLMovieDB-MCP.git
cd graphQLMovieDB-MCP
./mvnw spring-boot:run
The repository also carries a React web client for the app, which is here only to make browsing the data easier. The course does not teach any frontend work. The exercises use the schema through GraphiQL, and later through MCP tooling. Start the service first, because the client proxies /graphql to port 8080. Then open a second terminal in the same project directory and install the client's dependencies. That install is only needed the first time:
cd frontend
npm install
npm run dev
The service comes up with GraphQL at http://localhost:8080/graphql, GraphiQL, its interactive query editor, at http://localhost:8080/graphiql, and the web client at http://localhost:5173. If you completed the Spring GraphQL tutorial, the rest of this section will be familiar and you can skip ahead. For everyone else, here is a short tour of the app you just cloned.
The Movie Database (moviedb) is a Spring Boot 4, Java 21 service over an in-memory H2 database that a DataInitializer seeds with movies, TV shows, people, and reviews on every start. The domain is what the name promises: movies with directors and cast, TV shows with episodes, people appearing in both, and reviews carrying an integer 1-to-10 score. Movies and TV shows share a Content interface, a search query returns a union across movies, people, and TV shows, and the list queries already handle pagination and filtering.
Later lessons use these parts of the app:
- User accounts with JWT authentication. The
registerandloginmutations issue the token, and a security filter validates it on every request. - A per-user watchlist, guarded by
@PreAuthorize. - A live
communityRatingfield, fetched from the real TMDB API with batching and caching. - A
reviewAddedsubscription that pushes new reviews to subscribed clients over WebSocket. - Two graphql-java instrumentations that cap query depth and log slow resolvers.
The code is organized feature-first, with one package per domain (movie, tvshow, person, review, watchlist, user). Each package holds its entity, repository, service and controller.
The Operations We're Covering
We cover three operations, each chosen because it represents a category we will see again when designing for agents. The first two are new, and we design and build them in this lesson.
recommendMoviesForMood is a typed-input read: it returns data, but the input is not free text. The agent picks from a fixed set of moods, and the server controls what those moods mean.
summarizeMovieReviews is a server-side-work read: the server reads all the reviews and returns one summary, so the agent does not have to fetch them and summarize them itself.
addToWatchlist is a user-scoped write, and it is deliberately not new. The app arrives with a full watchlist feature: a watchlist query, the addToWatchlist, setWatchStatus, and removeFromWatchlist mutations, and the service and entity underneath them. That makes it the realistic enterprise case. You rarely design a new agent mutation. Usually you inherit one written for a human-driven frontend, and you check whether its contract still works for a caller that retries again and again. We audit it here, and Class 3 documents what the audit finds.
The same reasoning applies to most of the operations you will design later, because a new operation is usually a new instance of one of these categories.
Design Rules: Operations for Agents Are Designed Differently
A frontend wants flexibility. The frontend developer reads the schema, picks the fields they need, and writes one carefully chosen request that runs millions of times. That kind of client benefits from wide schemas, free-text search fields and forgiving nullability. An agent needs almost the opposite:
- limits on what it can do
- a small set of operations whose names and inputs are hard to misread
- outputs that say what they mean
The three operations in this lesson illustrate the four rules below.
Narrow Inputs Over Open Ones
Given a free-text input, an agent will use it. It will guess synonyms, paraphrase, paste in whatever the user typed, and on retries it will try a different phrasing in the hope of a different answer. Each phrasing is a separate code path on your server with its own failure modes. A narrow input takes the choice away from the agent. recommendMoviesForMood takes a Mood enum, not a String. The agent has to pick from a list, and you control the list.
Controlled Vocabularies Over Free Text
This is the same rule, applied more widely. Wherever an agent might supply free text, ask whether the set of valid values is finite. If it is, model it as an enum. The Mood enum in this lesson has six values: COMFORT, ADVENTURE, ROMANCE, HORROR, THOUGHTFUL, COMEDY. Adding a seventh mood is a schema change, and that is deliberate. The schema and the recommendation engine change together, so the engine cannot quietly gain moods that the schema does not list. The existing app already applies the same rule where it matters: addToWatchlist takes a WatchStatus enum (WANT_TO_WATCH, WATCHED), not a free-text status string.
Idempotency Where Possible
Agents retry: on timeouts, on errors they think they saw, when the user asks again, and sometimes inside their own reasoning loops. If a write operation is not idempotent, every retry is a chance to corrupt state. The existing addToWatchlist is instructive because it is idempotent in one sense and not in another. It is state-idempotent: a repeated add cannot create a second row. The service checks for an existing entry, and a unique constraint per user and title backs that check in the database.
It is not response-idempotent: the repeat call gets a BAD_REQUEST error ("This movie is already in your watch list") instead of the existing entry. Retries are safe for your data either way. Whether they are safe for the agent's reasoning depends on the agent understanding that this error means "already done" and not "failed". Surfacing that distinction is documentation work, and it is exactly what Class 3 does to this mutation.
Explicit Nullability Semantics
GraphQL lets you mark a field nullable. It does not, on its own, tell the caller what null means. For an agent, "I got null back" is ambiguous: did the operation succeed with an empty result, fail silently, or hit a precondition? summarizeMovieReviews itself always returns a MovieReviewSummary. One field inside it is nullable, summary, and its description states what that null means: "not enough data to write a synthesis", which is a different thing from "no reviews exist". A movie with zero reviews still gets a summary object back, with reviewCount: 0 and summary: null. A movie id that does not exist is a different case again, and it produces a NOT_FOUND error.
Splitting the cases this way gives the agent a contract it can reason about.
Implementing recommendMoviesForMood
Add the following to src/main/resources/graphql/schema.graphqls. The new field goes inside the Query type your schema already declares, alongside the queries already there. A second type Query block would fail, because graphql-java rejects a schema that defines the same type twice. Note where the description sits: on the field itself, in triple quotes directly above it.
"""
Recommends movies that fit a given mood. Suitable for low-stakes recommendation
flows where an agent is asking on behalf of a user. Results are ranked by rating,
highest first, with a stable tiebreak on id, so repeated calls return the same
order.
"""
recommendMoviesForMood(
mood: Mood!
excludeWatched: Boolean! = false
): [Movie!]!
"""
Available mood categories. Bounded by design so an agent cannot supply free-text
moods that would force fuzzy matching on the server. Add a new value here and
update the recommendation engine in the same change.
"""
enum Mood {
COMFORT
ADVENTURE
ROMANCE
HORROR
THOUGHTFUL
COMEDY
}
Three design choices in that SDL need explaining. First, the default value on excludeWatched. An agent that retries should see the same shape on the retry. Without the default, omitting the argument and passing false would take different code paths, even though they mean the same thing. The schema default collapses both into one behavior. Second, the ! on excludeWatched itself.
A default value covers the argument being omitted. The GraphQL specification treats an explicit null as a different case, which the default never reaches, so a nullable argument gives the agent a third input to send. Marking it non-null leaves exactly two, true and false. Third, the cardinality [Movie!]!. The outer ! says the list itself cannot be null (an empty list means "no recommendations"), and the inner ! says the same of every element. Callers do not have to defend against null at two different positions, and an empty list has a clear meaning.
We can implement excludeWatched today because the Movie Database app already keeps a per-user watchlist, and every entry on it carries a WATCHED status. When an agent passes excludeWatched: true, the service reads that list for the current viewer and drops the movies already marked WATCHED, which is the RecommendationService code further down this lesson.
The general question to ask of any argument you add to an agent-facing operation is whether the server can answer it from state it already holds. When it can, the agent sends one boolean and gets back a list it can use directly. When it cannot, the same result takes a second call to fetch the watchlist. The whole watchlist then travels back through the model's context window, and the filtering becomes the model's job instead of the database's.
Now the Java side. Spring for GraphQL discovers Java enums whose names match GraphQL enums automatically, so the type itself is all we need:
package com.graphqlguy.moviedb.recommendation;
public enum Mood {
COMFORT,
ADVENTURE,
ROMANCE,
HORROR,
THOUGHTFUL,
COMEDY
}
The recommendation service is where the mood turns into an actual filter. Each Mood maps to a MoodProfile. A profile holds the genres the mood draws from, a minimum rating that leaves out the lowest-rated movies, and an optional maximum runtime for moods where a shorter film fits better. COMFORT pulls from comedy, romance, and fantasy, requires at least a 7.0 rating, and caps runtime at 140 minutes. ADVENTURE, ROMANCE, HORROR, and THOUGHTFUL leave runtime open, and set their own rating floors and genre sets:
package com.graphqlguy.moviedb.recommendation;
import com.graphqlguy.moviedb.movie.Movie;
import com.graphqlguy.moviedb.shared.Genre;
import java.util.Map;
import java.util.Set;
/**
* Maps a mood onto the catalogue's genre vocabulary plus quality and length
* constraints. Moods are cross-genre by nature, which is why this is a set and
* not a single genre: COMFORT is a tone, not a category.
*/
public record MoodProfile(Set<Genre> genres, double minRating, Integer maxRuntime) {
private static final Map<Mood, MoodProfile> PROFILES = Map.of(
Mood.COMFORT, new MoodProfile(Set.of(Genre.COMEDY, Genre.ROMANCE, Genre.FANTASY), 7.0, 140),
Mood.ADVENTURE, new MoodProfile(Set.of(Genre.ACTION, Genre.FANTASY, Genre.SCIFI, Genre.WESTERN), 6.5, null),
Mood.ROMANCE, new MoodProfile(Set.of(Genre.ROMANCE, Genre.DRAMA), 6.5, null),
Mood.HORROR, new MoodProfile(Set.of(Genre.HORROR, Genre.THRILLER, Genre.MYSTERY), 6.0, null),
Mood.THOUGHTFUL, new MoodProfile(Set.of(Genre.DRAMA, Genre.SCIFI, Genre.WAR, Genre.MYSTERY, Genre.CRIME), 7.5, null),
Mood.COMEDY, new MoodProfile(Set.of(Genre.COMEDY), 6.0, null));
public static MoodProfile forMood(Mood mood) {
return PROFILES.get(mood);
}
public boolean matches(Movie movie) {
if (movie.getGenre() == null || !genres.contains(movie.getGenre())) {
return false;
}
if (movie.getRating() == null || movie.getRating() < minRating) {
return false;
}
return maxRuntime == null || (movie.getRuntime() != null && movie.getRuntime() <= maxRuntime);
}
}
matches checks the genre set, the rating floor, and, when the mood has one, the runtime cap. A movie with an unknown rating or runtime fails the check, so an unknown value does not count as a hit. This is a real implementation. To tune the recommender, you edit the numbers in MoodProfile, and the controller and the schema stay as they are. The service asks every candidate movie whether it matches the current mood's profile, ranks what is left, and trims it to ten. The excludeWatched path reuses the app's existing WatchlistItemRepository and filters out every movie the caller has marked WATCHED:
package com.graphqlguy.moviedb.recommendation;
import com.graphqlguy.moviedb.movie.Movie;
import com.graphqlguy.moviedb.movie.MovieRepository;
import com.graphqlguy.moviedb.user.AppUser;
import com.graphqlguy.moviedb.watchlist.WatchStatus;
import com.graphqlguy.moviedb.watchlist.WatchlistItemRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class RecommendationService {
private final MovieRepository movieRepository;
private final WatchlistItemRepository watchlistRepository;
public List<Movie> recommendForMood(Mood mood, boolean excludeWatched, AppUser viewer) {
// Each mood maps to a genre set plus rating and runtime constraints in
// MoodProfile. Results rank by rating, highest first, with a stable
// tiebreak on id so the order does not change between calls.
MoodProfile profile = MoodProfile.forMood(mood);
Set<Long> excludedMovieIds = watchedMovieIds(excludeWatched, viewer);
return movieRepository.findAll().stream()
.filter(profile::matches)
.filter(m -> !excludedMovieIds.contains(m.getId()))
.sorted(Comparator.comparingDouble(Movie::getRating).reversed()
.thenComparing(Movie::getId))
.limit(10)
.toList();
}
private Set<Long> watchedMovieIds(boolean excludeWatched, AppUser viewer) {
// Anonymous viewers (viewer == null) are supported on purpose: the operation
// is callable without authentication, and an anonymous caller does not have a
// watchlist to exclude against.
if (!excludeWatched || viewer == null) {
return Set.of();
}
return watchlistRepository.findWithContentByUserId(viewer.getId()).stream()
.filter(item -> item.getStatus() == WatchStatus.WATCHED && item.getMovie() != null)
.map(item -> item.getMovie().getId())
.collect(Collectors.toSet());
}
}
The result is deterministic, and it does not depend on a clock. The same mood always filters to the same set of movies, and Comparator.comparingDouble(Movie::getRating).reversed().thenComparing(Movie::getId) orders that set by rating, highest first, with id breaking ties among movies that share a rating. A user who refreshes the page, or an agent that retries, always sees the same ten movies in the same order.
The item.getMovie() != null check is there for null safety. A WatchlistItem points at either a movie or a TV show, mirroring the @oneOf input that created it. So getMovie() returns null on every TV-show row, and without the filter the getMovie().getId() call on the next line would throw a NullPointerException. Skipping those rows is also correct for the recommendation itself: a watched TV show is not a movie that could appear in a movie recommendation.
The controller is small. It takes the two arguments, identifies the caller through an injected Principal, and delegates to the service. That injection is the pattern the Spring GraphQL tutorial's security classes established. Spring for GraphQL resolves the principal from the security context the JWT filter populated, which is cleaner than reaching into SecurityContextHolder by hand. Because this operation is callable anonymously, the controller guards the anonymous case and passes a null viewer:
package com.graphqlguy.moviedb.recommendation;
import com.graphqlguy.moviedb.movie.Movie;
import com.graphqlguy.moviedb.user.AppUser;
import com.graphqlguy.moviedb.user.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.stereotype.Controller;
import java.security.Principal;
import java.util.List;
@Controller
@RequiredArgsConstructor
public class RecommendationController {
private final RecommendationService recommendationService;
private final UserRepository userRepository;
@QueryMapping
List<Movie> recommendMoviesForMood(
@Argument Mood mood,
@Argument boolean excludeWatched,
Principal principal) {
AppUser viewer = resolveViewer(principal);
return recommendationService.recommendForMood(mood, excludeWatched, viewer);
}
private AppUser resolveViewer(Principal principal) {
if (principal == null || "anonymousUser".equals(principal.getName())) {
return null;
}
return userRepository.findByUsername(principal.getName()).orElse(null);
}
}
Note the primitive boolean for excludeWatched. It is safe because the argument is declared non-null, not merely because it has a default. The default supplies false when the agent omits the argument. The ! makes validation refuse an explicit null before the resolver runs, so nothing can arrive that a primitive cannot hold. If the argument were nullable, a client sending excludeWatched: null would reach this parameter and fail to unbox, which surfaces to the caller as an internal server error instead of a client error. Spring reports that mismatch at startup, in the schema inspection report it prints on the way up: boolean excludeWatched should be NULLABLE.
No @PreAuthorize appears anywhere on this path. That is deliberate. The recommendation operation is callable by anonymous users (the controller resolves the viewer to null and the service tolerates it), so it does not need an authentication requirement. The rest of the project follows the pattern the Spring GraphQL tutorial's authorization class established: @PreAuthorize sits on the methods that require it. MovieService and ReviewService gate their admin and logged-in-only operations at the service layer, and the watchlist controller gates all four watchlist operations with @PreAuthorize("isAuthenticated()"). This one operation opts out of that requirement.
Widening MovieFilter with a Runtime Bound
Not every change in this lesson is a new operation. The base app's movies query already accepts a MovieFilter input with genre, minRating, maxRating, minYear, and maxYear, and it lacks the one condition that the course's main demo needs later. A user types something like "a comedy from the last ten years, shorter than two hours." The model has to turn that whole sentence into one structured call: genre, a year range, and a length. The schema could express the first two clauses and not the third, so add minRuntime and maxRuntime to MovieFilter:
input MovieFilter {
genre: Genre
minRating: Float
maxRating: Float
minYear: Int
maxYear: Int
"""Only include movies at least this many minutes long. Movies with an unknown runtime are excluded when this is set."""
minRuntime: Int
"""Only include movies at most this many minutes long. Movies with an unknown runtime are excluded when this is set."""
maxRuntime: Int
titleContains: String
}
The Java side follows the pattern the year bounds already established. The two fields join the MovieFilter record:
package com.graphqlguy.moviedb.movie;
import com.graphqlguy.moviedb.shared.Genre;
public record MovieFilter(
Genre genre,
Double minRating,
Double maxRating,
Integer minYear,
Integer maxYear,
Integer minRuntime,
Integer maxRuntime,
String titleContains
) {}
and the derived query in MovieRepository grows two more optional conditions, following the same :param IS NULL OR ... shape the existing bounds use:
@Query("SELECT m FROM Movie m WHERE " +
"(:genre IS NULL OR m.genre = :genre) AND " +
"(:minRating IS NULL OR m.rating >= :minRating) AND " +
"(:maxRating IS NULL OR m.rating <= :maxRating) AND " +
"(:minYear IS NULL OR m.releaseYear >= :minYear) AND " +
"(:maxYear IS NULL OR m.releaseYear <= :maxYear) AND " +
"(:minRuntime IS NULL OR m.runtime >= :minRuntime) AND " +
"(:maxRuntime IS NULL OR m.runtime <= :maxRuntime) AND " +
"(:titleContains IS NULL OR LOWER(m.title) LIKE LOWER(CONCAT('%', :titleContains, '%')))")
Page<Movie> findWithFilters(
@Param("genre") Genre genre,
@Param("minRating") Double minRating,
@Param("maxRating") Double maxRating,
@Param("minYear") Integer minYear,
@Param("maxYear") Integer maxYear,
@Param("minRuntime") Integer minRuntime,
@Param("maxRuntime") Integer maxRuntime,
@Param("titleContains") String titleContains,
Pageable pageable);
Adding two parameters to the query method breaks its one caller until that caller passes them, so MovieService.findMovies grows two lines in the middle of the argument list. The order has to match the method signature:
public MoviePage findMovies(MovieFilter filter, int page, int size, MovieSort sort) {
latencySimulator.pause();
Pageable pageable = PageRequest.of(page, size, buildSort(sort));
Page<Movie> result = movieRepository.findWithFilters(
filter != null ? filter.genre() : null,
filter != null ? filter.minRating() : null,
filter != null ? filter.maxRating() : null,
filter != null ? filter.minYear() : null,
filter != null ? filter.maxYear() : null,
filter != null ? filter.minRuntime() : null,
filter != null ? filter.maxRuntime() : null,
filter != null ? filter.titleContains() : null,
pageable);
return new MoviePage(
result.getContent(), result.getTotalElements(), result.getTotalPages(),
result.getNumber(), result.getSize(), result.isFirst(), result.isLast(),
result.hasNext(), result.hasPrevious());
}
The filter != null guard on every line is the existing shape: movies can be called with no filter at all, and the query then sees null for every bound. Nothing else in the service changes.
A movie with an unknown runtime is excluded when a runtime bound is set, matching how the existing year bounds already behave. runtime is nullable in the catalog, because some seeded titles do not have one recorded. The IS NULL OR clause only short-circuits when the caller leaves the bound unset. For a row whose runtime column is NULL, SQL evaluates m.runtime >= :minRuntime and m.runtime <= :maxRuntime to unknown, so the row is filtered out. That distinction matters for an agent piecing a filter together from a sentence. An unbounded runtime and an unknown runtime look the same in a plain movies { runtime } read, but they behave differently once a bound is applied. The descriptions of the two new fields say so directly, so the agent does not have to discover it by trial and error.
Keep this separate from the maxRuntime inside MoodProfile, defined earlier in this lesson. That one is a fixed cap for each mood, which the recommendation engine sets on the server. MovieFilter.maxRuntime is a bound that the caller supplies to the general-purpose movies query. Both use the same name and the same column, but different callers decide the number.
Implementing summarizeMovieReviews
Add the following to the schema. The new field joins recommendMoviesForMood inside the same Query type, and a new result type appears alongside it.
"""
Returns a summary of user reviews for a movie. Always returns a summary object
for a movie that exists; an unknown id produces a NOT_FOUND error. When the
movie has too few reviews to summarize, `summary` is null and
`reviewCount` says how many there were.
"""
summarizeMovieReviews(
"""The id of the movie whose reviews should be summarized. Unknown ids produce a NOT_FOUND error."""
movieId: ID!
): MovieReviewSummary!
"""
A structured summary of the user reviews for one movie, computed server-side
from the review corpus.
"""
type MovieReviewSummary {
"""The id of the summarized movie, echoing the operation's argument. Present so the summary is self-describing when it travels as an MCP tool result."""
movieId: ID!
"""How many reviews this summary was computed from."""
reviewCount: Int!
"""A short summary of what reviewers said. Null when there are too few reviews to write one; read reviewCount to see how many there were."""
summary: String
"""Recurring subjects across the reviews. Empty when none were identified."""
themes: [String!]!
"""Mean of the 1-to-10 review scores. Null until the movie has at least one review."""
averageScore: Float
}
The choice that matters most here is on the operation itself, not on the type name. summarizeMovieReviews returns MovieReviewSummary!, never null: any movie that exists gets an object back, and an id that does not resolve to a movie is a NOT_FOUND error, not a null result. Nullability moves down one level, onto summary. That field is null exactly when there is not enough material to write a summary from, and the description says so. An agent that gets back summary: null with reviewCount: 1 knows the reason: the movie has reviews, but too few to summarize.
The raw material for the summary is the base app's Review type, and its rating field is score: Int!, an integer from 1 to 10, not a float. That scale carries into averageScore, the mean of those integers, and into whatever synthesis logic replaces the stub later. When Class 3 writes the agent-facing descriptions, each one has to name the 1-to-10 range. An agent that assumes a five-star scale, or a percentage, misreads every score it sees.
That was the last schema change in this lesson. Here is the whole file as Class 2 leaves it:
The complete schema.graphqls, collapsed because it runs to 475 lines
scalar DateTime
scalar CountryCode
directive @Range(min: Int = 0, max: Int = 2147483647, message: String = "graphql.validation.Range.message") on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
directive @Size(min: Int = 0, max: Int = 2147483647, message: String = "graphql.validation.Size.message") on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
directive @Pattern(regexp: String!, message: String = "graphql.validation.Pattern.message") on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
# The single entry point of our API. This line is a comment, by the way:
# comments start with # and are only for people reading this file.
type Query {
moviesAll: [Movie!]! @deprecated(reason: "Unbounded. Use 'movies' instead.")
movie(id: ID!): Movie
movies(filter: MovieFilter, page: Int = 0 @Range(min: 0), size: Int = 10 @Range(min: 1, max: 100), sort: MovieSort): MoviePage!
moviesByIds(ids: [ID!]!): [Movie]
searchMovies(title: String!): [Movie!]! @deprecated(reason: "Use 'search' instead.")
people(page: Int = 0 @Range(min: 0), size: Int = 20 @Range(min: 1, max: 100)): PersonPage!
person(id: ID!): Person
searchPeople(name: String!): [Person!]! @deprecated(reason: "Use 'search' instead.")
search(query: String!): [SearchResult!]!
"""Look up a country by its ISO 3166-1 alpha-2 code, e.g. CZ"""
country(code: ID!): Country
"""Search TMDB (The Movie Database) for movies by title"""
tmdbSearch(title: String!): [TmdbResult!]!
tvShow(id: ID!): TvShow
tvShows(page: Int = 0 @Range(min: 0), size: Int = 10 @Range(min: 1, max: 100)): TvShowPage!
"""The signed-in user's watch list."""
watchlist: [WatchlistItem!]!
"""
Recommends movies that fit a given mood. Suitable for low-stakes recommendation
flows where an agent is asking on behalf of a user. Results are ranked by rating,
highest first, with a stable tiebreak on id, so repeated calls return the same
order.
"""
recommendMoviesForMood(
mood: Mood!
excludeWatched: Boolean! = false
): [Movie!]!
"""
Returns a summary of user reviews for a movie. Always returns a summary object
for a movie that exists; an unknown id produces a NOT_FOUND error. When the
movie has too few reviews to summarize, `summary` is null and
`reviewCount` says how many there were.
"""
summarizeMovieReviews(
"""The id of the movie whose reviews should be summarized. Unknown ids produce a NOT_FOUND error."""
movieId: ID!
): MovieReviewSummary!
}
"""
Available mood categories. Bounded by design so an agent cannot supply free-text
moods that would force fuzzy matching on the server. Add a new value here and
update the recommendation engine in the same change.
"""
enum Mood {
COMFORT
ADVENTURE
ROMANCE
HORROR
THOUGHTFUL
COMEDY
}
"""
A structured summary of the user reviews for one movie, computed server-side
from the review corpus.
"""
type MovieReviewSummary {
"""The id of the summarized movie, echoing the operation's argument. Present so the summary is self-describing when it travels as an MCP tool result."""
movieId: ID!
"""How many reviews this summary was computed from."""
reviewCount: Int!
"""A short summary of what reviewers said. Null when there are too few reviews to write one; read reviewCount to see how many there were."""
summary: String
"""Recurring subjects across the reviews. Empty when none were identified."""
themes: [String!]!
"""Mean of the 1-to-10 review scores. Null until the movie has at least one review."""
averageScore: Float
}
"""Union of Movie and Person for cross-type search"""
union SearchResult = Movie | Person | TvShow
"""Something watchable: the fields a Movie and a TV show have in common."""
interface Content {
title: String!
genre: Genre
rating: Float
posterUrl: String
}
"""
Custom Movie type
All movie details are taken from The Movie Database (TMDB)
Triple quotes allow multiline documentation.
"""
# We will expand the Movie type as we progress through the tutorial
type Movie implements Content {
id: ID!
title: String!
releaseYear: Int!
genre: Genre
"Average rating from 0 to 10"
rating: Float
"Runtime in minutes"
runtime: Int
plot: String
posterUrl: String
"The Movie Database (TMDB) external identifier"
tmdbId: Int
"""Live community rating from TMDB; null when the movie does not have a tmdbId or TMDB is unavailable"""
communityRating: CommunityRating
directors: [Person!]!
cast: [MovieCast!]!
reviews: [Review!]!
"""Number of reviews - resolved as a grouped COUNT via @BatchMapping"""
reviewCount: Int!
}
enum Genre {
ACTION
COMEDY
CRIME
DRAMA
FANTASY
HORROR
MYSTERY
ROMANCE
SCIFI
THRILLER
WAR
WESTERN
}
enum MovieSortField {
TITLE
RELEASE_YEAR
RATING
RUNTIME
}
enum SortOrder {
ASC
DESC
}
input MovieFilter {
genre: Genre
minRating: Float
maxRating: Float
minYear: Int
maxYear: Int
"""Only include movies at least this many minutes long. Movies with an unknown runtime are excluded when this is set."""
minRuntime: Int
"""Only include movies at most this many minutes long. Movies with an unknown runtime are excluded when this is set."""
maxRuntime: Int
titleContains: String
}
input MovieSort {
field: MovieSortField
order: SortOrder
}
type MoviePage {
content: [Movie!]!
totalElements: Int!
totalPages: Int!
currentPage: Int!
size: Int!
isFirst: Boolean!
isLast: Boolean!
hasNext: Boolean!
hasPrevious: Boolean!
}
"""A page of people"""
type PersonPage {
content: [Person!]!
totalElements: Int!
totalPages: Int!
currentPage: Int!
size: Int!
}
type Person {
id: ID!
name: String!
birthYear: Int
"""Short biography blurb"""
biography: String
"""Portrait image URL (TMDB)"""
photoUrl: String
"""ISO 3166-1 alpha-2 country code"""
countryCode: CountryCode
"""Country details resolved live from the external countries GraphQL API"""
country: Country
"""Movies this person directed"""
directedMovies: [Movie!]!
"""This person's acting credits in movies"""
movieCastCredits: [MovieCast!]!
"""TV shows this person created"""
createdShows: [TvShow!]!
"""This person's acting credits in TV shows"""
tvShowCastCredits: [TvShowCast!]!
}
type MovieCast {
id: ID!
characterName: String!
person: Person!
movie: Movie!
}
type Country {
"ISO 3166-1 alpha-2 code, e.g. CZ"
code: ID!
name: String!
emoji: String
capital: String
currency: String
}
"""A movie search result from TMDB (The Movie Database)"""
type TmdbResult {
tmdbId: Int!
title: String!
releaseYear: Int
overview: String
posterUrl: String
"""TMDB community vote average on a 0-10 scale"""
rating: Float
}
type CommunityRating {
"""Vote average on a 0-10 scale"""
voteAverage: Float!
voteCount: Int!
}
type AuthResponse {
token: String!
user: User!
}
type User {
id: ID!
username: String!
email: String
role: Role!
}
enum Role {
USER,
ADMIN
}
type Mutation {
deleteMovie(id: ID!): DeleteMovieResponse!
"Deletes a person. With force: true, all movie/TV credits are unlinked first; otherwise a linked person yields a structured error."
deletePerson(id: ID!, force: Boolean! = false): DeletePersonResponse!
createMovie(input: CreateMovieInput!): Movie!
updateMovie(input: UpdateMovieInput!): Movie!
createPerson(input: CreatePersonInput!): Person!
updatePerson(input: UpdatePersonInput!): Person!
login(input: LoginInput!): AuthResponse!
"""Create a new USER account and sign in immediately"""
register(input: RegisterInput!): AuthResponse!
"""Submit a review for a movie or TV show (one per user per title)"""
createReview(input: CreateReviewInput!): Review!
"""Delete a review (owner or admin only)"""
deleteReview(id: ID!): DeleteReviewResponse!
"""Add a movie or TV show to the signed-in user's watch list."""
addToWatchlist(subject: WatchlistSubjectInput!, status: WatchStatus): WatchlistItem!
"""Change a watch-list entry's status, e.g. mark it watched."""
setWatchStatus(itemId: ID!, status: WatchStatus!): WatchlistItem!
"""Remove an entry from the watch list."""
removeFromWatchlist(itemId: ID!): Boolean!
}
input LoginInput {
username: String!
password: String!
}
"""Input for creating an account; field rules enforced by validation directives"""
input RegisterInput {
username: String! @Size(min: 3, max: 50)
email: String! @Size(max: 120) @Pattern(regexp: "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")
password: String! @Size(min: 6, max: 100)
}
"""Result of deleting a movie."""
type DeleteMovieResponse {
success: Boolean!
message: String!
"""ID of the deleted movie, or null if nothing was deleted."""
deletedId: ID
}
type DeletePersonResponse {
success: Boolean!
error: DeletePersonError
deletedId: ID
}
enum DeletePersonError {
LINKED_TO_MOVIE
LINKED_TO_TV_SHOW
}
"""Input for creating a movie."""
input CreateMovieInput {
title: String! @Size(min: 1, max: 200)
releaseYear: Int! @Range(min: 1888, max: 2100)
genre: Genre!
"""Rating from 0.0 to 10.0"""
rating: Float @Range(min: 0, max: 10)
"""Runtime in minutes"""
runtime: Int @Range(min: 1, max: 600)
plot: String @Size(max: 2000)
posterUrl: String @Size(max: 500)
tmdbId: Int
}
"""Input for updating a movie; omitted fields are left unchanged."""
input UpdateMovieInput {
id: ID!
title: String @Size(min: 1, max: 200)
releaseYear: Int @Range(min: 1888, max: 2100)
genre: Genre
"""Rating from 0.0 to 10.0"""
rating: Float @Range(min: 0, max: 10)
"""Runtime in minutes"""
runtime: Int @Range(min: 1, max: 600)
plot: String @Size(max: 2000)
posterUrl: String @Size(max: 500)
tmdbId: Int
}
input UpdatePersonInput {
id: ID!
name: String
birthYear: Int
countryCode: CountryCode
}
input CreatePersonInput {
name: String!
birthYear: Int
countryCode: CountryCode
}
"""A television show"""
type TvShow implements Content {
id: ID!
title: String!
genre: Genre
"Average rating on a scale of 0-10"
rating: Float
posterUrl: String
startYear: Int!
"Null while the show is still airing"
endYear: Int
seasons: Int
plot: String
creators: [Person!]!
cast: [TvShowCast!]!
episodes: [Episode!]!
reviews: [Review!]!
}
"""A single episode of a TV show"""
type Episode {
id: ID!
seasonNumber: Int!
episodeNumber: Int!
title: String!
overview: String
"Runtime in minutes"
runtime: Int
airYear: Int
}
"""An actor's role in a TV show, linking a person to a character"""
type TvShowCast {
id: ID!
characterName: String!
person: Person!
tvShow: TvShow!
}
"""A page of TV shows"""
type TvShowPage {
content: [TvShow!]!
totalElements: Int!
totalPages: Int!
currentPage: Int!
size: Int!
}
"""A single entry in a user's watch list: a movie or a show, plus its status."""
type WatchlistItem {
id: ID!
content: Content!
status: WatchStatus!
}
enum WatchStatus {
WANT_TO_WATCH
WATCHED
}
"""Identifies the title to add. @oneOf: provide exactly one of movieId or tvShowId."""
input WatchlistSubjectInput @oneOf {
movieId: ID
tvShowId: ID
}
"""A user's review of a movie or TV show"""
type Review {
id: ID!
"""Rating from 1 to 10"""
score: Int!
comment: String
createdAt: DateTime!
user: User!
}
type Subscription {
"""Live notifications when reviews are posted, pushed over WebSocket. Optionally filter by movieId or tvShowId."""
reviewAdded(movieId: ID, tvShowId: ID): ReviewNotification!
}
"""Notification pushed to subscribers when a review is created"""
type ReviewNotification {
review: Review!
"""Set when the review targets a movie"""
movieId: ID
"""Set when the review targets a TV show"""
tvShowId: ID
"""Title of the reviewed movie or show"""
title: String!
}
"""
The review target. @oneOf makes this an input union: exactly one of the
fields must be provided, enforced by GraphQL itself before any resolver runs.
"""
input ReviewSubjectInput @oneOf {
movieId: ID
tvShowId: ID
}
"""Input for creating a review of a movie or TV show"""
input CreateReviewInput {
subject: ReviewSubjectInput!
"""Rating from 1 to 10"""
score: Int! @Range(min: 1, max: 10)
comment: String @Size(max: 2000)
}
"""Result of deleting a review."""
type DeleteReviewResponse {
success: Boolean!
"""ID of the deleted review, or null if nothing was deleted."""
deletedId: ID
}
One wiring note before the code. The app's ReviewRepository batch-loads reviews by lists of ids for the N+1 optimizations, but does not include a single-movie lookup, so we add one derived query to it. A review in this app targets either a movie or a TV show. The derived query filters on the movie relation, so TV-show reviews never match:
List<Review> findByMovieIdOrderByCreatedAtDesc(Long movieId);
The Java summary type is a record, mirroring the SDL type field for field:
package com.graphqlguy.moviedb.review.summary;
import java.util.List;
public record MovieReviewSummary(
Long movieId,
int reviewCount,
String summary,
List<String> themes,
Double averageScore
) {}
The service implements the full contract in one method. An unknown movie id makes movieRepository.existsById fail, and the service raises EntityNotFoundException, the base app's shared exception that its GlobalExceptionHandler already maps to a NOT_FOUND error. A movie with zero reviews returns a summary with reviewCount: 0, summary: null, and averageScore: null. A movie with one or two reviews, below the summary threshold, returns summary: null but a real averageScore, computed from whatever reviews exist. A movie with three or more reviews gets a placeholder text summary; Class 11 replaces that placeholder with a real synthesis generated through MCP sampling against the client's own model. For Class 2 the goal is the contract, not the wording:
package com.graphqlguy.moviedb.review.summary;
import com.graphqlguy.moviedb.exception.EntityNotFoundException;
import com.graphqlguy.moviedb.movie.MovieRepository;
import com.graphqlguy.moviedb.review.Review;
import com.graphqlguy.moviedb.review.ReviewRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MovieReviewSummaryService {
private static final int MIN_REVIEWS_FOR_SUMMARY = 3;
private final MovieRepository movieRepository;
private final ReviewRepository reviewRepository;
public MovieReviewSummary summarize(Long movieId) {
if (!movieRepository.existsById(movieId)) {
throw new EntityNotFoundException("Movie", movieId);
}
List<Review> reviews = reviewRepository.findByMovieIdOrderByCreatedAtDesc(movieId);
if (reviews.isEmpty()) {
return new MovieReviewSummary(movieId, 0, null, List.of(), null);
}
double average = reviews.stream().mapToInt(Review::getScore).average().orElseThrow();
if (reviews.size() < MIN_REVIEWS_FOR_SUMMARY) {
return new MovieReviewSummary(movieId, reviews.size(), null, List.of(), average);
}
// Stub synthesis. Class 11 replaces this with MCP sampling against the client's model.
return new MovieReviewSummary(
movieId,
reviews.size(),
"Reviewers broadly agree on this one; see the individual reviews for detail.",
List.of(),
average);
}
}
The controller hands the work to the service:
package com.graphqlguy.moviedb.review.summary;
import lombok.RequiredArgsConstructor;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.stereotype.Controller;
@Controller
@RequiredArgsConstructor
public class MovieReviewSummaryController {
private final MovieReviewSummaryService summaryService;
@QueryMapping
MovieReviewSummary summarizeMovieReviews(@Argument Long movieId) {
return summaryService.summarize(movieId);
}
}
Two different things happen when the contract is not met, and Spring for GraphQL handles both cleanly. An unknown movieId makes the service throw an exception before it ever builds a MovieReviewSummary, and the app's exception mapping turns that into a NOT_FOUND GraphQL error, never a null result. A known movie with too little material returns a real MovieReviewSummary whose summary field is Java null, which Spring for GraphQL serializes as JSON null on that one field. The agent receives an object either way, and the description tells it what a null summary means.
Adopting the Existing addToWatchlist
The third operation does not need any new schema or code. It still gets the longest discussion, because adopting an existing operation is the common case. Here is what the Spring GraphQL tutorial already shipped, exactly as it stands in schema.graphqls:
"""Add a movie or TV show to the signed-in user's watch list."""
addToWatchlist(subject: WatchlistSubjectInput!, status: WatchStatus): WatchlistItem!
and the types it uses:
"""Identifies the title to add. @oneOf: provide exactly one of movieId or tvShowId."""
input WatchlistSubjectInput @oneOf {
movieId: ID
tvShowId: ID
}
enum WatchStatus {
WANT_TO_WATCH
WATCHED
}
"""A single entry in a user's watch list: a movie or a show, plus its status."""
type WatchlistItem {
id: ID!
content: Content!
status: WatchStatus!
}
This mutation was designed for a human-driven frontend. We audit it against the design rules above to see how much of it already suits agents.
Narrow inputs: pass. No free text appears anywhere. The subject is a @oneOf input, GraphQL's way of saying "exactly one of these fields, enforced by the type system before any resolver runs," and the status is an enum. An agent cannot send a paraphrase.
Identity from context: pass. The mutation does not take a userId argument. Whose watchlist gets written is decided by the security layer, not by anything the agent supplies. More on why that matters in the next section.
Idempotency: partial, and the source shows why. Here is the real WatchlistService.addToWatchlist method, exactly as it ships in the base app:
@Transactional
public WatchlistItem addToWatchlist(WatchlistSubjectInput subject, WatchStatus status, String username) {
latencySimulator.pause();
AppUser user = requireUser(username);
WatchlistItem.WatchlistItemBuilder item = WatchlistItem.builder()
.user(user)
.status(status != null ? status : WatchStatus.WANT_TO_WATCH);
// The @oneOf directive guarantees exactly one of movieId/tvShowId is set.
if (subject.movieId() != null) {
Long movieId = parseId(subject.movieId(), "movieId");
Movie movie = movieRepository.findById(movieId)
.orElseThrow(() -> new EntityNotFoundException("Movie", movieId));
if (watchlistRepository.existsByUserIdAndMovieId(user.getId(), movieId)) {
throw new InvalidInputException("movieId", "This movie is already in your watch list");
}
item.movie(movie);
} else {
Long tvShowId = parseId(subject.tvShowId(), "tvShowId");
TvShow tvShow = tvShowRepository.findById(tvShowId)
.orElseThrow(() -> new EntityNotFoundException("TvShow", tvShowId));
if (watchlistRepository.existsByUserIdAndTvShowId(user.getId(), tvShowId)) {
throw new InvalidInputException("tvShowId", "This show is already in your watch list");
}
item.tvShow(tvShow);
}
try {
return watchlistRepository.save(item.build());
} catch (DataIntegrityViolationException e) {
// Two concurrent adds can both pass the existsBy check above; the unique
// constraint on watchlist_items catches the loser here.
throw new InvalidInputException("subject", "This title is already in your watch list");
}
}
Read what a retry actually does. The duplicate check consults the database, and the watchlist_items table carries a unique constraint per (user, movie) and per (user, tvShow). A second add can never create a second row, so the state is retry-proof. The second call does not return the existing entry, though. It fails with an InvalidInputException, which the app's GlobalExceptionHandler maps to a BAD_REQUEST GraphQL error whose message says the title is already on the list.
For the human frontend this is the right behavior: the UI wants to tell the user "already on your list," and an error with a message is how it learns that. For an agent it causes a problem unless the schema documents it. The agent reads BAD_REQUEST, concludes the call failed, and then either retries, which wastes a call, or tells the user the add did not work, which is wrong.
You have two options with an operation like this. Option one, change the behavior: make the duplicate case return the existing entry, so the response is idempotent too. Option two is to keep the behaviour and document the contract. The wording would be: "a BAD_REQUEST error whose message says the title is already on the list means the goal state is already achieved, so treat it as success." We take option two. The behaviour is already a shipped contract.
The frontend depends on the error to render its "already on your list" notice, and other clients may too. Changing the semantics of a shipped mutation quietly, because a new kind of caller showed up, is how an API loses its callers' trust. Documenting the real contract takes one paragraph in a description. Class 3 writes that paragraph, and the tool layer in Track B builds on it.
Explicit semantics: one gap. The status argument is optional, and the service quietly defaults it to WANT_TO_WATCH when it is null (status != null ? status : WatchStatus.WANT_TO_WATCH in the builder above). The behavior is sensible, but the schema does not mention it, so an agent cannot know it. That is a description gap, not a code gap, and it goes on Class 3's list.
This gap also matters in a later lesson. MCP elicitation is the protocol feature that lets a server pause a tool call to ask the user a question, and Class 11 covers it. There, the server will ask "have you already watched it?" and fill status from the answer, falling back to the same WANT_TO_WATCH default when the user declines. Because the argument is optional today, Class 11 can do this without changing the schema.
One last note for the audit: the watchlist trio also includes removeFromWatchlist, the only genuinely destructive operation in the set. It stays out of the agent surface for now; Class 16 uses it as the case study for stage-and-confirm patterns around destructive tools.
Wiring the Authenticated User
addToWatchlist does not take a userId argument, even though every entry it creates belongs to exactly one user. The user identity comes from the security context that the JWT filter populated. It reaches WatchlistController as an injected Principal. The controller's @PreAuthorize("isAuthenticated()") gate makes sure a real logged-in caller is present before the method body runs. The service then limits every lookup to that username, so it cannot find an item that belongs to someone else.
Agents often act on behalf of a user, but the agent must not decide which user that is. If the agent could choose the userId, a compromised agent could write to anyone's watchlist. The safe pattern is to read the user from the authenticated security context, as the Spring GraphQL tutorial's authentication class does. For the same reason, recommendMoviesForMood reads the viewer from the context instead of accepting a userId argument.
Schema Description Discipline
Both operations we added have a triple-quoted description block above them, and the watchlist types we adopted arrived with descriptions of their own. Those descriptions do real work. In later lessons the agent reads them, exactly as written, when it lists the tools, and uses them to decide whether to call a tool. A description that says "recommends movies" tells the agent very little. A useful one explains why the ranking is deterministic, what the boolean default means, and which flows the operation suits.
The same applies to the null contract on summarizeMovieReviews, and even more to addToWatchlist. Its existing one-line description, "Add a movie or TV show to the signed-in user's watch list", is accurate for a human. It does not mention the two things an agent needs: the duplicate-add contract and the status default. Class 3 (Schema Descriptions as Agent Documentation) is dedicated to writing those descriptions deliberately, building on the schema you finished today.
Tests for the Two New Services
Both new operations promise something an agent will rely on, and a test is where that promise is written down. recommendMoviesForMood promises that each mood returns a different list and that the genres match the mood profile. summarizeMovieReviews promises the three-review threshold: a written summary above it, a null summary with a live score below it, and nulls for both when there are no reviews at all.
RecommendationServiceTest.java, three tests over the mood profiles
package com.graphqlguy.moviedb.recommendation;
import com.graphqlguy.moviedb.movie.Movie;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class RecommendationServiceTest {
@Autowired
private RecommendationService service;
@Test
void recommendForMood_twoDifferentMoods_shouldReturnDifferentMovies() {
List<Movie> horror = service.recommendForMood(Mood.HORROR, false, null);
List<Movie> comedy = service.recommendForMood(Mood.COMEDY, false, null);
assertThat(horror).isNotEmpty();
assertThat(comedy).isNotEmpty();
assertThat(horror).isNotEqualTo(comedy);
}
@Test
void recommendForMood_horror_shouldReturnOnlyGenresInThatMoodProfile() {
List<Movie> horror = service.recommendForMood(Mood.HORROR, false, null);
assertThat(horror)
.extracting(Movie::getGenre)
.allMatch(MoodProfile.forMood(Mood.HORROR).genres()::contains);
}
@Test
void recommendForMood_everyMood_shouldReturnAListNoOtherMoodReturns() {
Map<Mood, List<Movie>> resultsByMood = new EnumMap<>(Mood.class);
for (Mood mood : Mood.values()) {
resultsByMood.put(mood, service.recommendForMood(mood, false, null));
}
Mood[] moods = Mood.values();
for (int i = 0; i < moods.length; i++) {
for (int j = i + 1; j < moods.length; j++) {
Mood first = moods[i];
Mood second = moods[j];
assertThat(resultsByMood.get(first))
.as("%s and %s should not return identical result lists", first, second)
.isNotEqualTo(resultsByMood.get(second));
}
}
}
}
The third test is the one that would catch a mood profile copied and left unedited, which is the failure the schema's controlled vocabulary cannot prevent on its own.
MovieReviewSummaryServiceTest.java, the three-review threshold in four tests
package com.graphqlguy.moviedb.review.summary;
import com.graphqlguy.moviedb.exception.EntityNotFoundException;
import com.graphqlguy.moviedb.movie.MovieRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@SpringBootTest
class MovieReviewSummaryServiceTest {
@Autowired
private MovieReviewSummaryService service;
@Autowired
private MovieRepository movieRepository;
private Long shawshankId;
private Long godfatherId;
private Long movieWithNoReviewsId;
@BeforeEach
void resolveMovieIds() {
shawshankId = idForTitle("The Shawshank Redemption");
godfatherId = idForTitle("The Godfather");
movieWithNoReviewsId = idForTitle("Forrest Gump");
}
private Long idForTitle(String title) {
return movieRepository.findByTitleContainingIgnoreCase(title).stream()
.filter(movie -> movie.getTitle().equalsIgnoreCase(title))
.findFirst()
.orElseThrow(() -> new IllegalStateException("No seeded movie titled " + title))
.getId();
}
@Test
void summarize_movieWithManyReviews_shouldReturnASummaryAndAScore() {
MovieReviewSummary s = service.summarize(shawshankId);
assertThat(s.reviewCount()).isGreaterThanOrEqualTo(3);
assertThat(s.summary()).isNotBlank();
assertThat(s.averageScore()).isNotNull();
}
@Test
void summarize_movieWithTooFewReviews_shouldOmitTheSummaryAndKeepTheScore() {
MovieReviewSummary s = service.summarize(godfatherId);
assertThat(s.reviewCount()).isBetween(1, 2);
assertThat(s.summary()).isNull();
assertThat(s.averageScore()).isNotNull();
}
@Test
void summarize_movieWithNoReviews_shouldOmitBothSummaryAndScore() {
MovieReviewSummary s = service.summarize(movieWithNoReviewsId);
assertThat(s.reviewCount()).isZero();
assertThat(s.summary()).isNull();
assertThat(s.averageScore()).isNull();
}
@Test
void summarize_unknownMovieId_shouldRaiseNotFound() {
assertThatThrownBy(() -> service.summarize(999_999L))
.isInstanceOf(EntityNotFoundException.class);
}
}
Both classes lean on the seeded catalog, so the titles they look up have to exist with the review counts the assertions expect. Run them before moving on:
./mvnw clean test
Verifying the Build
Restart the application and confirm the new operations are visible to GraphiQL.
./mvnw spring-boot:run
Open http://localhost:8080/graphiql. The quickest look is the Docs panel, GraphiQL's documentation explorer: open it, select Query, and both recommendMoviesForMood and summarizeMovieReviews are listed there with the descriptions you wrote next to them. If you prefer one query that answers the same question, run an introspection query that asks for the new fields:
query IntrospectionQuery {
__schema {
queryType {
fields(includeDeprecated: false) {
name
description
}
}
}
}
The response should show recommendMoviesForMood and summarizeMovieReviews alongside the base app's existing query fields, each with the description you wrote in schema.graphqls. You will notice moviesAll, searchMovies and searchPeople are absent from the list. They are deprecated in the schema as it arrived, and includeDeprecated: false hides them, exactly as it will for any client that asks the same way.
A missing field or empty description signals an SDL parse problem. Check the triple-quoted strings and the placement of the new fields inside type Query.
You can also smoke-test the audited mutation and see the duplicate contract for yourself. With a logged-in JWT in the GraphiQL Authorization header, run:
mutation AddToWatchlist {
addToWatchlist(subject: { movieId: "60" }, status: WANT_TO_WATCH) {
id
status
content { title }
}
}
The first run returns a WatchlistItem. Run the exact same mutation a second time and you get an error instead:
{
"errors": [
{
"message": "This movie is already in your watch list",
"extensions": { "classification": "BAD_REQUEST", "field": "movieId" }
}
]
}
Check the database (or the watchlist query) and you will find exactly one Casablanca entry. This shows the contract from the client side. The state is idempotent and the response is not, and the schema does not document that difference yet. Class 3 adds that documentation.
The web client at http://localhost:5173 covers the first half of this from the UI: log in, open a movie, and press "+ Want to watch", which sends exactly this mutation. It cannot cover the second half. Once the title is on your list, the client replaces the two add buttons with a "✓ On your watch list" label and a Remove button, so the browser does not send the duplicate call. That is sensible for a human-facing UI, and it is why you check the duplicate contract in GraphiQL, where you can send the same mutation twice. An agent is in the same position as GraphiQL here: it can repeat the call too.
Summary
We added two operations to the schema and audited a third. Each one stands for a category of agent-facing design:
recommendMoviesForMoodshows narrow, enum-typed inputs and a controlled vocabulary. ItsexcludeWatchedflag works because the base app already keeps a watchlist.summarizeMovieReviewsshows server-side work behind an explicit null contract on itssummaryfield. A movie that does not exist returns aNOT_FOUNDerror instead of null, and the operation works from the integer 1-to-10 review scores.addToWatchlistshows a user-scoped write designed for humans. Its@oneOfinput and enum status already pass the audit. Its duplicate-add error and its silent status default are contract gaps, and Class 3 closes them with documentation.
Alongside those three, MovieFilter gained minRuntime and maxRuntime. With that change, a length limit in a free-text prompt now has a filter field to map to, as the year and rating limits already had. The new code follows the project's existing conventions throughout: new fields inside the one Query type, Principal injection in the controllers, and feature-sliced packages.
Further Reading
- Spring for GraphQL: Annotated Controllers - reference for
@QueryMapping,@MutationMapping,@SchemaMapping,@Argument, andPrincipalinjection. - GraphQL specification: OneOf Input Objects - the
@oneOfdirectiveWatchlistSubjectInputuses, now part of the specification proper. - "Your GraphQL Schema Is Already an MCP Server" - the blog post on agent-oriented operation design.
- graphql.org: Mutations - the official learn-section treatment of mutation semantics and design.
- GraphQL specification: Type system - the source-of-truth for what
[Movie!]!and the other cardinality markers mean.
What's Next
Class 3 treats schema descriptions as the agent's primary documentation. The Movie Database app arrives partly documented, and documented for humans. We will upgrade its descriptions against a checklist of what a good agent-facing description contains, write the missing contracts this lesson uncovered on addToWatchlist, and add a CI lint gate that keeps future fields described. Classes 4, 8 and 13 then show how each track delivers those descriptions to the model. The schema you finished today is the input to the rest of the tutorial. The work from here on is mostly about the layers between this schema and the agent.