Skip to main content

Class 13: External APIs, Directives & Production

Duration: 110 minutes | Difficulty: Advanced | Prerequisites: Class 12 completed; a free TMDB API key if you want to run the external-API steps yourself


What You'll Learn

By the end of this class, you will:

  • Call external REST APIs from GraphQL resolvers using Spring's RestClient
  • Call an external GraphQL API with HttpSyncGraphQlClient, with your server acting as a GraphQL client
  • Implement the cache-aside pattern with Caffeine to avoid hammering external services
  • Declare validation rules in your schema with custom directives (@Range, @Size) that document the constraint in the SDL
  • Enforce those rules with Spring's Bean Validation, and understand why SchemaDirectiveWiring.onInputObjectField is the wrong tool for input validation in graphql-java
  • Add query instrumentation for security (depth limiting) and observability (field timing)
  • Configure CORS for frontend integration

Why External APIs?

So far every piece of data in our API comes from our own database. That is rarely enough in practice. Movie ratings, poster images, trending data and metadata often live in third-party services. TMDB, The Movie Database, has community ratings from millions of users, which we could never collect ourselves.

The challenge is that external API calls are slow (100-500ms per call) and rate-limited. If a client queries 20 movies and each one triggers a separate TMDB API call, that is 20 sequential HTTP requests. That is potentially 10 seconds of latency. We need two things to make this viable: batching (via @BatchMapping) and caching (via Caffeine).

Step 1: TMDB Search Integration

Before You Start: Get a TMDB API Key

Everything in Steps 1 and 2 talks to the real TMDB API, so if you want to implement this and see live data, you will need an API key. It is free: create an account at themoviedb.org, then open Settings → API and request a key (pick the "Developer" use case; the form takes a minute). TMDB shows you two credentials on that page: a short v3 "API Key" and a long v4 "API Read Access Token". Our code sends the credential as a Bearer token in the Authorization header, so copy the API Read Access Token, the long one.

The token is a secret, so it never goes into source code or version control. Set it as an environment variable in the shell where you run the app:

export TMDB_API_KEY=eyJhbGciOi...your_token_here

Then reference it from configuration, together with the two TMDB URLs the integration needs:

src/main/resources/application.yaml
tmdb:
api-key: ${TMDB_API_KEY:}
base-url: https://api.themoviedb.org/3
image-base-url: https://image.tmdb.org/t/p/w500

The ${TMDB_API_KEY:} placeholder reads the environment variable and falls back to an empty string when it is not set. That fallback is deliberate. You can follow the whole class without a key, because everything we build returns empty results when TMDB is unconfigured, rather than failing.

With the key in place, let's add a simple search query that lets users search TMDB for movies.

The TmdbProperties Class

The integration needs three pieces of configuration: the API key, the API base URL, and the image base URL. You could inject each one into the service with its own @Value("${tmdb.api-key}") field. That scatters raw property strings across the class, and every new setting adds another field and another string to keep in sync. We bind the whole tmdb.* prefix to one dedicated class instead, the same records-based @ConfigurationProperties binding we used for DemoProperties in Class 8:

src/main/java/com/graphqlguy/moviedb/tmdb/TmdbProperties.java
@ConfigurationProperties(prefix = "tmdb")
public record TmdbProperties(
@DefaultValue("") String apiKey,
String baseUrl,
String imageBaseUrl) {

public boolean hasApiKey() {
return !apiKey.isBlank();
}
}

Relaxed binding maps tmdb.api-key onto apiKey, and @DefaultValue("") keeps the key non-null when the TMDB_API_KEY environment variable is not set. The hasApiKey() helper gives every caller one authoritative answer to "is TMDB configured?", in place of repeated null-or-blank checks. It also expands well. When the integration later grows a timeout or a retry count, the new setting becomes one more record component here, rather than another @Value field in the service. As in Class 8, component scanning does not pick up @ConfigurationProperties records on its own. Register the record with @ConfigurationPropertiesScan on the application class, or with @EnableConfigurationProperties(TmdbProperties.class).

The TmdbService

src/main/java/com/graphqlguy/moviedb/tmdb/TmdbService.java
@Service
@Slf4j
@RequiredArgsConstructor
public class TmdbService {

private final TmdbProperties tmdbProperties;
private final Cache<Integer, CommunityRating> ratingCache;
private final RestClient restClient = RestClient.create();

public List<TmdbResult> search(String title) {
if (!tmdbProperties.hasApiKey()) {
log.warn("TMDB_API_KEY is not configured. TMDB search unavailable.");
return Collections.emptyList();
}
try {
TmdbSearchResponse response = restClient.get()
.uri(tmdbProperties.baseUrl() + "/search/movie?query={query}&language=en-US&page=1", title)
.header("Authorization", "Bearer " + tmdbProperties.apiKey())
.retrieve()
.body(TmdbSearchResponse.class);

if (response == null || response.results() == null)
return Collections.emptyList();

return response.results().stream().limit(10).map(r -> new TmdbResult(
r.id(), r.title() != null ? r.title() : "",
parseYear(r.releaseDate()), r.overview(),
r.posterPath() != null ? tmdbProperties.imageBaseUrl() + r.posterPath() : null,
r.voteAverage() != null ? Math.round(r.voteAverage() * 10.0) / 10.0 : null
)).toList();
} catch (Exception e) {
log.error("TMDB search failed: {}", e.getMessage());
return Collections.emptyList();
}
}
}

There are several important design decisions here:

RestClient over WebClient: We use Spring 6's RestClient rather than the reactive WebClient because our resolvers are synchronous. RestClient is simpler and does not require a reactive runtime.

Lombok wires the dependencies: @RequiredArgsConstructor generates a constructor for the two final fields without initializers, tmdbProperties and ratingCache, and Spring injects both. The restClient field has an initializer, so Lombok correctly leaves it out of the constructor.

Bearer token authentication: TMDB uses API keys passed as Bearer tokens in the Authorization header. The key arrives through TmdbProperties, so it never appears in source code.

Graceful degradation: Whether the key is missing or the TMDB call fails, we log the problem and return an empty list rather than throwing an exception. The rest of the GraphQL response remains intact (the partial-response philosophy from Class 5 in action), and the whole class stays runnable without a key.

Response mapping: TMDB returns snake_case JSON fields (release_date, poster_path, vote_average). We use @JsonProperty annotations on inner record classes to handle the mapping.

The TMDB integration hinges on a single field: the external identifier that links our Movie rows to TMDB's records. Add it to the schema and the entity now.

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

Add to the Movie type:

"""The Movie Database (TMDB) external identifier"""
tmdbId: Int

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

Add after the other descriptive fields:

private Integer tmdbId;

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

