Skip to main content

Class 12: TV Shows, Union Types & Custom Scalars

Duration: 105 minutes | Difficulty: Intermediate-Advanced | Prerequisites: Class 11 completed


What You'll Learn

By the end of this class, you will:

  • Add a complete new domain (TV shows with episodes and cast) by applying everything from previous classes
  • Understand what union types are and why they are essential for cross-type search
  • Let Spring's auto-registered ClassNameTypeResolver map each union member to its GraphQL type by name, and know when a hand-written TypeResolver is actually needed
  • Use __typename on the client side to discriminate union results and understand why every major GraphQL client injects it automatically
  • Model "exactly one of several options" inputs with the @oneOf directive, avoiding defensive runtime checks
  • Register a DateTime custom scalar using the Extended Scalars library
  • See how @BatchMapping prevents N+1 queries across multiple related types

Why This Class Matters

Up to this point, every new concept has been introduced in the context of movies. This class is different. Instead of one new feature, you build a whole new domain: TV shows with episodes, cast members and creators. It uses everything so far, meaning entities, repositories, services, controllers, @BatchMapping, pagination and error handling.

This is the real test. If you can add a new domain to an existing GraphQL API without looking back at every previous lesson, you have internalized the patterns. Along the way, we will introduce two genuinely new concepts: union types and custom scalars.

The TV Show Domain Model

Notice how this mirrors the movie domain. TvShowCast is a junction entity just like MovieCast - it links a person to a show with a character name. Creators use a simple @ManyToMany because that relationship does not carry extra data, just like directors on movies.

Movie Gets Some Descriptive Company

TvShow exists to be the parallel of Movie, and the parallel only works if both types carry the same descriptive fields. We deferred adding runtime, plot, and posterUrl to Movie in Class 4 (the mutations chapter did not need them). They earn their keep now. This also closes a gap Class 11 left open: MovieSortField has offered RUNTIME since then, but sorting by it raised a PropertyReferenceException because the entity did not have a runtime column until now.

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

Add to the Movie type, after rating:

"""Runtime in minutes"""
runtime: Int
plot: String
posterUrl: String

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

Add after rating:

private Integer runtime;
private String plot;
private String posterUrl;

Step 1: Define the Schema

Before writing any Java code, we define the full schema contract. This is the schema-first approach: the GraphQL schema is the API specification, and the implementation follows from it. By the end of this step, every type, query, union, and custom scalar for the TV show domain will be declared.

Add the TV show types to your schema. The TvShow type mirrors Movie with creators (like directors), cast (junction entity), and episodes, and TvShowPage/PersonPage handle pagination:

src/main/resources/graphql/schema.graphqls
"""A television show"""
type TvShow {
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
inProduction: Boolean!
creators: [Person!]!
cast: [TvShowCast!]!
episodes: [Episode!]!
}

"""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 page of people"""
type PersonPage {
content: [Person!]!
totalElements: Int!
totalPages: Int!
currentPage: Int!
size: Int!
}

The nullability mirrors the entities you write in Step 2. title, genre, startYear and inProduction are non-null columns, and endYear stays nullable, because a show still on the air has no end year. Genre is the shared enum from Class 2, which already includes FANTASY, so the TV domain reuses it unchanged. Notice too that TvShowPage and PersonPage carry five core fields where MoviePage has nine. Class 11 added the convenience booleans to spare clients some arithmetic, and we trim them here to keep the focus on the new domain. Either shape is valid, and a real API should pick one and stay with it.

Two existing types change as well. Starting from a person, a cast credit is the road back to the movie or the show, so Person gains cross-domain credit fields and MovieCast gains a movie back-reference. Back in Class 3 we deliberately left movie off MovieCast, because every cast entry was reached through its movie and the field pointed nowhere useful; that reasoning expires now that people are queryable directly. Edit both types in place - do not paste a second type Person or type MovieCast:

type Person {
id: ID!
name: String!
birthYear: Int
nationality: String
"""Movies this person directed"""
directedMovies: [Movie!]!
"""This person's acting credits in movies"""
movieCastCredits: [MovieCast!]!
"""This person's acting credits in TV shows"""
tvShowCastCredits: [TvShowCast!]!
}

type MovieCast {
id: ID!
characterName: String!
person: Person!
movie: Movie!
}

This makes the schema legitimately cyclic - a movie's cast leads to people, whose credits lead back to movies - which is exactly the shape Class 13 uses to motivate depth limiting. One caution while we are here: the new movie field is served by the fetch-joins we will write for the person direction in Step 4. Reaching it the other way around (movies { cast { movie { ... } } }) is circular and not fetch-joined, so treat it as a from-person field.

Next, wire the queries. Add the new fields inside your existing Query block. As in Class 11, edit the block in place rather than pasting a second type Query, because graphql-java fails at startup with a SchemaProblem when a type is defined twice. The unpaginated people query from Class 4 stays runnable, and we mark it deprecated in favour of the paginated persons. @deprecated is a schema directive you meet again later in this chapter, and this is the course's first live use of it. GraphiQL and introspection-driven tooling now show people as deprecated, with the reason attached.

type Query {
# ... existing queries from Classes 2-11
people: [Person!]! @deprecated(reason: "Use the paginated persons query")

tvShow(id: ID!): TvShow
tvShows(page: Int = 0, size: Int = 10): TvShowPage!
search(query: String!): [SearchResult!]!

person(id: ID!): Person
persons(page: Int = 0, size: Int = 20): PersonPage!
searchPersons(name: String!): [Person!]!
}

tvShow(id:) returns a nullable TvShow, matching movie(id:) from Class 1: a missing id surfaces through the error handling from Class 5, not through the type. The page and size defaults live on the arguments, exactly as Class 11 did for movies, so clients discover them through introspection.

This class also introduces two new schema-level concepts. First, a union type for cross-type search:

"""Union of Movie and TvShow for cross-type search"""
union SearchResult = Movie | TvShow

