Skip to main content

Class 2: Schema Design & Relationships

Duration: 55 minutes | Difficulty: Beginner | Prerequisites: Class 1 completed


What You'll Learn

By the end of this class, you will:

  • Understand GraphQL enums and how they map to Java enums
  • Set up JPA with an in-memory H2 database for persistence
  • Model a many-to-many relationship (movies have directors)
  • Resolve related types with @SchemaMapping
  • Seed initial data with a CommandLineRunner

Why Persistence Matters Now

In Class 1 (Github repo here), we hardcoded five movies in a list inside the controller. That was fine for learning @QueryMapping, but real applications need persistence. More importantly, once we introduce relationships between types (a movie has directors, a director has movies), we need a proper data layer to manage those connections.

We'll use Spring Data JPA with an H2 in-memory database. H2 is perfect for development - it starts instantly, requires no installation, and resets on every restart so you always have clean data. JPA gives us the same code patterns you'd use with PostgreSQL or MySQL in production.

Step 1: Add Dependencies

Add JPA, H2, and the H2 console to your pom.xml:

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-h2console</artifactId>
</dependency>
Why H2?

H2 is an in-memory database that lives and dies with your application. No install, no configuration files, no cleanup. When we deploy to production later, we'd swap to PostgreSQL - but the JPA code stays exactly the same. That's the beauty of JPA's database abstraction.

Why <scope>runtime</scope>?

The runtime scope means H2 is available when the application runs but not at compile time. Your code never imports H2 classes directly; it interacts with the database through JPA/JDBC abstractions (DataSource, EntityManager, etc.). H2 is only needed at runtime to provide the JDBC driver. This also prevents you from accidentally coupling your code to H2-specific APIs, keeping your data layer database-agnostic.

Step 2: Configure the Database

Replace your application.yaml with:

📁 src/main/resources/application.yaml

spring:
application:
name: moviedb
datasource:
url: jdbc:h2:mem:moviedb;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password:
h2:
console:
enabled: true
path: /h2-console
jpa:
hibernate:
ddl-auto: create-drop
show-sql: false
properties:
hibernate:
format_sql: true
graphql:
graphiql:
enabled: true

Let's understand the key settings:

SettingPurpose
jdbc:h2:mem:moviedbIn-memory database named "moviedb"
DB_CLOSE_DELAY=-1Keeps DB alive as long as the JVM is running
h2.console.enabledBrowse your DB at /h2-console
ddl-auto: create-dropHibernate creates tables on startup, drops them on shutdown
H2 Console

With the console enabled, you can visit http://localhost:8080/h2-console and connect with JDBC URL jdbc:h2:mem:moviedb, username sa, and no password. This lets you inspect your tables and run SQL queries - very handy for debugging.

Note that the H2 console defaults to a file-based JDBC URL (something like jdbc:h2:~/test) when first opened in the browser. Make sure to change it to jdbc:h2:mem:moviedb before connecting, otherwise you'll be looking at an empty, unrelated database.

Step 3: Define the Schema

Before we write any Java, let's define our API contract. GraphQL encourages a schema-first approach: decide what your API looks like from the client's perspective, then build the server to match.

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

type Query {
"""Find a movie by its unique identifier"""
movie(id: ID!): Movie
"""List all movies in the database"""
movies: [Movie!]!
}

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

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

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

Compared to the schema from Class 1, we have made several changes. genre is now a proper Genre enum instead of a String, giving clients a fixed set of allowed values. We added a Person type and a directors field on Movie to model the relationship between movies and the people who direct them.

Notice that directors is [Person!]!-a non-null list of non-null persons. Even if a movie has no directors, the list itself won't be null (it'll be empty []).

Modeling relationships as a connected graph

Notice directors returns Person objects, not a directorIds: [ID!] list of foreign keys. That is deliberate: GraphQL's strength is a connected graph, where a client can walk from a movie to its directors to their other movies in one request. Prefer object references over id fields so clients traverse relationships instead of issuing follow-up lookups.

Two schema elements you will meet later round this out. An interface lets otherwise-unrelated types share a common set of fields (say a Media interface over Movie and TvShow), and a union lets one field return one of several unrelated types (union SearchResult = Movie | TvShow). We use a union for cross-type search in Class 12.

Also notice that rating is Float (nullable)-not every movie has been rated yet.

Best Practice: Nullable First

It's tempting to make everything non-null. After all, your database has NOT NULL constraints, so why not mirror that in the schema?