The field is only useful with data in it - without seeded ids, communityRating in Step 2 stays null even with a valid API key. Give createAndSaveMovie a tmdbId parameter and pass each seed movie its real TMDB id (any movie's id is visible in the URL of its themoviedb.org page):

private Movie createAndSaveMovie(String title, int year, Genre genre, double rating, Integer tmdbId, List<Person> directors) {
Movie movie = Movie.builder()
.title(title).releaseYear(year).genre(genre).rating(rating).tmdbId(tmdbId)
.build();
movie.getDirectors().addAll(directors);
return movieRepository.save(movie);
}
Movie shawshank = createAndSaveMovie("The Shawshank Redemption", 1994, Genre.DRAMA, 9.3, 278, List.of(frankDarabont));
Movie godfather = createAndSaveMovie("The Godfather", 1972, Genre.CRIME, 9.2, 238, List.of(francisFordCoppola));

Update the remaining call sites the same way, using the TMDB ids for the rest of our seed catalog:

FilmTMDB idFilmTMDB id
The Godfather Part II240Goodfellas769
Forrest Gump13Se7en807
12 Angry Men389The Good, the Bad and the Ugly429
Inception27205Terminator 2280
Interstellar157336The Shining694
The Dark Knight155Unforgiven33

The TmdbController is a simple @Controller with a single @QueryMapping that delegates to the service. Add a TmdbResult type and tmdbSearch query to your schema, plus a TmdbResult record with fields tmdbId, title, releaseYear, overview, posterUrl, and rating.

Test It

query SearchTmdbMovies {
tmdbSearch(title: "Inception") {
tmdbId
title
releaseYear
rating
posterUrl
}
}

Step 2: Community Ratings with @BatchMapping and Caching

Now for the interesting part. We want every Movie in our database to have a communityRating field that shows the live TMDB rating. Without batching, querying 20 movies would fire 20 HTTP requests to TMDB. With @BatchMapping, we batch them into a single method call. With caching, we avoid calling TMDB at all for recently-fetched ratings.

The CommunityRating Record

src/main/java/com/graphqlguy/moviedb/tmdb/CommunityRating.java
package com.graphqlguy.moviedb.tmdb;

public record CommunityRating(double voteAverage, int voteCount) {}

Cache Configuration

src/main/java/com/graphqlguy/moviedb/config/CacheConfig.java
@Configuration
public class CacheConfig {

@Bean
public Cache<Integer, CommunityRating> tmdbRatingCache() {
return Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.MINUTES)
.maximumSize(500)
.build();
}
}

We use Caffeine rather than Spring's @Cacheable because we need fine-grained control: checking individual cache entries, bulk-inserting results, and handling partial cache hits. The TTL of 1 minute means ratings stay fresh without hammering the API.

Cache-Aside Pattern

The cache-aside pattern runs in five steps. Check the cache for each item, collect the misses, fetch only those from the external API, store what comes back, and merge cached with fresh results. It beats caching the whole response, because individual entries are reusable across different queries.

Batch-Fetching with Virtual Threads

The TmdbService.fetchMovieRatings method implements the cache-aside pattern and uses virtual threads to parallelize API calls for cache misses:

src/main/java/com/graphqlguy/moviedb/tmdb/TmdbService.java
public Map<Integer, CommunityRating> fetchMovieRatings(List<Integer> tmdbIds) {
if (!tmdbProperties.hasApiKey()) {
log.warn("TMDB_API_KEY not configured. Community ratings unavailable.");
return Collections.emptyMap();
}

Map<Integer, CommunityRating> results = new HashMap<>();
List<Integer> cacheMisses = new ArrayList<>();

// Step 1: Check cache for each ID
for (Integer tmdbId : tmdbIds) {
CommunityRating cached = ratingCache.getIfPresent(tmdbId);
if (cached != null) {
results.put(tmdbId, cached);
} else {
cacheMisses.add(tmdbId);
}
}

// Step 2: Fetch only cache misses, in parallel using virtual threads
if (!cacheMisses.isEmpty()) {
log.info("TMDB cache miss for {} IDs, fetching from API", cacheMisses.size());
Map<Integer, CommunityRating> fetched = new ConcurrentHashMap<>();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
cacheMisses.forEach(tmdbId -> executor.submit(() -> {
try {
TmdbMovieDetails details = restClient.get()
.uri(tmdbProperties.baseUrl() + "/movie/{id}?language=en-US", tmdbId)
.header("Authorization", "Bearer " + tmdbProperties.apiKey())
.retrieve()
.body(TmdbMovieDetails.class);
if (details != null) {
CommunityRating rating = new CommunityRating(
Math.round(details.voteAverage() * 10.0) / 10.0,
details.voteCount());
fetched.put(tmdbId, rating);
ratingCache.put(tmdbId, rating); // Step 3: Cache the result
}
} catch (Exception e) {
log.debug("Failed to fetch TMDB details for tmdbId={}: {}",
tmdbId, e.getMessage());
}
}));
}
results.putAll(fetched);
} else {
log.info("All {} TMDB ratings served from cache", results.size());
}

return results;
}

Virtual threads (Java 21+) fit this workload well. Each TMDB API call blocks waiting for a network response, but virtual threads are so lightweight that blocking thousands of them costs almost nothing. The try-with-resources block on the ExecutorService ensures we wait for all tasks to complete before returning.

You are not hand-rolling all of the threading

Spring Boot already offloads blocking work for you when you opt in. Setting spring.threads.virtual.enabled=true, on Java 21 or later, makes the embedded web server run each request on its own virtual thread. A blocking @Controller, @SchemaMapping or @BatchMapping method then suspends its virtual thread, and the carrier OS thread is freed to serve other requests, with no reactive rewrite. That covers request-level concurrency, so different requests and different batch methods already run in parallel. The manual ExecutorService above adds a finer layer: intra-batch concurrency, firing the per-tmdbId cache-miss calls inside one batch invocation at the same time rather than in sequence. Reach for the explicit executor only for that in-method fan-out, and not to get blocking resolvers off the request thread.

The CommunityRatingController

src/main/java/com/graphqlguy/moviedb/tmdb/CommunityRatingController.java
@Controller
@RequiredArgsConstructor
@Slf4j
public class CommunityRatingController {

private final TmdbService tmdbService;

@BatchMapping(typeName = "Movie")
Map<Movie, CommunityRating> communityRating(List<Movie> movies) {
log.info("@BatchMapping: resolving communityRating for {} movies", movies.size());

List<Movie> moviesWithTmdb = movies.stream()
.filter(m -> m.getTmdbId() != null)
.toList();

if (moviesWithTmdb.isEmpty()) return Collections.emptyMap();

List<Integer> tmdbIds = moviesWithTmdb.stream().map(Movie::getTmdbId).toList();
Map<Integer, CommunityRating> ratings = tmdbService.fetchMovieRatings(tmdbIds);

Map<Movie, CommunityRating> result = new HashMap<>();
for (Movie movie : moviesWithTmdb) {
CommunityRating rating = ratings.get(movie.getTmdbId());
if (rating != null) {
result.put(movie, rating);
}
}
return result;
}
}