A union type says: "this field can return either a Movie or a TvShow." The client uses inline fragments to handle each case:

query SearchMoviesAndTvShows {
search(query: "Game") {
... on Movie {
title
releaseYear
rating
}
... on TvShow {
title
startYear
seasons
}
}
}
Union vs. Interface

Both union types and interfaces allow a field to return multiple types. The difference is that interfaces require shared fields (title, id), while unions do not impose that requirement; the member types can be completely unrelated. Use unions when the types share behavior (search results), and interfaces when they share structure (all have an id and title).

Second, a custom scalar declaration for timestamps. GraphQL's built-in scalar types (Int, Float, String, Boolean, ID) do not include dates or timestamps. Our Review entity has carried a createdAt of type OffsetDateTime since Class 7, exposed until now as a plain String!. Declare the scalar as a new top-level line:

scalar DateTime

Then retype the one field inside the existing Review type from Class 7. Do not paste a second type Review - exactly like the Query block above, graphql-java refuses to build a schema that defines the same type twice:

"""A user's review of a movie"""
type Review {
id: ID!
"""Rating from 1 to 10"""
score: Int!
comment: String
createdAt: DateTime! # was: createdAt: String!
user: User!
}

Two practical notes. First, from the moment the schema mentions DateTime, the application refuses to start until the scalar implementation is registered. That registration comes in Step 6, so wait until then to restart, or expect a startup error naming the unwired scalar. Second, nothing changes on the wire for existing clients, and createdAt still arrives as an ISO-8601 string. The contract is what changes. The schema now names the format precisely, and coercion validates the value, where the previous String mapping passed through whatever arrived.

When do you need custom scalars?

If a built-in scalar can represent your data adequately, use it. Int is fine for years and runtimes. But when the data has semantic meaning that requires specific serialization or validation (timestamps, URLs, email addresses), a custom scalar communicates intent and ensures correctness at the schema level.

With the schema in place, the remaining steps implement each part in Java.

Step 2: Create the Entities

TvShow Entity

src/main/java/com/graphqlguy/moviedb/tvshow/TvShow.java
package com.graphqlguy.moviedb.tvshow;

import com.graphqlguy.moviedb.person.Person;
import com.graphqlguy.moviedb.shared.Genre;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
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 jakarta.persistence.OrderBy;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

@Entity
@Builder
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "tv_shows")
public class TvShow {

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

@Column(nullable = false)
private String title;

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Genre genre;

private Double rating;
private String posterUrl;
private Integer tmdbId;

@Column(nullable = false)
private Integer startYear;

private Integer endYear; // null if still airing
private Integer seasons;

@Column(columnDefinition = "TEXT")
private String plot;

@Column(nullable = false)
private Boolean inProduction;

@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "tvshow_creators",
joinColumns = @JoinColumn(name = "tvshow_id"),
inverseJoinColumns = @JoinColumn(name = "person_id")
)
@Builder.Default
private Set<Person> creators = new HashSet<>();