The problem shows up in two scenarios:

  1. Requirements change. You mark genre as Genre! (non-null) because every movie has a genre. Six months later, you want to support custom genres or movies without a genre. Changing a non-null field to nullable is a breaking change for clients that expect a value to always be there.

  2. Downstream services fail. If your movie data comes from one service but ratings come from another, and the ratings service goes down, a non-null rating field means the entire Movie type fails to resolve. GraphQL propagates nulls upward: if a non-null field returns null, its parent becomes null too. One failing field can cascade and take out an entire query response.

The safer approach is to start with nullable fields and only add ! when you're confident that:

  • The field will always have a value, now and in the future
  • If the field somehow can't be resolved, the entire parent type should disappear

In our schema, id and title are non-null because a movie without those isn't really a movie. But rating, birthYear, and nationality are nullable because their absence is a valid state, not an error.

This is sometimes called "nullable first"-start permissive, then tighten the contract when you have evidence that non-null is safe.

With the schema defined, we now know exactly what we need to build on the Java side. Let's start implementing.

Step 4: Create the Genre Enum

Our schema declares a Genre enum, so we need a matching Java enum. In Class 1, genre was a plain String, which meant a client could pass "sci-fi", "SciFi", or "science fiction"-all different strings for the same thing. The enum we just defined in the schema solves this by restricting values to an exact set.

📁 src/main/java/com/graphqlguy/moviedb/shared/Genre.java

package com.graphqlguy.moviedb.shared;

public enum Genre {
ACTION,
ANIMATION,
COMEDY,
CRIME,
DOCUMENTARY,
DRAMA,
FANTASY,
HORROR,
MUSICAL,
MYSTERY,
ROMANCE,
SCIFI,
THRILLER,
WAR,
WESTERN
}
Why a shared package?

Genre will be used by movies now, and later by TV shows too. Putting it in a shared package avoids circular dependencies between domain packages. As a general rule: if something is used across multiple domains, it belongs in a shared location.

Spring GraphQL automatically maps the GraphQL Genre enum to your Java Genre enum by name. No configuration needed-as long as the values match exactly, it just works.

GraphQL Enums vs Java Enums

The mapping is case-sensitive and matches by value name. Your GraphQL enum value SCIFI maps to Java's Genre.SCIFI. If they don't match, Spring GraphQL throws an error at startup, so you'll know immediately.

Enums for Inputs vs Outputs: a best practice to consider

Enums work well for inputs because the server controls validation. If you add a new enum value later, existing clients are unaffected since they simply don't send it yet.

For outputs, enums carry a risk: adding a new value is a breaking change for clients. If your server starts returning ANIME as a genre but a client's codegen doesn't know about it yet, the client can crash or fail to deserialize. With a String output, the client just gets a string it doesn't recognize and can handle it gracefully.

Because of this, some teams follow the pattern of using enums for inputs and strings for outputs. In a production system with many independent clients, Genre might be better modeled as a String on the output side.

For this tutorial, we use Genre as an enum on both sides because it keeps things simple and self-documenting. Just be aware of this trade-off when you design schemas for production APIs with multiple consumers.

Step 5: Convert Movie to a JPA Entity

Now let's upgrade our Movie from a plain POJO to a JPA entity that Hibernate can persist to the database. We need to match the Movie type we defined in our schema:

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

package com.graphqlguy.moviedb.movie;

import com.graphqlguy.moviedb.shared.Genre;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

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

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

private String title;

private int releaseYear;

@Enumerated(EnumType.STRING)
private Genre genre;

private Double rating;

// Vlad Mihalcea's recommended equals/hashCode for JPA entities
// https://vladmihalcea.com/how-to-implement-equals-and-hashcode-using-the-jpa-entity-identifier/
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Movie movie = (Movie) o;
return id != null && id.equals(movie.getId());
}

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

Key changes from Class 1:

ChangeWhy
@EntityTells JPA this class maps to a database table
@Id + @GeneratedValueAuto-generated primary key
Genre genre instead of String genreType-safe enum, matching our schema
Double rating (nullable)Not every movie has a rating yet
@Enumerated(EnumType.STRING)Stores "DRAMA" in DB instead of 0 - much more readable
@Getter @Setter vs @Data