Movies without a tmdbId are filtered out, and get a null community rating. Movies with one are batched into a single call to fetchMovieRatings, which handles caching itself.

Test Community Ratings

query MoviesWithCommunityRatings {
movies(size: 5) {
content {
title
rating
communityRating {
voteAverage
voteCount
}
}
}
}

Watch the server logs. On the first request, you will see "TMDB cache miss for 5 IDs, fetching from API." Run the same query again within a minute and you will see "All 5 TMDB ratings served from cache."

Calling an External GraphQL API

TMDB speaks REST, so we integrated it with RestClient. Sometimes the external service is itself a GraphQL API, and your server changes role: it becomes a GraphQL client. To show how little that changes, we add one small query backed by the free Countries GraphQL API. That public endpoint works without an API key, and serves country data looked up by ISO 3166-1 alpha-2 codes such as CZ or US. Open the URL in a browser and you get a playground for exploring its schema.

Spring for GraphQL ships a dedicated client for this, and its transport flavors mirror what you already know: the reactive HttpGraphQlClient rides on WebClient, while HttpSyncGraphQlClient rides on the same RestClient we used for TMDB. Our stack is synchronous, so we take the sync one. Both live in the spring-graphql artifact that spring-boot-starter-graphql already brings in, so no new dependency needs adding.

First the schema. We expose a small Country type and a lookup query:

src/main/resources/graphql/schema.graphqls
type Country {
"ISO 3166-1 alpha-2 code, e.g. CZ"
code: ID!
name: String!
emoji: String
capital: String
currency: String
}

Add to the Query type:

country(code: ID!): Country

The Java side is one record, one service, and one controller:

src/main/java/com/graphqlguy/moviedb/country/Country.java
package com.graphqlguy.moviedb.country;

public record Country(String code, String name, String emoji,
String capital, String currency) {}
src/main/java/com/graphqlguy/moviedb/country/CountryService.java
@Service
public class CountryService {

private static final String COUNTRY_QUERY = """
query Country($code: ID!) {
country(code: $code) {
code
name
emoji
capital
currency
}
}
""";

private final HttpSyncGraphQlClient graphQlClient = HttpSyncGraphQlClient.create(
RestClient.create("https://countries.trevorblades.com/"));

public Country findByCode(String code) {
return graphQlClient.document(COUNTRY_QUERY)
.variable("code", code)
.retrieveSync("country")
.toEntity(Country.class);
}
}
src/main/java/com/graphqlguy/moviedb/country/CountryController.java
@Controller
@RequiredArgsConstructor
public class CountryController {

private final CountryService countryService;

@QueryMapping
public Country country(@Argument String code) {
return countryService.findByCode(code);
}
}

A few things are worth pausing on:

The document is a real GraphQL operation. COUNTRY_QUERY is exactly what you would type into the countries playground. The user-supplied code travels as a variable through .variable("code", code), never concatenated into the document string - the same injection discipline you apply to SQL.

We ask only for what we need. The selection set requests exactly the five fields our schema exposes and nothing else crosses the wire. The efficiency our own API offers its clients now works in our favor, because this time we are the client.

retrieveSync("country") extracts and maps. It reaches into the response at the country path and binds the JSON onto our record. Notice that no @JsonProperty appears anywhere. A GraphQL response echoes the field names of the query, already camelCase, so the record binds as it is. Compare that with the TMDB integration, where snake_case fields such as vote_average forced mapping annotations.

Unknown codes come back as null, not as errors. Ask for code: "XX" and the upstream responds with "country": null and no errors, toEntity returns null, and that flows straight out of our nullable country field. If the upstream field were null because of an error, toEntity would instead throw a FieldAccessException carrying the response and the field errors, so the two cases stay distinguishable.

One simplification to be aware of: we hard-code the endpoint URL and build the client inline to keep the example a single class. In a real integration the URL belongs in a @ConfigurationProperties class exactly like TmdbProperties, and the client in a @Bean, so tests can point it at a stub server.

Test It

query CountryByCode {
country(code: "CZ") {
code
name
emoji
capital
currency
}
}
{
"data": {
"country": {
"code": "CZ",
"name": "Czech Republic",
"emoji": "🇨🇿",
"capital": "Prague",
"currency": "CZK"
}
}
}

You might be tempted to hang this directly onto Person, since our people already have a nationality field. Resist the shortcut: nationality is free-form text like "British-American", a demonym rather than an ISO code, and mapping demonyms to countries is fuzzier than it looks. The clean way to link the two is to store a country code on the entity and resolve it through this client, and Exercise 4 walks you through exactly that, external-API N+1 included.

Step 3: Schema-Declared Validation Rules

Validation logic often lives in service methods: check that the score is between 1 and 10, check that the comment is not too long. But those checks happen deep in the Java layer; by the time the service sees the data, it has already passed through parsing, security, and resolver dispatch. Declaring the rules in the schema has two benefits: anyone reading the SDL sees the contract, and the validation runs before any resolver executes.

We will do this in two parts. First, we declare the constraints as schema directives so they are part of the public API contract. Second, we wire actual enforcement using Bean Validation on the Java input record, which Spring runs before the mutation method is invoked.

Declare the Directives in the Schema

src/main/resources/graphql/schema.graphqls
directive @Range(min: Int!, max: Int!) on INPUT_FIELD_DEFINITION
directive @Size(min: Int = 0, max: Int!) on INPUT_FIELD_DEFINITION

input CreateMovieReviewInput {
movieId: ID!
score: Int! @Range(min: 1, max: 10)
comment: String @Size(max: 2000)
}

type Mutation {
createMovieReview(input: CreateMovieReviewInput!): Review!
}

These directives document the constraint in the SDL itself, which is where anyone reading your schema file, and any tooling that reads SDL, will find it. They do not reach a client that introspects. Standard introspection reports directive definitions, in __schema.directives, and never reports where a directive was applied, so an introspecting client sees score: Int! and learns nothing about the 1-to-10 range. That gap is in the specification, and it is why schema registries prefer an SDL upload over an introspection result. (graphql-java offers IntrospectionWithDirectivesSupport, which bolts applied directives onto the introspection types at the cost of answering with something the specification does not define.) The directives also do not, on their own, validate anything at runtime; we have to wire enforcement.