@OneToMany(mappedBy = "tvShow", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@Builder.Default
private List<TvShowCast> cast = new ArrayList<>();

@OneToMany(mappedBy = "tvShow", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@OrderBy("seasonNumber ASC, episodeNumber ASC")
@Builder.Default
private List<Episode> episodes = new ArrayList<>();

// Same id-based equals/hashCode as every entity since Class 2;
// Class 8 explains why @BatchMapping's entity-keyed maps depend on it
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
TvShow tvShow = (TvShow) o;
return id != null && id.equals(tvShow.getId());
}

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

A few things to note. endYear is nullable - a show still on the air does not have an end year. The @OrderBy on episodes ensures they always come back in season/episode order without the client needing to sort them. We use Set<Person> for creators (no duplicates) but List<TvShowCast> for cast (preserves order, allows duplicate person if they play multiple roles). Two fields deserve a word. posterUrl mirrors the descriptive field Movie just gained. tmdbId stays entity-only with no schema counterpart, because it is the external identifier that links a row to The Movie Database.

Class 13 adds the same field to Movie and builds the TMDB integration around it. Extending that integration to TV shows is left as an exercise.

Episode Entity

src/main/java/com/graphqlguy/moviedb/tvshow/Episode.java
package com.graphqlguy.moviedb.tvshow;

import jakarta.persistence.Column;
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 jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Entity
@Builder
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "episodes")
public class Episode {

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

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tvshow_id", nullable = false)
private TvShow tvShow;

@Column(nullable = false)
private Integer seasonNumber;

@Column(nullable = false)
private Integer episodeNumber;

@Column(nullable = false)
private String title;

@Column(columnDefinition = "TEXT")
private String overview;

private Integer runtime;
private Integer airYear;

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

TvShowCast Junction Entity

src/main/java/com/graphqlguy/moviedb/tvshow/TvShowCast.java
package com.graphqlguy.moviedb.tvshow;

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 jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Entity
@Builder
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "tvshow_cast")
public class TvShowCast {

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

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tvshow_id", nullable = false)
private TvShow tvShow;

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

private String characterName;

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

This is structurally identical to MovieCast. The same junction-entity pattern applies any time a relationship carries its own data.

Create the Repositories

Three Spring Data interfaces back the new domain. findByTitleContainingIgnoreCase is the same derived query MovieRepository gained in Class 3. The other two custom methods carry Class 8's point forward. findWithCreatorsByIdIn join-fetches the creators inside the repository call, where distinct collapses the duplicate rows the join produces. findWithPersonByTvShowIdIn join-fetches each cast entry's person for the same reason Class 8 join-fetched mc.person: without it, cast { person { name } } silently reintroduces the N+1, and throws a LazyInitializationException once OSIV is off in production, as Class 8 warns. One difference from Class 8: these methods take Set<Long>, because the controller in Step 3 collects ids with Collectors.toSet(). Episode does not need a fetch-join, since the batch only reads its own columns; the order-by in the derived method name mirrors the entity's @OrderBy, so both access paths agree.

src/main/java/com/graphqlguy/moviedb/tvshow/TvShowRepository.java
package com.graphqlguy.moviedb.tvshow;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Set;

public interface TvShowRepository extends JpaRepository<TvShow, Long> {

List<TvShow> findByTitleContainingIgnoreCase(String title);

@Query("select distinct s from TvShow s left join fetch s.creators where s.id in :ids")
List<TvShow> findWithCreatorsByIdIn(@Param("ids") Set<Long> ids);
}
src/main/java/com/graphqlguy/moviedb/tvshow/TvShowCastRepository.java
package com.graphqlguy.moviedb.tvshow;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Set;

public interface TvShowCastRepository extends JpaRepository<TvShowCast, Long> {

@Query("select tc from TvShowCast tc join fetch tc.person where tc.tvShow.id in :ids")
List<TvShowCast> findWithPersonByTvShowIdIn(@Param("ids") Set<Long> ids);
}
src/main/java/com/graphqlguy/moviedb/tvshow/EpisodeRepository.java
package com.graphqlguy.moviedb.tvshow;

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

import java.util.List;
import java.util.Set;

public interface EpisodeRepository extends JpaRepository<Episode, Long> {

List<Episode> findByTvShowIdInOrderBySeasonNumberAscEpisodeNumberAsc(Set<Long> tvShowIds);
}

Step 3: Service and Controller

Before the service can return a page of shows, it needs the page record itself - the Java side of the TvShowPage type from Step 1. It carries the five core fields, so it is MoviePage from Class 11 minus the convenience booleans:

src/main/java/com/graphqlguy/moviedb/tvshow/TvShowPage.java
package com.graphqlguy.moviedb.tvshow;

import java.util.List;

public record
(
List<TvShow> content,
long totalElements,
int totalPages,
int currentPage,
int size
) {}

The service follows the exact same patterns as MovieService:

src/main/java/com/graphqlguy/moviedb/tvshow/TvShowService.java
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TvShowService {

private final TvShowRepository tvShowRepository;

public Optional<TvShow> findById(Long id) {
return tvShowRepository.findById(id);
}

public TvShowPage findAll(int page, int size) {
Page<TvShow> result = tvShowRepository.findAll(
PageRequest.of(page, size, Sort.by("startYear").descending().and(Sort.by("id"))));
return new TvShowPage(result.getContent(), result.getTotalElements(),
result.getTotalPages(), result.getNumber(), result.getSize());
}

public List<TvShow> searchByTitle(String title) {
return tvShowRepository.findByTitleContainingIgnoreCase(title);
}
}

The .and(Sort.by("id")) tiebreaker is the Class 11 rule applied again: shows premiering the same year are inevitable once you add more seed data, and a paginated sort must end in a unique column.

The controller is where @BatchMapping comes in. Without it, querying 10 TV shows with their creators, cast, and episodes would fire 30+ individual queries. With @BatchMapping, the data arrives in exactly 4: one for the page of shows, one for creators, one for cast, one for episodes. (If you count SQL statements with show-sql on and the returned page is full, you will see one more: the SELECT COUNT that Page runs for totalElements, as Class 11 explained. That is the cost of pagination, not of the relations.)

src/main/java/com/graphqlguy/moviedb/tvshow/TvShowController.java
@Controller
@RequiredArgsConstructor
@Slf4j
public class TvShowController {

private final TvShowService tvShowService;
private final MovieService movieService; // used by the search query in Step 5
private final TvShowRepository tvShowRepository;
private final TvShowCastRepository tvShowCastRepository;
private final EpisodeRepository episodeRepository;

@QueryMapping
TvShow tvShow(@Argument Long id) {
return tvShowService.findById(id)
.orElseThrow(() -> new EntityNotFoundException("TvShow", id));
}

@QueryMapping
TvShowPage tvShows(@Argument Integer page, @Argument Integer size) {
return tvShowService.findAll(page != null ? page : 0, size != null ? size : 10);
}

@BatchMapping(typeName = "TvShow")
Map<TvShow, Set<Person>> creators(List<TvShow> shows) {
log.info("@BatchMapping: loading creators for {} TV shows", shows.size());
Set<Long> ids = shows.stream().map(TvShow::getId).collect(Collectors.toSet());
List<TvShow> withCreators = tvShowRepository.findWithCreatorsByIdIn(ids);

Map<Long, Set<Person>> byShowId = withCreators.stream()
.collect(Collectors.toMap(TvShow::getId, TvShow::getCreators));

Map<TvShow, Set<Person>> result = new HashMap<>();
for (TvShow show : shows) {
result.put(show, byShowId.getOrDefault(show.getId(), Set.of()));
}
return result;
}

@BatchMapping(typeName = "TvShow")
Map<TvShow, List<TvShowCast>> cast(List<TvShow> shows) {
log.info("@BatchMapping: loading cast for {} TV shows", shows.size());
Set<Long> ids = shows.stream().map(TvShow::getId).collect(Collectors.toSet());
List<TvShowCast> allCast = tvShowCastRepository.findWithPersonByTvShowIdIn(ids);

Map<Long, List<TvShowCast>> byShowId = allCast.stream()
.collect(Collectors.groupingBy(c -> c.getTvShow().getId()));

Map<TvShow, List<TvShowCast>> result = new HashMap<>();
for (TvShow show : shows) {
result.put(show, byShowId.getOrDefault(show.getId(), List.of()));
}
return result;
}

@BatchMapping(typeName = "TvShow")
Map<TvShow, List<Episode>> episodes(List<TvShow> shows) {
log.info("@BatchMapping: loading episodes for {} TV shows", shows.size());
Set<Long> ids = shows.stream().map(TvShow::getId).collect(Collectors.toSet());
List<Episode> allEpisodes = episodeRepository
.findByTvShowIdInOrderBySeasonNumberAscEpisodeNumberAsc(ids);

Map<Long, List<Episode>> byShowId = allEpisodes.stream()
.collect(Collectors.groupingBy(e -> e.getTvShow().getId()));

Map<TvShow, List<Episode>> result = new HashMap<>();
for (TvShow show : shows) {
result.put(show, byShowId.getOrDefault(show.getId(), List.of()));
}
return result;
}
}

No TvShowNotFoundException needs writing. TV shows reuse the same EntityNotFoundException and the same handleEntityNotFound from Class 5. Adding an entity to the not-found story costs nothing, because the entity name travels as data, "TvShow", rather than as a new class. The id binds directly as @Argument Long, per Class 4's ID-coercion tip, so nothing has to accept a String and parse it by hand. Imports are left out of the service and controller listings as usual. The only non-obvious ones are EntityNotFoundException from com.graphqlguy.moviedb.exception, and MovieService from com.graphqlguy.moviedb.movie.

One deliberate shortcut versus Class 8: the batch queries live in the controller here rather than behind service methods that work with ids. Each repository call runs in its own read-only transaction, and the associations the resolvers touch are fetch-joined before returning, so nothing lazy escapes. If you prefer the Class 8 layering, moving the three loads into TvShowService is a mechanical refactor. Note also that tvShows, and persons in Step 4, feed page and size straight into PageRequest.of. Give each mapping the one-line clamp Class 11's "Cap the page size" tip added to movies, keeping each controller's own default: 10 here, and 20 for persons.

The @BatchMapping pattern

Every @BatchMapping follows the same structure: collect IDs, batch-fetch from the database, group results by parent ID, build the return map. Once you have written two or three of these, the pattern becomes second nature.

Step 4: Person Queries

Now that people can be linked to both movies and TV shows, we need dedicated person queries. Three pieces are new on the Java side: a page record, a derived search method on the repository, and three read methods on the service.

src/main/java/com/graphqlguy/moviedb/person/PersonPage.java
package com.graphqlguy.moviedb.person;

import java.util.List;

public record PersonPage(
List<Person> content,
long totalElements,
int totalPages,
int currentPage,
int size
) {}

The repository gains a derived search method, the same mechanism as findByTitleContainingIgnoreCase from Class 3:

src/main/java/com/graphqlguy/moviedb/person/PersonRepository.java (add one line)
List<Person> findByNameContainingIgnoreCase(String name);

Then PersonService gains three read methods. The paginated findAll(int page, int size) overloads the argument-less findAll() from Class 4, which stays behind the deprecated people query; the sort ends with the id tiebreaker Class 11 established as non-optional:

src/main/java/com/graphqlguy/moviedb/person/PersonService.java (add three methods)
public Optional<Person> findById(Long id) {
return personRepository.findById(id);
}

public PersonPage findAll(int page, int size) {
Page<Person> result = personRepository.findAll(
PageRequest.of(page, size, Sort.by("name").ascending().and(Sort.by("id"))));
return new PersonPage(result.getContent(), result.getTotalElements(),
result.getTotalPages(), result.getNumber(), result.getSize());
}

public List<Person> searchByName(String name) {
return personRepository.findByNameContainingIgnoreCase(name);
}

Now add the three query mappings to the existing PersonController from Class 4 - keep the people query (it is deprecated, not deleted) and the mutations it already has:

src/main/java/com/graphqlguy/moviedb/person/PersonController.java (add three methods)
@QueryMapping
Person person(@Argument Long id) {
return personService.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Person", id));
}

@QueryMapping
PersonPage persons(@Argument Integer page, @Argument Integer size) {
return personService.findAll(page != null ? page : 0, size != null ? size : 20);
}

@QueryMapping
List<Person> searchPersons(@Argument String name) {
return personService.searchByName(name);
}

Cross-Domain Credits

Step 1 declared directedMovies, movieCastCredits, and tvShowCastCredits on Person; here is the implementation. Each junction-credit repository method fetch-joins the parent the credit points back to, so movie { title } and tvShow { title } resolve straight off loaded objects:

src/main/java/com/graphqlguy/moviedb/movie/MovieRepository.java (add one line)
List<Movie> findByDirectorsContaining(Person person);
src/main/java/com/graphqlguy/moviedb/movie/MovieCastRepository.java (add)
@Query("select mc from MovieCast mc join fetch mc.movie where mc.person.id = :personId")
List<MovieCast> findWithMovieByPersonId(@Param("personId") Long personId);
src/main/java/com/graphqlguy/moviedb/tvshow/TvShowCastRepository.java (add)
@Query("select tc from TvShowCast tc join fetch tc.tvShow where tc.person.id = :personId")
List<TvShowCast> findWithTvShowByPersonId(@Param("personId") Long personId);

PersonService gains three matching loaders, and with them three new @RequiredArgsConstructor dependencies (MovieRepository, MovieCastRepository, and TvShowCastRepository, imported across packages the same way Class 3 imported Person into the movie package):

src/main/java/com/graphqlguy/moviedb/person/PersonService.java (add)
public List<Movie> findDirectedMovies(Person person) {
return movieRepository.findByDirectorsContaining(person);
}

public List<MovieCast> findMovieCastCredits(Long personId) {
return movieCastRepository.findWithMovieByPersonId(personId);
}

public List<TvShowCast> findTvShowCastCredits(Long personId) {
return tvShowCastRepository.findWithTvShowByPersonId(personId);
}

And PersonController resolves the three fields:

src/main/java/com/graphqlguy/moviedb/person/PersonController.java (add)
@SchemaMapping(typeName = "Person")
List<Movie> directedMovies(Person person) {
return personService.findDirectedMovies(person);
}

@SchemaMapping(typeName = "Person")
List<MovieCast> movieCastCredits(Person person) {
return personService.findMovieCastCredits(person.getId());
}

@SchemaMapping(typeName = "Person")
List<TvShowCast> tvShowCastCredits(Person person) {
return personService.findTvShowCastCredits(person.getId());
}

These are per-person resolvers, which is exactly right for person(id:). Under a persons page they would fan out once per row - converting them to @BatchMapping with the Step 3 recipe is a good exercise.

Step 5: Implement the Union Query

With the SearchResult union declared in the schema, we now need the Java implementation. Suppose a user searches for "Game" - they might want to find both the movie "The Game" (1997) and the TV show "Game of Thrones." The search query combines results from both movie and TV show services. This method lives in the same TvShowController file from Step 3, and it is why that controller declared the movieService field. The union query pulls candidates from both domains in one place, and @RequiredArgsConstructor turned the extra final field into a constructor dependency:

src/main/java/com/graphqlguy/moviedb/tvshow/TvShowController.java
@QueryMapping
List<Object> search(@Argument String query) {
List<Object> results = new ArrayList<>();
results.addAll(movieService.searchByTitle(query));
results.addAll(tvShowService.searchByTitle(query));
return results;
}

The return type is List<Object> because Java's type system does not have an equivalent of GraphQL's union type. Both Movie and TvShow are valid return values, and their only common ancestor is Object.

Resolving the Concrete Type (Usually Zero Config)

GraphQL still needs a way to determine which concrete type each object in the result list actually is when it serializes the response. For most unions and interfaces, Spring for GraphQL handles this for you. GraphQlSource.Builder registers a ClassNameTypeResolver as the default TypeResolver for every union and interface that lacks one. That resolver matches the value's simple Java class name against a GraphQL object type of the same name, walking super types when the first match fails.

In our case that is all we need. The Java classes are named Movie and TvShow, and the GraphQL types are named Movie and TvShow, so the names line up and the union resolves with no configuration at all. You do not write a TypeResolver for SearchResult.

The only thing this domain still needs registered by hand is the DateTime scalar (covered in Step 6), so the configuration bean holds just that one line:

src/main/java/com/graphqlguy/moviedb/config/GraphQLConfig.java
package com.graphqlguy.moviedb.config;

import graphql.scalars.ExtendedScalars;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.graphql.execution.RuntimeWiringConfigurer;

@Configuration
public class GraphQLConfig {

@Bean
public RuntimeWiringConfigurer runtimeWiringConfigurer() {
return wiringBuilder -> wiringBuilder
.scalar(ExtendedScalars.DateTime);
}
}
When you do need an explicit TypeResolver

The default works only because the Java class names match the GraphQL type names. If they diverge - say your GraphQL type is Show but the Java class is TvShow, or several unrelated classes map to one GraphQL type - register an explicit resolver on that union or interface.

The registration lives in the same place as the scalar: the RuntimeWiringConfigurer bean in GraphQLConfig. The wiringBuilder that bean hands you is graphql-java's RuntimeWiring.Builder, and everything you register chains onto it, one call after another - the scalar, a type resolver, and later the validation directive wiring from Class 13. .type("SearchResult", ...) selects which schema type to customize by name, and typeResolver(...) attaches the resolver to it. In context, the whole bean would look like this (only the .type(...) call is new; remember, our project keeps the default and does not need it):

src/main/java/com/graphqlguy/moviedb/config/GraphQLConfig.java
@Bean
public RuntimeWiringConfigurer runtimeWiringConfigurer() {
return wiringBuilder -> wiringBuilder
.scalar(ExtendedScalars.DateTime)
.type("SearchResult", typeWiring -> typeWiring.typeResolver(env -> {
Object obj = env.getObject();
if (obj instanceof Movie) return env.getSchema().getObjectType("Movie");
if (obj instanceof TvShow) return env.getSchema().getObjectType("TvShow");
throw new IllegalStateException(
"Unknown SearchResult type: " + obj.getClass().getName());
}));
}

The resolver itself is a lambda receiving a TypeResolutionEnvironment: env.getObject() is the Java object your controller returned, and your job is to hand back the matching GraphQL object type from the schema. You can also keep the default ClassNameTypeResolver and give it a custom name-extracting function instead of hand-writing every instanceof branch. Prefer the default whenever the names already agree.

Advanced: the "skipped" line in the startup inspection report

Spring for GraphQL runs a schema-mapping inspection at startup and prints a report of fields it could not verify. Our search method returns List<Object>, so the inspector has no concrete Java type to match against the SearchResult members, and lists the union as skipped. That is a note in a startup report rather than a runtime problem. The union still resolves through ClassNameTypeResolver, because the actual instances at runtime are Movie and TvShow, whose simple names match the GraphQL types. The inspection behavior for unions is documented on the request-execution reference page.

A self-describing alternative is a sealed marker interface: declare sealed interface SearchResult permits Movie, TvShow, have both types implement it, and return List<SearchResult> from search. The concrete member classes are still named Movie and TvShow, so ClassNameTypeResolver resolves them as before, with no change to the resolver. The return type now names the union's members explicitly, which gives the startup inspector the concrete type information it lacked. Treat this as an optional refinement; the List<Object> version already works, and the skipped line changes nothing at runtime.

The Client Side: __typename

Type resolution solves half the problem: the server knows which concrete type it's serializing. But how does the client know which branch of the union it received? That is what __typename is for.

__typename is a meta-field that exists on every object, union, and interface type in every GraphQL schema, automatically, without you adding it. Query it anywhere and you get back the name of the concrete type as a string.

query SearchMoviesAndTvShows {
search(query: "Game") {
__typename
... on Movie {
title
releaseYear
}
... on TvShow {
title
seasons
}
}
}

Response:

{
"data": {
"search": [
{ "__typename": "Movie", "title": "The Game", "releaseYear": 1997 },
{ "__typename": "TvShow", "title": "Game of Thrones", "seasons": 8 }
]
}
}

With __typename in the response, the client has a deterministic way to dispatch on the result:

search.forEach(item => {
if (item.__typename === "Movie") renderMovieCard(item);
if (item.__typename === "TvShow") renderShowCard(item);
});
Why Apollo Client and Relay Require It

Every major GraphQL client (Apollo Client, Relay, urql) uses __typename for cache normalization. The cache key for an object is typically __typename + id, so Movie:1 and TvShow:1 don't collide. These clients automatically inject __typename into your queries whether you ask for it or not. You don't have to manage it, but you should know it's there. Debugging cache issues without understanding __typename is miserable.

__typename is not limited to unions or interfaces. It works on regular object types too:

query MovieTypename {
movie(id: "1") {
__typename # "Movie"
title
}
}

You'll rarely query it on a concrete type where the shape is already known, but it's there if you want it - useful for logging, metrics, or generic UI components that render any entity.

More Client-Query Tools: Fragments and Directives

__typename and inline fragments are not the only query-side features worth knowing. Two more come up constantly.

A named fragment is a reusable selection set. When several queries (or several branches of a union) need the same fields, define the fragment once and spread it with ...:

fragment PersonFields on Person {
id
name
nationality
}

query MovieDirectorsAndCast {
movie(id: "1") {
directors { ...PersonFields }
cast { person { ...PersonFields } }
}
}

Inline fragments (... on Type) narrow a union or interface to one concrete type, as you just saw with SearchResult; a named fragment gives a reusable selection a name you can spread anywhere.

The @skip and @include operation directives let the client turn a field on or off from a variable, without editing the query string:

query MovieWithOptionalCast($withCast: Boolean!) {
movie(id: "1") {
title
cast @include(if: $withCast) { characterName }
}
}

With $withCast: false the cast field is left out entirely; with true it is fetched. @skip(if:) is the inverse.

That contrast is worth a frame: GraphQL directives come in two families. Operation directives like @skip and @include live in the client's query and steer execution; schema directives like @oneOf (below), @deprecated, and @specifiedBy live in the SDL and describe or constrain the schema itself. What decides the family is where the directive is allowed to appear - a directive on a FIELD is an operation directive, one on a FIELD_DEFINITION is a schema directive.

Unions for Expected Errors

The same union machinery has a second use worth knowing. Back in Class 5 we saw that expected failures, the ones a client branches on as normal flow, are often better modelled as typed schema data than as entries in the errors array. For deletePerson we did that with an error field on the response. A union is the other shape for the same idea: use it when a field returns cleanly one of a full result or a typed error. Adding a review is a good candidate, because it can fail in a way the client expects - you already reviewed that movie:

union AddReviewResult = Review | ReviewProblem

type ReviewProblem {
code: ReviewProblemCode!
message: String!
}

enum ReviewProblemCode {
ALREADY_REVIEWED
MOVIE_NOT_FOUND
}

The client dispatches with the same ... on Review / ... on ReviewProblem inline fragments and __typename you just used for SearchResult - identical mechanics, different intent. Rule of thumb: reach for a response type with an error field when you also return normal fields, and a union when the result is cleanly one-or-the-other. Either way, reserve this for outcomes the client handles as normal flow; genuine faults still belong in the errors array with a classification, as Class 5 covers.

Step 6: DateTime Custom Scalar Registration

The DateTime scalar was declared in the schema in Step 1. Now we need the library that provides the implementation and the configuration to wire it in.

The graphql-java-extended-scalars library provides a DateTime scalar that maps to ISO-8601 timestamps:

pom.xml
<dependency>
<groupId>com.graphql-java</groupId>
<artifactId>graphql-java-extended-scalars</artifactId>
<version>24.0</version>
</dependency>
Pin the version yourself

This library is not managed by the Spring Boot BOM, so you must pin an explicit <version>. Its release numbers follow graphql-java majors, and not every major gets a release. The project's compatibility rule is a floor: "use 24.0 or above for graphql-java 24.x and above". Spring Boot 4.0.x puts graphql-java 25.0 on the classpath, and 24.0 is the newest extended-scalars release, with no 25.x or 26.x. So 24.0 is the correct pin here, even though the two numbers differ. When a Boot upgrade moves you to a new graphql-java major, check the extended-scalars releases page and move to a matching or newer release if one has shipped.

The registration itself is a single line in GraphQLConfig (shown above in Step 5):

.scalar(ExtendedScalars.DateTime)

That single line tells graphql-java how to serialize OffsetDateTime to an ISO-8601 string such as "2024-06-15T14:30:00.000Z", and how to parse incoming strings back into OffsetDateTime objects. The scalar always prints exactly three fractional-second digits, which is where the .000 comes from. No custom coercing logic is needed.

ScalarJava TypeExample Value
DateTimeOffsetDateTime"2024-06-15T14:30:00.000Z"
DateLocalDate"2024-06-15"
TimeOffsetTime"14:30:00Z"
LocalTimeLocalTime"14:30:00"
JSONObject (any JSON value){"key": "value"}
LongLong9223372036854775807

These six are the ones you will reach for most often, but the library's catalog is considerably larger: it also ships UUID, Url, Locale, CountryCode, Currency, arbitrary-precision BigDecimal/BigInteger, and range-constrained numerics like PositiveInt and NonNegativeInt that reject out-of-range values at coercion time. The full list, with the exact ExtendedScalars constant for each, is in the project's official documentation.

Writing your own scalar, and documenting it

We used a ready-made scalar from the library, but a custom scalar is also a natural input-validation layer. Every scalar is backed by a Coercing implementation whose first job is to validate a value and reject a malformed one. A bespoke Email or PositiveInt scalar therefore turns bad input into a coercion error before any resolver runs, with no Bean Validation annotation involved. When you write one you implement serialize, parseValue, and parseLiteral; the library simply did this for DateTime on your behalf.

If you do ship a custom scalar, document its format with the built-in @specifiedBy directive, which links it to a specification URL - scalar Email @specifiedBy(url: "https://example.com/specs/email"). It shows up in introspection so clients and codegen tools can see the exact contract.

Step 7: Safer Inputs with @oneOf

Unions are the output-side answer to "this can be several things." The schema spec now has the input-side cousin: the @oneOf directive on an input type, which enforces at runtime that exactly one of the input's fields is provided. Either zero or two or more fields triggers a validation error before any resolver runs.

Why @oneOf Exists

Consider a hypothetical mutation where a user can create a review either for a movie or for a TV show. The shape you might reach for first:

# Without @oneOf
input CreateReviewInput {
movieId: ID # provide one of these...
tvShowId: ID # ...or the other...
score: Int!
comment: String
}

Nothing in the schema prevents a client from sending both movieId and tvShowId, or neither. Your resolver has to validate this manually, and you end up writing the same "exactly one of these two must be set" check in every mutation that has a choice of subject. Worst case, the check is missing and your resolver picks one arbitrarily, leading to hard-to-diagnose bugs.

The @oneOf Solution

Wrap the either-or fields in their own input type, marked @oneOf:

"""References either a movie or a TV show, never both"""
input SubjectRef @oneOf {
movieId: ID
tvShowId: ID
}

input CreateReviewInput {
subject: SubjectRef!
score: Int!
comment: String
}

Two rules apply to @oneOf input types:

  1. All fields must be nullable (no !). The directive itself is the "exactly one" constraint, so the fields can't individually be required.
  2. Exactly one field must be provided at runtime. Sending both, or none, is a validation error before your mutation resolver ever runs.

What Valid and Invalid Inputs Look Like

# ✅ Valid: exactly one field provided
mutation CreateReviewForMovie { createReview(input: { subject: { movieId: "1" }, score: 9 }) { id } }
mutation CreateReviewForTvShow { createReview(input: { subject: { tvShowId: "3" }, score: 8 }) { id } }

# ❌ Invalid: both provided
mutation CreateReviewWithBothSubjects { createReview(input: { subject: { movieId: "1", tvShowId: "3" }, score: 9 }) { id } }

# ❌ Invalid: neither provided
mutation CreateReviewWithEmptySubject { createReview(input: { subject: { }, score: 9 }) { id } }

The invalid requests produce a validation error (the kind that returns no data key, as you saw in Class 5). Your mutation code never runs, which is exactly what you want, because the invariant is guaranteed before it reaches business logic.

Applying the Directive

@oneOf is a spec directive, not a custom one, so you don't write a SchemaDirectiveWiring for it the way we do for @Range and @Size in Class 13. You do not need to declare it either. graphql-java ships @oneOf as a built-in directive (like @deprecated and @specifiedBy) and synthesizes it into every schema automatically, so you simply annotate the input type and graphql-java enforces the constraint natively:

src/main/resources/graphql/schema.graphqls
input SubjectRef @oneOf {
movieId: ID
tvShowId: ID
}

You can leave out a directive @oneOf on INPUT_OBJECT line of your own. graphql-java synthesises the definition into every schema it builds, the way it synthesises @deprecated and @specifiedBy. Current graphql-java tolerates a duplicate declaration, and yours simply stands in for the synthesised one. Where a DirectiveRedefinitionError really appears (surfacing inside a SchemaProblem at startup) is when the same directive is declared twice in SDL you control, for example once in each of two .graphqls files that get merged into one registry. Just apply the annotation; graphql-java enforces the constraint during request validation, before your resolver runs.

Spec Status

@oneOf is now a built-in directive in the official GraphQL specification (merged in the September 2025 release). It is supported across the ecosystem: graphql-java has shipped it since 21.2 (initially flagged experimental, now spec-official), and graphql-js since 16.9, which is where servers built on graphql-js, including Apollo Server, get their validation. Older docs may still call it "draft" or "RFC"; that history is no longer current.

The Java Side

SubjectRef maps cleanly to a Java record with nullable fields:

src/main/java/com/graphqlguy/moviedb/review/SubjectRef.java
package com.graphqlguy.moviedb.review;

public record SubjectRef(String movieId, String tvShowId) {
public boolean isMovie() { return movieId != null; }
public boolean isTvShow() { return tvShowId != null; }
}

The resolver can safely branch on which field is set, because @oneOf has guaranteed exactly one is non-null:

@MutationMapping
Review createReview(@Argument CreateReviewInput input) {
if (input.subject().isMovie()) {
return reviewService.createForMovie(input.subject().movieId(), input.score(), input.comment());
}
return reviewService.createForTvShow(input.subject().tvShowId(), input.score(), input.comment());
}

No defensive "what if both are set" check. No "what if neither is set" check. The schema enforces the invariant, and your resolver reads cleanly as a consequence.

When @oneOf Is the Right Tool

Reach for @oneOf whenever your input has a "polymorphic identity" flavor:

  • References by one of several key types (by ID, by slug, by external ID, by email)
  • Filters that support alternative forms (date range vs. date list vs. relative date)
  • Mutations that accept alternative subjects (movie or TV show, user or team, file or URL)

Skip it when the fields are genuinely independent (all optional, any combination is valid). That's a normal input type.

This createReview mutation isn't wired into our project yet; we introduce it as a pattern here because the schema-level concept fits best alongside unions and custom scalars. Extend the tutorial as an exercise if you want to fully implement it.

Step 8: Seed TV Show Data

Update DataInitializer to seed TV show data. First give it the three new collaborators, alongside the repositories it has accumulated since Class 2 (@RequiredArgsConstructor picks them up; the imports come from com.graphqlguy.moviedb.tvshow):

src/main/java/com/graphqlguy/moviedb/config/DataInitializer.java (add fields)
private final TvShowRepository tvShowRepository;
private final TvShowCastRepository tvShowCastRepository;
private final EpisodeRepository episodeRepository;

Then, at the end of run() after the Class 11 in-theaters block, seed the people the shows need, using the same createAndSavePerson helper the initializer has used since Class 2. Seed one movie too: the cross-type search in Step 9 looks for "Game", and so far every movie title lacks it. David Fincher directed The Game, and his Person variable is already in scope:

Person davidBenioff  = createAndSavePerson("David Benioff", 1970, "American");
Person dbWeiss = createAndSavePerson("D. B. Weiss", 1971, "American");
Person emiliaClarke = createAndSavePerson("Emilia Clarke", 1986, "British");
Person kitHarington = createAndSavePerson("Kit Harington", 1986, "British");
Person peterDinklage = createAndSavePerson("Peter Dinklage", 1969, "American");

createAndSaveMovie("The Game", 1997, Genre.THRILLER, 7.8, List.of(davidFincher));

Here is a representative example for Game of Thrones:

src/main/java/com/graphqlguy/moviedb/config/DataInitializer.java
TvShow got = TvShow.builder()
.title("Game of Thrones")
.genre(Genre.FANTASY)
.rating(9.2)
.plot("Nine noble families fight for control over the mythical lands of Westeros " +
"while an ancient enemy returns.")
.tmdbId(1399)
.startYear(2011).endYear(2019).seasons(8)
.inProduction(false)
.build();
tvShowRepository.save(got);

// Add creators (simple M2M)
got.getCreators().add(davidBenioff);
got.getCreators().add(dbWeiss);
tvShowRepository.save(got);

// Add cast (junction entity with character names)
tvShowCastRepository.save(TvShowCast.builder()
.tvShow(got).person(emiliaClarke).characterName("Daenerys Targaryen").build());
tvShowCastRepository.save(TvShowCast.builder()
.tvShow(got).person(kitHarington).characterName("Jon Snow").build());
tvShowCastRepository.save(TvShowCast.builder()
.tvShow(got).person(peterDinklage).characterName("Tyrion Lannister").build());

// Add episodes
episodeRepository.save(Episode.builder()
.tvShow(got).seasonNumber(1).episodeNumber(1)
.title("Winter Is Coming")
.overview("Eddard Stark is torn between his family and an old friend " +
"when asked to serve at the side of King Robert Baratheon.")
.runtime(62).airYear(2011).build());

Follow this pattern for your other shows (Friends, Seinfeld, etc.).

Step 9: Test Everything

query TvShow {
tvShow(id: "1") {
title
startYear
endYear
seasons
inProduction
creators {
name
}
cast {
characterName
person { name }
}
episodes {
seasonNumber
episodeNumber
title
}
}
}
query SearchMoviesAndTvShows {
search(query: "Game") {
... on Movie {
__typename
title
releaseYear
}
... on TvShow {
__typename
title
startYear
seasons
}
}
}

The __typename meta-field is always available and tells the client which type each result is. Many GraphQL client libraries use it automatically for cache normalization.

Person with Cross-Domain Credits

query PersonCredits {
person(id: "1") {
name
directedMovies {
title
}
movieCastCredits {
characterName
movie { title }
}
tvShowCastCredits {
characterName
tvShow { title }
}
}
}

Exercises

Exercise 1: Add TV Show Filtering

Create a TvShowFilter input type with genre, minRating, and inProduction fields. Implement a findWithFilters query on TvShowRepository following the same pattern as MovieRepository.

Exercise 2: Episode Count

Add an episodeCount field to TvShow that returns the total number of episodes. Decide whether it should be a database-computed field or a @SchemaMapping resolver.

Exercise 3: Extend the Union

Add Person to the SearchResult union, then extend the search resolver in TvShowController with a third source: inject PersonService and add results.addAll(personService.searchByName(query)). Verify that searching "Christopher" now returns Christopher Nolan as a Person - with our seed data he is the only hit, since no movie or show title contains "Christopher". Notice that the default ClassNameTypeResolver picks the new member up with no further configuration, exactly as the Common Issues section explains.

Common Issues

Issue: "Cannot determine type for union SearchResult"

Symptom: Error when querying the search field Solution: The default ClassNameTypeResolver matches each value's simple Java class name against a GraphQL object type of the same name. This normally happens only when the names fail to line up, such as a Java class whose simple name belongs to no member of the union. Adding a new member (Exercise 3 adds Person) does not need an extra instanceof check as long as the Java class is named Person to match the GraphQL type; the default resolver picks it up automatically. If your class name genuinely diverges from the GraphQL type name, register an explicit resolver (or a custom name extractor) as shown in Step 5.

Issue: Lazy loading exception on creators/cast

Symptom: LazyInitializationException when accessing tvShow.getCreators() Solution: Never access lazy collections outside a transaction. Use @BatchMapping to load related data in a separate, properly-scoped database call.

Issue: DateTime field returns a string instead of a structured object

Symptom: createdAt comes back as "2024-06-15T14:30:00.000Z" (a string) Solution: This is correct. Custom scalars serialize to primitive JSON types. DateTime serializes to a JSON string in ISO-8601 format. The client parses it into a date object on their end.

Summary

In this class, you applied everything from previous lessons to build a new domain from scratch:

  • TV Show entities follow the same patterns as movies: junction entities for cast (with character names), simple @ManyToMany for creators, @OneToMany for episodes
  • @BatchMapping on creators, cast, and episodes prevents N+1 queries, using the same technique you learned for movies, applied identically to TV shows
  • Union types let a single field return multiple types, which is essential for cross-type search. Spring's auto-registered ClassNameTypeResolver tells GraphQL which concrete type each object is by matching the Java class name to the GraphQL type name, so matching names mean zero config
  • __typename is the client-side counterpart to type resolution: a meta-field on every type that returns the concrete type's name, used for union discrimination and automatic cache normalization by Apollo Client and Relay
  • @oneOf input types enforce "exactly one of these fields is set" at the schema level, removing the need for defensive resolver-side checks
  • Custom scalars like DateTime extend GraphQL's type system to handle data types that the built-in scalars cannot represent
  • PersonPage and person queries give you a complete read surface for people who work across both movies and TV shows, including the cross-domain credit fields resolved with @SchemaMapping, while the old people query demonstrates @deprecated in action

Further Reading

The new concepts in this class each have a primary source worth reading in full:

What's Next?

In Class 13: External APIs, Directives & Production, we'll integrate with the TMDB external API, build custom directives for input validation, add query instrumentation for security and observability, and configure caching. It is the final class, and it ties everything together.