We use @Getter @Setter instead of @Data because @Data generates equals() and hashCode() based on all fields, which causes three problems with JPA entities:

  1. Lazy proxy breakage - equals() calls all getters, which forces lazy-loaded relationships to load unnecessarily.
  2. Unstable hash in Sets - If you put an entity in a Set and then change a field, @Data's hash changes and the Set can't find the object anymore.
  3. Null ID before persist - Two unsaved entities with null IDs would be considered equal, which is incorrect.

Instead, we follow Vlad Mihalcea's recommended pattern: equals compares only the id (and short-circuits on null), while hashCode returns a constant (getClass().hashCode()). The constant hash is safe for Sets because it never changes, and equals handles the actual identity comparison. We apply this pattern to every JPA entity throughout the project.

Step 6: Create the Person Entity

Our schema defines a Person type, so we need a corresponding entity. Rather than creating separate Actor and Director classes, we'll use a single Person entity. In reality, many people in the film industry play multiple roles; Clint Eastwood both directs and acts, for instance. A unified Person model reflects this naturally.

📁 src/main/java/com/graphqlguy/moviedb/person/Person.java

package com.graphqlguy.moviedb.person;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

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

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

private String name;

private Integer birthYear;

private String nationality;

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

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

Same equals/hashCode pattern as Movie-we'll add this to every entity we create.

Step 7: Add the Director Relationship

Our schema declares directors: [Person!]! on the Movie type. A movie can have multiple directors (like the Wachowskis directing The Matrix), and a director can direct multiple movies. This is a classic many-to-many relationship.

Update Movie.java to add the relationship:

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

package com.graphqlguy.moviedb.movie;

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

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

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

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

private String title;

private int releaseYear;

@Enumerated(EnumType.STRING)
private Genre genre;

private Double rating;

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

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

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

Let's break down the relationship mapping:

AnnotationPurpose
@ManyToManyMultiple movies can share multiple directors
@JoinTableCreates a separate join table movie_directors
joinColumnsThe foreign key pointing to Movie
inverseJoinColumnsThe foreign key pointing to Person
@Builder.DefaultEnsures new ArrayList<>() is used even when using Builder
Why @JoinTable?

A many-to-many relationship needs a join table-a separate table with two foreign key columns that links movies to their directors. JPA can auto-generate this, but specifying @JoinTable gives us control over the table and column names, making the schema more readable.

Step 8: Create the Repositories

Spring Data JPA generates full CRUD implementations from an interface:

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

package com.graphqlguy.moviedb.movie;

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

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

📁 src/main/java/com/graphqlguy/moviedb/person/PersonRepository.java

package com.graphqlguy.moviedb.person;

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

public interface PersonRepository extends JpaRepository<Person, Long> {
}

Step 9: Update the Controller

Now let's update MovieController to use the repository and resolve the directors field from our schema:

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

package com.graphqlguy.moviedb.movie;

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

import java.util.List;