Why not use SchemaDirectiveWiring.onInputObjectField to enforce these?

A common (and tempting) approach is to write a SchemaDirectiveWiring.onInputObjectField that wraps a data fetcher with validation. This does not work in graphql-java. Input object fields are not executable. They have no DataFetcher, because their values come from argument coercion rather than field resolution. Calling env.getFieldDataFetcher() or env.setFieldDataFetcher(...) from onInputObjectField throws an AssertException, "An output field must be in context to call this method", because there is no data fetcher to wrap. Wrapping a data fetcher is the right move for output field directives, through onField. Enforcing an input constraint means validating the coerced argument value at the enclosing field. Two practical routes exist. The graphql-java-extended-validation library does exactly this through its ValidationSchemaWiring, shown in the alternative below. Spring's own Bean Validation is simpler still. We use Bean Validation next, because it is idiomatic, well supported, and produces structured errors the resolver layer maps cleanly. The schema-first alternative follows it.

Enforce the Constraints with Bean Validation

Add jakarta.validation annotations to the input record. These match the directive declarations in the schema:

src/main/java/com/graphqlguy/moviedb/review/CreateMovieReviewInput.java
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

public record CreateMovieReviewInput(
@NotNull String movieId,
@Min(1) @Max(10) int score,
@Size(max = 2000) String comment
) {}

Mark the controller as @Validated so Spring runs the validators on incoming arguments, and add @Valid to the input parameter so nested constraints inside the record are walked:

src/main/java/com/graphqlguy/moviedb/review/ReviewController.java
import jakarta.validation.Valid;
import org.springframework.validation.annotation.Validated;

@Controller
@Validated
@RequiredArgsConstructor
class ReviewController {

private final ReviewService reviewService;

@MutationMapping
Review createMovieReview(@Argument @Valid CreateMovieReviewInput input,
Authentication auth) {
return reviewService.createMovieReview(input, auth.getName());
}
}

When a client sends score: 15, Spring's validation aborts the call with a ConstraintViolationException before ReviewService ever runs. The @GraphQlExceptionHandler we wrote in Class 5 catches that exception and returns a structured error to the client.

Add a handler that translates ConstraintViolationException into a BAD_REQUEST GraphQL error - a new method on the GlobalExceptionHandler from Class 5, in the same style as the others:

src/main/java/com/graphqlguy/moviedb/exception/GlobalExceptionHandler.java
@GraphQlExceptionHandler
public GraphQLError handleConstraintViolation(ConstraintViolationException ex,
DataFetchingEnvironment env) {
ConstraintViolation<?> first = ex.getConstraintViolations().iterator().next();
String field = first.getPropertyPath().toString();
return GraphqlErrorBuilder.newError(env)
.message(field + " " + first.getMessage())
.errorType(ErrorType.BAD_REQUEST)
.extensions(Map.of("field", field))
.build();
}

Test Validation

mutation CreateMovieReviewWithInvalidScore {
createMovieReview(input: {
movieId: "1", score: 15, comment: "Great movie!"
}) {
id
score
}
}

Response:

{
"errors": [{
"message": "createMovieReview.input.score must be less than or equal to 10",
"extensions": {
"field": "createMovieReview.input.score",
"classification": "BAD_REQUEST"
}
}]
}

Note the field's shape: method-level validation reports the violation's property path as method.parameter.field, so the handler sees createMovieReview.input.score, not a bare score. The prefix is useful during development because it names the exact mutation and argument; if you would rather expose only the field name to clients, walk getPropertyPath() to its last node in the handler instead of calling toString() on the whole path.

Alternative: Schema-Driven Enforcement

Bean Validation works, but notice the duplication: every rule is declared once in the SDL (@Range(min: 1, max: 10)) and again on the Java record (@Min(1) @Max(10)). If you want the schema to be the single source of truth - the SDL drives validation and no Java annotations need keeping in sync - reach for graphql-java-extended-validation, maintained by the GraphQL Java team. It ships a catalog of ready-made constraint directives modeled on Bean Validation's jakarta.validation annotations and enforces them for you. The ones you will use most often:

DirectiveApplies toEnforces
@Range(min:, max:)numbersvalue falls between min and max
@Size(min:, max:)strings, listslength or element count within bounds
@Pattern(regexp:)stringsvalue matches a regular expression
@NotBlankstringsat least one non-whitespace character
@Positive / @Negativenumberssign constraints, with ...OrZero variants

The full catalog - including @DecimalMin/@DecimalMax, @NotEmpty, and the @Expression escape hatch that evaluates a Jakarta EL expression when no built-in constraint fits - is in the project's official documentation, together with each directive's exact SDL declaration.

pom.xml
<dependency>
<groupId>com.graphql-java</groupId>
<artifactId>graphql-java-extended-validation</artifactId>
<version>24.0</version>
</dependency>

You wire it the same way you wired the DateTime scalar in Class 12 - a RuntimeWiringConfigurer bean - but registering a directive wiring instead of a scalar. ValidationSchemaWiring is itself a SchemaDirectiveWiring, and it is precisely the piece a hand-rolled onInputObjectField could not be: instead of wrapping a non-existent input-field data fetcher, it validates the whole coerced argument value, nested input fields included, before the resolver runs.

src/main/java/com/graphqlguy/moviedb/config/GraphQLConfig.java
@Bean
public RuntimeWiringConfigurer validationWiringConfigurer() {
ValidationRules rules = ValidationRules.newValidationRules().build();
ValidationSchemaWiring validation = new ValidationSchemaWiring(rules);
return wiringBuilder -> wiringBuilder.directiveWiring(validation);
}

Spring detects every RuntimeWiringConfigurer bean, so this sits happily alongside the DateTime scalar configurer from Class 12 (you could equally fold .directiveWiring(validation) into that existing chain). With it in place, you can drop the jakarta.validation annotations from CreateMovieReviewInput and the @Validated/@Valid markers from the controller: a score of 15 is now rejected by the engine against the @Range directive declared in the schema. One difference from the Bean Validation route is worth knowing. The failure surfaces as a structured GraphQL error the library classifies as ExtendedValidationError, carrying the validatedPath and the violated constraint, where our own handler would have produced BAD_REQUEST. The wrapped data fetcher returns the errors directly, so the ConstraintViolationException handler stays out of it. If you want your own classification, subclass ResourceBundleMessageInterpolator and override buildErrorClassification. One dependency caveat: the validation library's major version tracks the graphql-java major version it targets (24.0 targets graphql-java 24.x), so pin it to match the graphql-java that Spring Boot manages for you. Its releases can trail the Boot-managed graphql-java version, so if Boot pulls in a newer graphql-java major before a matching validation release exists, hold the previous validation major until one ships. That is exactly our situation today: 24.0 is the newest release, running against the Boot-managed graphql-java 25.0.

Which to use? Bean Validation is idiomatic Spring and keeps the rules next to the Java types, which is handy when the same records are validated outside GraphQL too. The extended-validation library keeps the contract in the schema, where anyone reading the SDL can see it, and removes the duplication. Note that this is an SDL benefit and not an introspection one: its @Range and @Size are applied directives, so they are as invisible to an introspecting client as the hand-declared ones above. Both are legitimate; this lesson uses Bean Validation as its primary path and offers this as the schema-first alternative.

Step 4: Query Instrumentation

Instrumentation lets you intercept every stage of query execution. We will build two instrumentations: one for security and one for observability.

Query Depth Limiting (Security)

Without depth limits, a malicious client can send a deeply nested query that causes exponential work:

# This query could overwhelm the server
query {
movies {
content {
cast { person { movieCastCredits { movie { cast { person { movieCastCredits {
# ... 20 levels deep
}}}}}}
}
}
}

Our QueryDepthInstrumentation walks the parsed query AST and rejects anything deeper than a configurable threshold:

Default limits arrive with graphql-java 26

graphql-java 26, in a deliberate breaking change upstream, turns on a QueryComplexityLimits validation for every query, rejecting anything past maxDepth 100 or maxFieldsCount 100,000. An over-limit query fails validation with an error of type MaxQueryDepthExceeded or MaxQueryFieldsExceeded. Both limits are tunable through the GraphQLContext under QueryComplexityLimits.KEY, and QueryComplexityLimits.NONE disables them. Our classpath is a version behind. Spring Boot 4.0.x and 4.1.x manage graphql-java 25.0, which ships no default depth or field-count limits. Its built-in defaults stop at the parser, capping raw query size at 15,000 tokens and 1 MB, plus good-faith introspection limits. Every depth and complexity guard in this lesson exists because you register it. The 26 defaults land here on their own when Boot moves its managed version up. Those good-faith introspection limits are worth knowing in their own right: graphql-java caps an introspection query at 500 fields and depth 20, and allows __schema, __type, fields, inputFields, interfaces and possibleTypes to appear once each per operation. They arrived in 19.10, 20.8 and 21.4 in March 2024, in response to CVE-2024-40094, an availability issue. The introspection type system is cyclic, because a __Type has fields and a __Field has a type, so a small query can ask for an enormous response. graphql-java 26 moves that check from execution time into validation. The limits are tuned so the standard tooling query still passes.

Even then, the default is a blunt safety net - a fixed depth of 100 and a raw field count. graphql-java also ships MaxQueryDepthInstrumentation and MaxQueryComplexityInstrumentation as beans for a lower, app-specific depth cap or a weighted complexity budget (new MaxQueryDepthInstrumentation(10)). The hand-rolled QueryDepthInstrumentation below is a teaching device for the instrumentation API - and a template for when you need rejection logic the built-ins don't express (a custom message, a per-tenant limit, metrics on every rejection). For a plain depth cap in production, reach for the built-in.

Both of our instrumentations are tunable: the depth limiter has a maximum depth, and the field timer (coming up next) has a slow-resolver threshold. Just as with TmdbProperties, the tuning knobs live in one dedicated @ConfigurationProperties record bound to the graphql.* prefix, not in per-class @Value fields. Relaxed binding maps graphql.max-query-depth onto maxQueryDepth and graphql.slow-resolver-threshold-ms onto slowResolverThresholdMs, and it gets registered the same way, via @ConfigurationPropertiesScan or @EnableConfigurationProperties:

src/main/java/com/graphqlguy/moviedb/instrumentation/InstrumentationProperties.java
@ConfigurationProperties(prefix = "graphql")
public record InstrumentationProperties(
@DefaultValue("10") int maxQueryDepth,
@DefaultValue("100") long slowResolverThresholdMs) {}
src/main/java/com/graphqlguy/moviedb/instrumentation/QueryDepthInstrumentation.java
@Component
@Slf4j
@RequiredArgsConstructor
public class QueryDepthInstrumentation extends SimplePerformantInstrumentation {

private final InstrumentationProperties properties;

@Override
public DocumentAndVariables instrumentDocumentAndVariables(
DocumentAndVariables documentAndVariables,
InstrumentationExecutionParameters parameters,
InstrumentationState state) {

int maxDepth = properties.maxQueryDepth();
Document document = documentAndVariables.getDocument();

// Collect fragment definitions for resolving FragmentSpreads
Map<String, FragmentDefinition> fragments = document.getDefinitions().stream()
.filter(FragmentDefinition.class::isInstance)
.map(FragmentDefinition.class::cast)
.collect(Collectors.toMap(FragmentDefinition::getName, f -> f));

int depth = calculateDepth(document, fragments);

if (depth > maxDepth) {
log.warn("Query rejected: depth {} exceeds maximum {}", depth, maxDepth);
throw new AbortExecutionException(
String.format("Query depth %d exceeds maximum allowed depth of %d",
depth, maxDepth));
}

return documentAndVariables;
}
}

We extend SimplePerformantInstrumentation instead of implementing Instrumentation directly, because it is the recommended no-op base class. It implements every hook with cheap shared no-ops, and unlike the deprecated SimpleInstrumentation it avoids deprecated callbacks and per-call state. We override only the hooks we need.

The depth calculation walks the selection set tree recursively, handling fields, inline fragments, and named fragment spreads. Fragment spreads are resolved through the fragments map to ensure that a query using fragments is measured correctly.

Depth limiting is not optional

Any public-facing GraphQL API needs a depth limit. Without one, a single malicious query can recurse through millions of field resolutions, burning CPU and memory until the process crashes. Our graphql-java 25.0 classpath applies no default depth limit, so this instrumentation is the protection. graphql-java 26 adds one at depth 100, and it has yet to reach the Boot-managed classpath. Even that default is generous, so set a tighter limit for your own application.

One number deserves care when you pick that limit. The standard introspection query is thirteen levels deep, because the TypeRef fragment that unwraps NON_NULL and LIST nests ofType seven times on its own, and the query GraphiQL sends nests it further still. A depth cap below that rejects introspection while every ordinary query keeps working. The symptom is quiet: the Docs panel and the autocomplete in GraphiQL stop working, which is the tooling Class 1 told you to explore your schema with. The 10 this class binds as the default is below that floor. If you want introspection working in development, either raise the limit above thirteen or skip the check for operations whose root fields are all __schema or __type, which is a two-line addition to calculateDepth. In production, where you have turned introspection off anyway, a tighter cap costs you nothing.

Field Timing (Observability)

The FieldTimingInstrumentation hooks beginFieldFetching - the per-field callback that fires as each resolver runs - and logs any resolver slower than a configurable threshold, so you can spot bottlenecks without distributed tracing:

src/main/java/com/graphqlguy/moviedb/instrumentation/FieldTimingInstrumentation.java
@Component
@Slf4j
@RequiredArgsConstructor
public class FieldTimingInstrumentation extends SimplePerformantInstrumentation {

private final InstrumentationProperties properties;

@Override
public FieldFetchingInstrumentationContext beginFieldFetching(
InstrumentationFieldFetchParameters parameters,
InstrumentationState state) {
long startNanos = System.nanoTime();
String fieldPath = parameters.getEnvironment()
.getExecutionStepInfo().getPath().toString();

return new FieldFetchingInstrumentationContext() {
@Override
public void onDispatched() { }

@Override
public void onCompleted(Object result, Throwable error) {
long durationMs = (System.nanoTime() - startNanos) / 1_000_000;
if (durationMs >= properties.slowResolverThresholdMs()) {
log.warn("Slow resolver: {} took {}ms", fieldPath, durationMs);
}
}
};
}
}

beginFieldFetching returns a FieldFetchingInstrumentationContext, and we implement its onCompleted to record the elapsed time. It replaced the older beginFieldFetch (which returned a plain InstrumentationContext<Object>), now deprecated in graphql-java.

Name your operations for observability

Logs and metrics grouped by field path (above) become more useful when you can also group requests by operation. Give every query and mutation a name, such as query MoviesPage { ... }. Spring for GraphQL records that name on its graphql.request observation under the graphql.operation.name key, as its observability reference lists. That key is high-cardinality, so Micrometer adds it to traces and keeps it out of metric tags. For an operation without a name, Spring records the value query, so every unnamed request appears under the same label. Our introduction course explains anonymous operations and why to name them.

Both instrumentations are @Components, so Spring Boot auto-discovers them. Spring GraphQL automatically picks up all Instrumentation beans and chains them together.

src/main/resources/application.yaml
# Instrumentation configuration
graphql:
max-query-depth: 10
slow-resolver-threshold-ms: 100

Beyond depth: cost analysis

Depth limiting stops queries that go too deep, and a field-count or complexity cap stops ones that are absurdly wide. graphql-java 26 brings a default field-count cap that has yet to reach our Boot-managed classpath, and the MaxQueryComplexityInstrumentation below gives the same protection today. Neither knows that some fields cost far more than others. communityRating reaches TMDB over the network, where title is a column read. Cost analysis closes that gap. You assign a weight to each field, sum the weights across the requested query, and reject anything over budget before a resolver runs.

On this stack the in-process tool is graphql-java's MaxQueryComplexityInstrumentation paired with a custom FieldComplexityCalculator (weight the expensive fields, and multiply a list field by its page size). At the gateway tier, the emerging declarative approach is directive-based demand control - the @cost and @listSize directives from the IBM GraphQL Cost Directive spec, implemented by Apollo's GraphOS Router and others. Both get fiddly fast: the cost is a static estimate, list sizes have to be inferred from paging arguments, and the weights are heuristics you tune against real traffic. So we stop at depth limiting here and keep the full treatment for the Advanced Spring GraphQL tutorial.

Passing Request Context with WebGraphQlInterceptor

The JwtAuthFilter from Class 6 is a servlet filter. It runs on the HTTP request, populates Spring Security's context, and resolvers read the user from there. That is right for authentication over HTTP, and it misses two things. It sees only HTTP, never the WebSocket that carries subscriptions. And it is the wrong place for non-auth request-scoped data, such as a tenant id, a feature flag, or a correlation id you want on every response.

Spring for GraphQL's own hook, WebGraphQlInterceptor, covers both. It runs for HTTP and WebSocket, it can read and modify the request and the response, and it can seed values into the per-request GraphQLContext that any resolver reads with @ContextValue:

src/main/java/com/graphqlguy/moviedb/config/RequestContextInterceptor.java
@Component
public class RequestContextInterceptor implements WebGraphQlInterceptor {

@Override
public Mono<WebGraphQlResponse> intercept(WebGraphQlRequest request, Chain chain) {
String requestId = UUID.randomUUID().toString();
request.configureExecutionInput((executionInput, builder) ->
builder.graphQLContext(ctx -> ctx.put("requestId", requestId)).build());

return chain.next(request).doOnNext(response ->
response.getResponseHeaders().add("X-Request-Id", requestId));
}
}

The interceptor stamps a requestId into the GraphQLContext before execution, and echoes it back as a response header afterwards. That correlation id ties a client-visible header to everything happening server-side, and pairs naturally with the error reference id from Class 5. Any resolver can read it as a method parameter:

@QueryMapping
List<Movie> movies(@ContextValue String requestId) {
log.info("Serving movies for request {}", requestId);
return movieService.findAll();
}

Where does this leave the Class 6 filter? Keep it. The servlet filter is the idiomatic place for HTTP authentication, and SecurityContextHolder works fine in resolvers. The interceptor handles cross-cutting request and response concerns, and anything that has to reach a subscription over WebSocket. The day you add a subscription that needs to know who is listening, for "only notify me about my movies", you authenticate the WebSocket in an interceptor and read the principal from the GraphQLContext.

Step 5: Spring Data AOT for Faster Startup and Build-Time Validation

Every time the application starts, Spring Data walks every repository interface, parses every derived query method name (like findByTitleContainingIgnoreCase), and validates every @Query JPQL string against the entity metadata. For our app that is fast. For a service with hundreds of repository methods and complex JPQL, that's a non-trivial chunk of startup time. And every JPQL typo is a runtime surprise, not a compile error.

Spring Data AOT moves that work from startup to build time. Repository metadata gets baked into generated Java classes at compile, so at runtime Spring Data just loads them.

Enable the Build Goal

Add the process-aot execution to the Spring Boot Maven plugin in pom.xml:

pom.xml
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<id>process-aot</id>
<goals>
<goal>process-aot</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

That is all the wiring you need. On Spring Boot 4.0, AOT repositories are enabled by default whenever AOT actually runs - which the process-aot goal above triggers at build time. No enabled=true flag needs setting; the relevant property is an opt-out. If you ever need to turn generation off, set spring.aot.jpa.repositories.enabled=false to disable the JPA repositories specifically, or spring.aot.repositories.enabled=false to disable generated repositories across every Spring Data module:

src/main/resources/application.yaml (only if you need to opt out)
# AOT repositories are ON by default when AOT runs; set false to disable
spring:
aot:
jpa:
repositories:
enabled: false

Run the AOT Processing

./mvnw clean compile

Look in target/spring-aot/main/sources/. You'll find generated classes following the pattern <Repository FQCN>Impl__AotRepository.java:

target/
└── spring-aot/main/sources/
└── com/graphqlguy/moviedb/movie/
├── MovieRepositoryImpl__AotRepository.java
└── MovieCastRepositoryImpl__AotRepository.java

Open one. You'll see your derived method names translated to actual EntityManager code, and your @Query JPQL embedded in a validated form. Spring already did the parse, resolved the fields and checked every property reference.

Test It By Breaking It

The real payoff: introduce a typo into your Class 11 filter query. Change m.releaseYear to m.releasYear:

src/main/java/com/graphqlguy/moviedb/movie/MovieRepository.java
@Query("SELECT m FROM Movie m WHERE " +
"(:minYear IS NULL OR m.releasYear >= :minYear)") // typo!
Page<Movie> findWithFilters(...);

Run ./mvnw compile. The build fails with a JPQL validation error pointing directly at the typo. In the pre-AOT world, this would have compiled cleanly, the app would have started, and the Page<Movie> query would have blown up the first time a client sent a filter.

What You Get

  • 50-70% faster startup on repo-heavy applications, because Spring Data skips the runtime metadata build for every AOT-processed repository
  • Build-time JPQL validation, so every typo in an @Query becomes a build failure instead of a 3 AM production alert
  • Reduced runtime memory, since the metadata caches Spring Data would otherwise keep in memory don't exist
  • Inspectable generated code, so you can read the SQL your @Query will actually produce without running the app

The Trade-Offs

  • Build time grows by a few seconds as the AOT processor runs. Fine for CI, occasionally annoying locally.
  • Derived method changes require recompilation before the app can use them. Live-reload workflows feel slightly heavier.
  • Not every repository method is generated. Interface and DTO projections and Stream returns are supported, but a handful of method shapes are deliberately excluded and stay on runtime processing. There are five. Methods inherited from base interfaces such as CrudRepository, PagingAndSortingRepository, Querydsl and Query-by-Example, whose implementations already come from the base fragments. Methods accepting a ScrollPosition for keyset pagination. Dynamic projections, meaning a method taking a Class<T> argument. Derived methods that are too complex. And reactive repositories as a whole, since AOT repositories are imperative-only. Check the Spring Data AOT docs for the current support matrix.
When This Matters Most

AOT's value scales with your repository count. A single @Query method in Class 3 barely notices. By Class 13, with repositories in movie, person, review, tvshow, and user, each with derived and JPQL methods, AOT is a measurable win. For an enterprise service with 200 repositories, it's the difference between a 30-second startup and an 8-second one.

Step 6: CORS Configuration

If you are building a frontend that runs on localhost:5173, you need CORS configuration. There are two valid approaches in a Spring GraphQL app:

  1. Configure CORS for the GraphQL endpoint specifically via spring.graphql.cors.* properties (allowed-origins, allowed-methods, allow-credentials, etc.). This is the idiomatic Spring GraphQL approach and applies to the /graphql HTTP handler. It's also the simplest if your GraphQL endpoint is the only thing the browser hits.

  2. Configure global CORS via Spring Security with a CorsConfigurationSource bean referenced from the security filter chain (.cors(cors -> cors.configurationSource(corsConfigurationSource()))). This is appropriate if you have multiple HTTP endpoints (REST + GraphQL) sharing the same CORS policy.

Either way, without CORS configured the browser blocks cross-origin GraphQL requests. Pick one approach and use it consistently.

Final Project Structure

After all 13 classes, the project has grown to include packages for config, country, exception, instrumentation, movie, person, review, shared, tmdb, tvshow, and user. The new packages and changes from this class are:

  • tmdb/: TmdbProperties, TmdbService, TmdbController, CommunityRatingController, TmdbResult, CommunityRating
  • country/: Country, CountryService, CountryController - the external GraphQL API integration
  • review/: CreateMovieReviewInput gains Bean Validation annotations (@Min, @Max, @Size) and ReviewController gains @Validated / @Valid. The schema gains @Range / @Size directive declarations that document the constraints in the SDL.
  • exception/GlobalExceptionHandler: gains a @GraphQlExceptionHandler that translates ConstraintViolationException into BAD_REQUEST GraphQL errors.
  • instrumentation/: InstrumentationProperties, QueryDepthInstrumentation, FieldTimingInstrumentation
  • config/CacheConfig: Caffeine cache bean for TMDB ratings
  • config/DataInitializer: seed movies gain their real TMDB ids, so communityRating returns live data once a key is configured

Exercises

Exercise 1: Add TV Show Community Ratings

Extend the communityRating concept to TV shows. TMDB has a TV show API endpoint (/tv/{id}). Create a TvShowCommunityRatingController with a @BatchMapping(typeName = "TvShow") that resolves community ratings for TV shows using the same cache-aside pattern.

Exercise 2: Create a @Deprecated Directive

Build a @Deprecated(reason: String!) directive that logs a warning whenever a deprecated field is accessed. This is useful for tracking migration progress when evolving your schema.

Exercise 3: Query Complexity Analysis

Depth limiting catches deeply nested queries. To catch absurdly wide ones on today's classpath, register graphql-java's MaxQueryComplexityInstrumentation as a @Bean with a budget, say 200, for a uniform per-field complexity cap. graphql-java 26 has a default maxFieldsCount for this, and the 25.0 that Boot manages does not. For per-field weighted cost - pricing an external-API field like communityRating above a cheap column read - see the "Beyond depth: cost analysis" note in Step 4; the full treatment is in the advanced tutorial.

Add a countryCode column to Person (ISO 3166-1 alpha-2), expose birthCountry: Country on the Person type, and resolve it with a @SchemaMapping that delegates to CountryService. Then load a page of 20 people and watch the N+1 problem reappear, this time against an external API. Fix it with this class's tools. The countries API accepts countries(filter: { code: { in: [...] } }), so a @BatchMapping collapses the page into one upstream call. A Caffeine cache with a long TTL removes most calls entirely, since country data rarely changes.

Common Issues

Issue: TMDB returns empty results

Symptom: tmdbSearch returns an empty array even for known movies Solution: Check that TMDB_API_KEY is set as an environment variable. The key must be an API Read Access Token (v4 auth), not the older API Key (v3 auth). Check server logs for "TMDB_API_KEY is not configured."

Issue: Validation does not fire (score: 15 passes through)

Symptom: A request with score: 15 reaches the service instead of being rejected. Solution: There are three common causes. The controller class may be missing @Validated, without which Spring runs no validators. The input parameter may be missing @Valid, so the record's per-field constraints go unchecked. Or spring-boot-starter-validation may be off the classpath, leaving no Validator bean wired; add the starter to pom.xml in that case. Note also that the schema directive declarations, @Range and @Size, are documentation only in this implementation. The Bean Validation annotations on the input record do the enforcing.

Issue: QueryDepthInstrumentation not firing

Symptom: Deeply nested queries execute without being rejected Solution: Ensure the class is annotated with @Component so Spring discovers it. Check that graphql.max-query-depth is set in application.yaml. The default is 10, so verify your test query actually exceeds this depth.

Issue: CORS errors from the frontend

Symptom: Browser console shows "Access to XMLHttpRequest has been blocked by CORS policy" Solution: Check that your CORS setup includes the frontend's origin, such as http://localhost:5173. That is the spring.graphql.cors.allowed-origins property if you configured CORS on the GraphQL endpoint, or the CorsConfigurationSource bean if you configured it through Spring Security. The origin has to match exactly, and http://localhost:5173 differs from http://localhost:5173/. Allow credentials too if the frontend sends cookies or auth headers: spring.graphql.cors.allow-credentials=true for the property approach, or setAllowCredentials(true) on the CorsConfiguration for the Security one.

Production Topics Beyond This Class

This chapter scaffolds a production-shaped application but doesn't cover everything a hardened service needs. Before you ship, plan for:

  • HTTP timeouts on external clients. RestClient.create() does not set a connect or read timeout by default; an upstream stall can hang resolver threads indefinitely. Configure ClientHttpRequestFactorySettings.withConnectTimeout(...) and withReadTimeout(...) and pass them via RestClient.builder().requestFactory(...). This applies to both of our external clients: the TMDB RestClient and the RestClient underneath HttpSyncGraphQlClient.
  • Circuit breaker / retry / bulkhead. Resilience4j integrates cleanly with Spring Boot and protects you from cascading failures when an upstream API degrades.
  • Disable introspection in production if your schema isn't intended to be public. On this stack the one-line switch is Spring Boot's spring.graphql.schema.introspection.enabled=false property. Boot implements it by calling graphql-java's Introspection.enabledJvmWide(false), and the older NoIntrospectionGraphqlFieldVisibility is deprecated in 25.0. Read that method name carefully. The call flips a process-global static, and nothing sets it back. One application context that disables introspection disables it for every other context in the same JVM, which is a trap in a test suite that boots several. It is an execution-time check on __schema and __type, so __typename keeps working, which is what you want. Disabling introspection also closes one disclosure channel and leaves the other open. spring.graphql.schema.printer.enabled publishes the SDL at the GraphQL path and at /schema, and SDL carries more than introspection does, because it includes the directives you applied. It defaults to false, and should stay there.
  • Built-in Micrometer observability. Spring for GraphQL ships GraphQlObservationInstrumentation and auto-configures it when Micrometer is on the classpath, producing graphql.request, graphql.datafetcher, and graphql.dataloader observations. Prefer this over hand-rolled timing instrumentation in production.
  • Persisted queries / trusted documents. Limits attack surface and reduces request payload sizes. Covered in a future advanced tutorial.
  • Response caching at the HTTP/CDN layer when applicable. Persisted queries used over GET enable CDN caching by URL.

Summary

In this final class, you learned:

  • External API integration with RestClient and Bearer token authentication, calling TMDB from GraphQL resolvers while handling failures gracefully
  • GraphQL-to-GraphQL integration with HttpSyncGraphQlClient: sending a real document with variables to an upstream GraphQL API and binding the camelCase response straight onto records, no mapping annotations needed
  • Cache-aside pattern with Caffeine: checking the cache first, fetching only cache misses, and caching fresh results to avoid rate limits
  • Virtual threads for parallel HTTP calls, providing lightweight concurrency for I/O-bound work like external API calls
  • Custom directives (@Range, @Size) that declare input constraints in the SDL for documentation. Bean Validation on the input record enforces them at runtime, or, in the schema-first alternative, graphql-java-extended-validation's ValidationSchemaWiring on the coerced argument value. Wrapping an input-field data fetcher cannot work, because an input field has none
  • Query instrumentation for security (depth limiting) and observability (field timing), cross-cutting concerns that apply to every query without modifying resolver code
  • Spring Data AOT, which moves repository query processing from runtime to build time for faster startup and compile-time validation of @Query JPQL
  • CORS configuration to enable frontend integration

Congratulations!

You have completed the Spring GraphQL Tutorial. Over these 13 classes, you built a full-featured GraphQL API from scratch:

ClassWhat You Built
1-2Project setup, schema design, your first queries
3-4Nested queries with junction entities, mutations with input types
5Error handling with custom exceptions and partial responses
6Security with JWT authentication and role-based authorization
7Resolver-level authorization with owner-or-admin checks and method security
8@BatchMapping (DataLoader-backed) for N+1 prevention at the resolver layer
9Testing with HttpGraphQlTester
10Real-time subscriptions over WebSocket
11Offset and cursor pagination, filtering, sorting
12TV show domain, union types, custom scalars
13External APIs, directives, instrumentation, caching

You now have the skills to design schemas, build resolvers, handle errors, secure your API, optimize performance, and integrate with external services. These patterns apply to any GraphQL API you build, whether it is a movie database, an e-commerce platform, or a social network.

Topics for the Advanced Tutorial

There are several production patterns we haven't covered in this tutorial that become important when running GraphQL at scale. These will be covered in a future Advanced Spring GraphQL tutorial:

  • Persisted queries: predefining a set of known-safe queries that clients reference by ID instead of sending full query strings. This improves security (no arbitrary queries), performance (smaller payloads), and makes query cost analysis more predictable.
  • Field-level cost analysis: assign a complexity weight to each field, and reject queries over a cost budget. It is finer-grained than the depth and complexity limits from this class, so an external-API field can cost more than a column read. Do it in process with graphql-java's MaxQueryComplexityInstrumentation plus a FieldComplexityCalculator, or declaratively at the gateway with the @cost and @listSize demand-control directives, as in Apollo GraphOS Router and the IBM Cost spec.
  • Rate limiting: throttling queries per user, per tenant, or per API key. Best implemented at the gateway level so individual services don't need their own implementations.
  • Schema lifecycle management: tagging fields as experimental, beta, or production using custom directives, so clients can opt into unstable APIs with a clear handshake about expectations.
  • Schema governance and ground rules: establishing naming conventions, mutation return type patterns, and consistency rules across teams. Essential once multiple contributors are working on the same schema, to avoid ending up with six different patterns for the same thing.
  • GraphQL gateway and federation: consolidating multiple GraphQL services behind a single schema with cross-service data hydration and subgraphs.

What's Next?

The natural next step is a client. A React and Apollo Client tutorial against this same API is in preparation, and it will be linked here once it is ready.