@Controller
@RequiredArgsConstructor
public class MovieController {

private final MovieRepository movieRepository;

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

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

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

Key changes:

WhatWhy
@RequiredArgsConstructorLombok generates constructor for final fields - Spring injects the repo
MovieRepositoryReplaced hardcoded list with database queries
@Argument Long idMatches our entity's ID type
@SchemaMapping directors(Movie)Resolves the directors field on the Movie type

Understanding @SchemaMapping

@SchemaMapping is the key new concept in this class. Here's what happens when a client queries:

query {
movie(id: 1) {
title
directors { # ← This triggers the @SchemaMapping
name
}
}
}

This is the beauty of GraphQL field resolution: the directors method is only called if the client actually asks for directors. If a query only requests title and genre, the director data is never loaded. This is fundamentally different from REST, where the server decides what to include.

@SchemaMapping Method Naming

By default, @SchemaMapping matches the method name to the schema field name, and the method parameter type to the parent type. So directors(Movie movie) resolves the directors field on the Movie type. You can be explicit: @SchemaMapping(typeName = "Movie", field = "directors").

Do we actually need the directors @SchemaMapping?

Strictly speaking, no. @ManyToMany relationships are lazy-loaded by default in JPA, so Spring GraphQL can resolve directors automatically without an explicit @SchemaMapping method. We're adding it here deliberately to set up a demonstration of the N+1 problem-when you query a list of movies with their directors, each movie triggers a separate database query to load its directors. We'll tackle this in Class 8.

Step 10: Seed the Database

Our H2 database starts empty on every restart. Let's create a DataInitializer that populates it with sample data:

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

package com.graphqlguy.moviedb.config;

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

import java.util.List;

@Component
@RequiredArgsConstructor
public class DataInitializer implements CommandLineRunner {

private final MovieRepository movieRepository;
private final PersonRepository personRepository;

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

// Movies
createAndSaveMovie("The Shawshank Redemption", 1994, Genre.DRAMA, 9.3, List.of(frankDarabont));
createAndSaveMovie("The Godfather", 1972, Genre.CRIME, 9.2, List.of(francisFordCoppola));
createAndSaveMovie("The Godfather Part II", 1974, Genre.CRIME, 9.0, List.of(francisFordCoppola));
createAndSaveMovie("Forrest Gump", 1994, Genre.DRAMA, 8.8, List.of(robertZemeckis));
createAndSaveMovie("12 Angry Men", 1957, Genre.DRAMA, 9.0, List.of(sidneyLumet));
createAndSaveMovie("Inception", 2010, Genre.SCIFI, 8.8, List.of(christopherNolan));
createAndSaveMovie("Interstellar", 2014, Genre.SCIFI, 8.6, List.of(christopherNolan));
createAndSaveMovie("The Dark Knight", 2008, Genre.ACTION, 9.0, List.of(christopherNolan));
createAndSaveMovie("Goodfellas", 1990, Genre.CRIME, 8.7, List.of(martinScorsese));
createAndSaveMovie("Se7en", 1995, Genre.THRILLER, 8.6, List.of(davidFincher));
createAndSaveMovie("The Good, the Bad and the Ugly", 1966, Genre.WESTERN, 8.8, List.of(sergioLeone));
createAndSaveMovie("Terminator 2: Judgment Day", 1991, Genre.SCIFI, 8.6, List.of(jamesCameron));
createAndSaveMovie("The Shining", 1980, Genre.HORROR, 8.4, List.of(stanleyKubrick));
}

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

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

CommandLineRunner is a Spring Boot interface. Any bean implementing it will have its run() method called after the application context is fully loaded. It's perfect for seeding development data. We'll grow this initializer throughout the tutorial as we add new entities.

Step 11: Run and Test

Start your application:

./mvnw spring-boot:run

Open GraphiQL at http://localhost:8080/graphiql .

Test the Enum

query {
movies {
title
genre
}
}

Response:

{
"data": {
"movies": [
{
"title": "The Shawshank Redemption",
"genre": "DRAMA"
},
{
"title": "The Godfather",
"genre": "CRIME"
}
]
}
}

Genre is now a proper enum - clients can only query valid values, and the schema is self-documenting.

Test the Relationship

query {
movie(id: 6) {
title
genre
rating
directors {
name
nationality
}
}
}

Response:

{
"data": {
"movie": {
"title": "Inception",
"genre": "SCIFI",
"rating": 8.8,
"directors": [
{
"name": "Christopher Nolan",
"nationality": "British-American"
}
]
}
}
}

Test the Flexibility

Now try requesting a movie without directors:

query {
movie(id: 6) {
title
rating
}
}

The directors resolver is never called. This is GraphQL's efficiency - fields are only resolved when requested.

Project Structure So Far

moviedb/
├── src/main/java/com/graphqlguy/moviedb/
│ ├── MoviedbApplication.java
│ ├── config/
│ │ └── DataInitializer.java
│ ├── movie/
│ │ ├── Movie.java
│ │ ├── MovieController.java
│ │ └── MovieRepository.java
│ ├── person/
│ │ ├── Person.java
│ │ └── PersonRepository.java
│ └── shared/
│ └── Genre.java
├── src/main/resources/
│ ├── application.yaml
│ └── graphql/
│ └── schema.graphqls
└── pom.xml

Naming Standards

The GraphQL community has largely settled on naming conventions. Following them keeps your schema consistent and familiar to anyone who has worked with other GraphQL APIs:

ElementConventionExample
Fields, input fields, argumentscamelCasereleaseYear, birthYear, directorIds
Types (object, input, enum, union)PascalCaseMovie, CreateMovieInput, Genre
Enum valuesSCREAMING_SNAKE_CASEACTION, SCIFI, WAR

Our schema already follows these conventions. If you look back at the schema we defined, fields like releaseYear and posterUrl are camelCase, types like Movie and Person are PascalCase, and enum values like ACTION and DRAMA are uppercase.

These naming conventions are widely adopted across the GraphQL ecosystem.

Schema Evolution: When You Need to Break Things

As your API grows, you will eventually need to change something that clients already depend on. In REST, this is painful because you have a limited set of HTTP verbs per resource. If someone relies on PUT /movies/{id}, you can't easily change what that endpoint returns without breaking them.

GraphQL comes from a place of abundance. There is no limit on field or mutation names. Say your addMovie mutation currently takes a simple flat input, but now you need to support adding a movie with its full cast and crew in one call. Instead of changing addMovie and breaking existing clients, you can create addMovieWithCast alongside it. The old mutation keeps working. The new one serves the new use case. Both coexist.

When in doubt, duplicate

If you need to make a breaking change to a field or mutation, consider just adding a new one with a more specific name instead of modifying the existing one. It's wordier, but it lets existing clients keep working while new clients get the improved behavior. GraphQL has plenty of room for descriptive names.

If you do deprecate a field, use the built-in @deprecated directive in your schema and set a concrete date for removal:

type Mutation {
addMovie(input: AddMovieInput!): Movie!
addMovieWithCast(input: AddMovieWithCastInput!): Movie!
}

type Movie {
year: Int @deprecated(reason: "Use releaseYear instead. Will be removed after 2025-06-01.")
releaseYear: Int!
}

A deprecation without a date tends to live forever. "Deprecated" stops meaning anything if half your schema has been deprecated for three years.

Exercises

Exercise 1: Add a New Movie

Add "The Matrix" (1999, SCIFI, 8.7) to the DataInitializer. You'll need to create a new Person for the Wachowskis first.

Solution
Person wachowski = createAndSavePerson("Lana Wachowski", 1965, "American");

createAndSaveMovie("The Matrix", 1999, Genre.SCIFI, 8.7, List.of(wachowski));

Exercise 2: Query by Director

Notice how Christopher Nolan directed three of our movies. Try querying all movies and see which ones share directors. Think about how you'd add a query to find all movies by a specific director.

Solution

While we haven't built a moviesByDirector query yet, you can see the relationship by querying:

query {
movies {
title
directors { name }
}
}

In a later class, we'll add filtering that lets you search by director.

Exercise 3: Explore the H2 Console

Visit http://localhost:8080/h2-console, connect to jdbc:h2:mem:moviedb (user: sa, no password), and run:

SELECT * FROM MOVIE_DIRECTORS;

You'll see the join table that connects movies to their directors - this is what @JoinTable creates.

Common Issues

Issue: Table not created

Error: Table "MOVIE" not found Solution: Make sure ddl-auto: create-drop is set in your application.yaml. Hibernate needs this to auto-create tables from your entities.

Issue: Directors always empty

Error: Directors list is always [] Solution: Make sure your DataInitializer calls movie.getDirectors().addAll(directors) before movieRepository.save(movie). The relationship must be set before persisting.

Issue: LazyInitializationException

Error: LazyInitializationException when accessing directors Solution: This happens when JPA tries to load a lazy association after its persistence context (Hibernate session) has been closed. In a typical Spring Boot web app this is masked by Open Session in View (spring.jpa.open-in-view=true, the default), which keeps the EntityManager open for the entire HTTP request, so lazy loads triggered during GraphQL field resolution still find a live session. That is what makes our @SchemaMapping approach work; the GraphQL execution itself does not hold a JPA transaction open across resolvers. If you turn OSIV off (a common production setting), you will need explicit transactional boundaries, JOIN FETCH queries, or batch-loading patterns like @BatchMapping (covered in Class 8) to avoid this error.

Summary

In this class, you learned:

  • GraphQL enums map directly to Java enums by name, providing type-safe, self-documenting values
  • Bounded inputs, unbounded outputs - consider using enums for inputs and strings for outputs in production APIs
  • Nullable first - start with nullable fields and only add ! when the absence of a value should break the parent type
  • When in doubt, duplicate - add new fields or mutations instead of breaking existing ones
  • JPA with H2 gives us real persistence with zero configuration
  • Many-to-many relationships use @JoinTable to create a join table linking entities
  • @SchemaMapping resolves nested fields (like directors) only when the client requests them
  • CommandLineRunner seeds development data on startup

What's Next?

In Class 3: Queries Deep Dive, we'll:

  • Add a Cast model for Actors with character names (junction entities)
  • Learn about nested resolver chains
  • Add search functionality
  • Understand how GraphQL resolves deeply nested queries