Class 4: Mutations
Duration: 115 minutes (the longest class in the course) | Difficulty: Intermediate | Prerequisites: Class 3 completed
What You'll Learn
By the end of this class, you will:
- Understand the difference between queries and mutations
- Design input types and response types for clean, maintainable mutation APIs
- Implement create, update, and delete operations with
@MutationMapping - Apply three layers of validation (schema, Bean, service) to mutation inputs
- Use Java records and
ArgumentValue<T>for partial updates
Queries vs. Mutations: A Matter of Intent
In Class 3 (Github repo here), we modeled relationships with a junction entity (MovieCast) and resolved nested fields like a movie's directors and cast. So far, everything we've built has been read-only. Queries are the "GET" of GraphQL: they fetch data without side effects. Mutations are the "POST/PUT/PATCH/DELETE": they change state on the server.
GraphQL could technically execute mutations through queries, and the server wouldn't know the difference at a protocol level. But the separation matters for two practical reasons. First, GraphQL guarantees that mutations within a single request execute sequentially, while query fields may execute in parallel. Second, caching tools and CDNs can safely cache query responses but must never cache mutations. The query vs. mutation distinction tells infrastructure how to behave.
The spec explicitly requires that top-level mutation fields run one at a time, in the order the client wrote them. This is not an implementation detail. It is a guarantee.
Why does it matter? Consider a client sending three mutations in one request:
mutation Cleanup {
created: createPerson(input: { name: "Temp Person", birthYear: 1970 }) { id }
updated: updatePerson(input: { id: "42", nationality: "American" }) { id nationality }
deleted: deletePerson(id: "42") { success deletedId }
}
If these ran in parallel, deletePerson could fire before updatePerson completed, and the update would silently land on a row that was about to vanish. Or worse: the delete runs first, and the update runs against a row that no longer exists. Sequential execution removes that entire class of race conditions.
Query fields have no such guarantee because they are side effect free by contract. Running two movie(id: ...) resolvers in parallel is safe; running two createReview(...) mutations in parallel is not. This is why mixing writes into a query (even if the schema technically permits it) is a bug in the making.
The serial guarantee applies only to top-level mutation fields. Nested fields inside a mutation's response selection set (e.g., the rating and id you request back) resolve in parallel, like any other query selection. They are reads, not writes.
In the schema, mutations live under a Mutation root type, parallel to the Query root type we've been using:
type Query {
# Read operations: can run in parallel
movie(id: ID!): Movie
movies: [Movie!]!
}
type Mutation {
# Write operations: run sequentially
createPerson(input: CreatePersonInput!): Person!
deletePerson(id: ID!): DeletePersonResponse!
}
Input Types and Response Types
Why Input Types?
Look at this mutation without input types:
# Don't do this
type Mutation {
createMovie(
title: String!,
releaseYear: Int!,
genre: Genre!,
rating: Float,
runtime: Int,
plot: String,
inTheaters: Boolean!,
posterUrl: String,
tmdbId: Int,
directorIds: [ID!]
): Movie!
}
That's ten arguments on a single mutation. It's hard to read, hard to maintain, and impossible to reuse. Now compare with an input type:
# Do this instead
type Mutation {
createPerson(input: CreatePersonInput!): Person!
}
input CreatePersonInput {
name: String!
birthYear: Int
nationality: String
}
Input types bundle related arguments into a single, named object. This makes the schema cleaner, the mutation easier to call, and the Java side simpler because Spring GraphQL maps the entire input to a single Java object.
GraphQL distinguishes between input types and regular types. You can't use a regular type Person as a mutation argument, because only input types are allowed. This is because regular types can have computed fields with resolvers (like Movie.directors), which doesn't make sense for input data. Input types are plain data containers with no resolver logic.
Naming Conventions and Our Hybrid Choice
The Relay spec popularized the <verb><Entity>Payload convention for mutation return types. You will see it in many production schemas, especially those built with Relay-aware clients. Andreas Marek's book on Spring GraphQL uses it too. The official GraphQL documentation does not, and in their examples mutations typically return regular types directly without a wrapper.
We use Response in this tutorial because the name itself signals what the object is. Payload is more ambiguous: a payload can be anything. Both styles work and are well understood; the choice is mostly about which signal you want to give readers of your schema.
For create and update operations in this chapter, we return the domain entity directly. createPerson returns Person! and updatePerson returns Person. This is clean and common when the client only needs the entity and the operation doesn't carry extra metadata. For delete operations, however, returning the deleted entity is awkward because the entity no longer exists. A response type fits naturally: it can report success, a human-readable message, and the deletedId. This is the hybrid we use throughout the chapter.
The deletedId field deserves a specific mention. Apollo Client and urql identify objects in their normalized caches by type and ID. When a delete succeeds, returning the deleted ID in the response gives these clients everything they need to evict the entry from their cache immediately, without issuing a follow-up query.
GraphQL's input vs. type split is CQRS at the API layer. Write paths use input types for structured data; read paths return regular types. This is the same separation of concerns that CQRS advocates, applied to the schema rather than the server internals. The persistence layer can still share models freely. You capture most of the value of the pattern with very little of the complexity.
Update the Schema
Before writing any Java code, define the full API contract in the schema. The schema-first approach means deciding what the API looks like from the client's perspective, then implementing it on the server. The Movie and Person types stay exactly as they were at the end of Class 3. This chapter adds new types, not new fields.
📁 src/main/resources/graphql/schema.graphqls
Add a people field to the existing Query type:
type Query {
# ...existing movie queries from Class 3 stay as they are...
"""List every person (actors, directors, etc.)."""
people: [Person!]!
}
people returns [Person!]!, a non-null list whose every element is a non-null Person. The shape mirrors the movies query from earlier classes on purpose: read fields that return collections follow one house style across the schema, so anyone who has already called movies knows what people will give back.
In the same file, add the mutations and the types they depend on:
type Mutation {
"""Delete a movie by ID"""
deleteMovie(id: ID!): DeleteMovieResponse!
"""Create a new person (actor, director, etc.)"""
createPerson(input: CreatePersonInput!): Person!
"""Update an existing person (partial update: only provided fields are changed)"""
updatePerson(input: UpdatePersonInput!): Person
"""Delete a person by ID"""
deletePerson(id: ID!): DeletePersonResponse!
}
"""Result of deleting a movie."""
type DeleteMovieResponse {
success: Boolean!
message: String!
"""ID of the deleted movie, or null if nothing was deleted."""
deletedId: ID
}
"""Result of deleting a person."""
type DeletePersonResponse {
success: Boolean!
message: String!
"""ID of the deleted person, or null if nothing was deleted."""
deletedId: ID
}
"""Fields required to create a new person."""
input CreatePersonInput {
name: String!
birthYear: Int
nationality: String
}
"""Fields to update on an existing person (all optional except id)."""
input UpdatePersonInput {
id: ID!
name: String
birthYear: Int
nationality: String
}
The input shapes follow a deliberate logic. CreatePersonInput marks name as required with String! because a person without a name makes no sense. birthYear and nationality are optional from the start: we may not have that data when creating a record. UpdatePersonInput flips this: id: ID! is the only required field because you always need to identify which person to update, while everything else is optional. Sending only { id: "5", nationality: "French" } changes nationality alone and leaves name and birthYear untouched.
For delete, the schema takes the ID directly as a top-level argument rather than wrapping it in an input type. deleteMovie(id: ID!) and deletePerson(id: ID!) is the whole signature on the write side; there are no DeleteMovieInput or DeletePersonInput types and no matching Java records on the server. Input types earn their keep when a mutation passes several related fields, as createPerson and updatePerson do, because they bundle related arguments into a single named shape that is easy to read, easy to reuse, and straightforward to bind to a Java record. When there is only an ID to pass, that bundling has nothing to bundle. A one-field input type adds a schema type, a Java record, and an extra unwrapping step in the resolver, all for the appearance of uniformity. We choose the smaller shape: input types where they earn their keep, bare arguments where they would not.
The delete response types carry three fields. success and message are self-explanatory. deletedId is the field that earns its keep on the client side: when a delete succeeds, deletedId holds the ID of the entity that was removed, and Apollo Client or urql can use that value to immediately evict the entry from their normalized cache without a follow-up query. When the delete fails (the entity was not found), deletedId is null, which signals to the client that there is nothing to evict.
Java Records: Inputs and Responses
With the schema defined, we create the Java records that Spring GraphQL will bind to when a mutation arrives. Two input records and two response records cover the full contract. Delete operations take a bare Long id straight into the resolver, so they need no record on the Java side.
CreatePersonInput
This is the first file in the tutorial that uses Jakarta Bean Validation (@NotBlank, @Size, @Min, and later @Valid). Those annotations are not on the classpath of the project we generated in Class 1. Since Spring Boot 2.3, spring-boot-starter-web no longer pulls validation in transitively, so the imports below will not resolve until you add the starter yourself.
Add this to your pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
The starter bundles both halves you need: the jakarta.validation API (the annotations themselves) and Hibernate Validator (the implementation that actually enforces them at runtime). Having only the API on the classpath is enough to compile but not to validate, so you want both. If you prefer to regenerate the project from Spring Initializr, ticking the Validation dependency does the same thing.
📁 src/main/java/com/graphqlguy/moviedb/person/CreatePersonInput.java
package com.graphqlguy.moviedb.person;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CreatePersonInput(
@NotBlank @Size(max = 200) String name,
@Min(1850) Integer birthYear,
@Size(min = 2, max = 100) String nationality
) {}
@NotBlank ensures name is not empty or whitespace-only. @Size(max = 200) caps it at a reasonable length. @Min(1850) on birthYear prevents obviously invalid entries like year 0 or negative years. You might wonder why there is no @Max annotation to prevent future birth years. Bean validation annotations accept only literal constants, so @Max(2026) would stop working the moment the calendar rolls over to 2027 and you would need a recompile to fix it. The actual rule, "birth year must not be in the future," depends on Year.now() evaluated at request time, which annotations cannot reach. That rule belongs in the service layer. Class 5's exercise will ask you to add it.
UpdatePersonInput
📁 src/main/java/com/graphqlguy/moviedb/person/UpdatePersonInput.java
package com.graphqlguy.moviedb.person;
import org.springframework.graphql.data.ArgumentValue;
public record UpdatePersonInput(
Long id,
ArgumentValue<String> name,
ArgumentValue<Integer> birthYear,
ArgumentValue<String> nationality
) {}
UpdatePersonInput uses ArgumentValue<T> for every optional field. Spring GraphQL's ArgumentValue is a wrapper that carries not just the value itself but also whether the field was present in the request at all. A field can be in three states: omitted from the request entirely, explicitly set to null, or set to a real value. Plain Java Optional<T> cannot represent all three, because omission and null both map to Optional.empty(). ArgumentValue distinguishes them with isOmitted(). When the service sees isOmitted() == true, it leaves the field alone on the entity. When it sees isOmitted() == false with a null value, it clears the field. This is how updatePerson(input: { id: "5", nationality: "French" }) changes only nationality while leaving name and birthYear exactly as they were. For fields declared non-null in the output schema (like Person.name: String!), the service rejects a present-with-null value rather than clearing it, as the service layer explains below.
DeleteMovieResponse and DeletePersonResponse
📁 src/main/java/com/graphqlguy/moviedb/shared/DeleteMovieResponse.java
package com.graphqlguy.moviedb.shared;
public record DeleteMovieResponse(boolean success, String message, Long deletedId) {}
📁 src/main/java/com/graphqlguy/moviedb/shared/DeletePersonResponse.java
package com.graphqlguy.moviedb.shared;
public record DeletePersonResponse(boolean success, String message, Long deletedId) {}
deletedId is typed as Long on the Java side, the boxed type rather than the primitive long, so it can hold null when nothing was deleted. On success the service passes the entity's id directly; on failure it passes null, which becomes a JSON null in the response and signals to the client that there is nothing to evict from its normalized cache. Although the Java field is a Long, the value still travels over the wire as a string, because GraphQL's ID type always serializes as a JSON string. graphql-java performs that Long-to-string coercion during serialization, so you never call String.valueOf yourself, and the response shows "deletedId": "24" with the value quoted even though the Java type is numeric. The same coercion runs in reverse on the input side, where deletePerson(id: ID!) arrives as a string and Spring's ConversionService binds it straight to @Argument Long id.
DeleteResponse?The two records carry the same three fields today: success, message, deletedId. It might seem cleaner to merge them into a single DeleteResponse type that both mutations share, and for projects where every delete will always carry the same shape, that is a defensible choice. We keep them separate because mutation response types tend to grow over time, and growth is much easier when each one can evolve independently.
Consider the kind of metadata each delete might want to expose later. DeleteMovieResponse.removedFromWatchlists: Int could tell the client how many user watchlists the movie was on, so a UI could show "Removed from 142 watchlists." DeletePersonResponse.affectedMovies: [ID!]! could list the movies that lost a director or cast member after the person was deleted. With separate types, each of these is an additive, non-breaking change: existing clients keep working, and new clients can opt in to the new field. With a shared DeleteResponse, you cannot add either field without forking the type back into two, which is a breaking change for every client that already queries the merged shape.
This is also the Relay convention that the chapter follows throughout: one response (or "payload" in Relay's vocabulary) type per mutation. The naming carries information, and the type identity gives clients and codegen tools a clean per-mutation handle.
Delete a Movie
We implement the operations in order of difficulty, simplest first. deleteMovie is the simplest: it takes a single ID, needs no input record, and returns a response type we already defined. Building it end to end, service then controller, sets the pattern that the person operations reuse.
Until now, the controller talked directly to the repository. Mutations need a service layer: business logic such as referential-integrity checks, validation beyond what annotations can express, and transactional boundaries all belong in one place, not scattered across controllers.
MovieService
📁 src/main/java/com/graphqlguy/moviedb/movie/MovieService.java
package com.graphqlguy.moviedb.movie;
import com.graphqlguy.moviedb.shared.DeleteMovieResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MovieService {
private final MovieRepository movieRepository;
public Optional<Movie> findById(Long id) {
return movieRepository.findById(id);
}
public List<Movie> findAll() {
return movieRepository.findAll();
}
public List<Movie> searchByTitle(String title) {
return movieRepository.findByTitleContainingIgnoreCase(title);
}
@Transactional
public DeleteMovieResponse deleteMovie(Long id) {
if (!movieRepository.existsById(id)) {
return new DeleteMovieResponse(false, "Movie not found: " + id, null);
}
movieRepository.deleteById(id);
return new DeleteMovieResponse(true, "Movie deleted successfully", id);
}
}
@Transactional(readOnly = true) at the class level tells Spring that all methods are read-only by default. Read-only transactions let the database skip some locking overhead, and JPA turns off dirty checking, which improves performance on every query path. The deleteMovie method overrides this with a plain @Transactional, which re-enables writes for that one method.
deleteMovie returns null for deletedId when the movie is not found, and the deleted id on success. Notice that it does not throw an exception when the record is missing. It returns a structured failure response instead.
HTTP DELETE is meant to be idempotent: deleting a resource twice produces the same end state as deleting it once. Our implementation honors that spirit. The first call removes the movie and returns success: true. A second call on the same ID finds nothing, returns success: false, and does not error. The client can retry a delete safely without worrying about receiving an exception on the second attempt. Class 5 will explore an alternative approach where the service throws an EntityNotFoundException instead, which makes more sense when the "not found" case is genuinely exceptional rather than an expected condition.
MovieController
With the service in place, the controller becomes a thin routing layer: it receives the argument, delegates to the service, and returns the result.
📁 src/main/java/com/graphqlguy/moviedb/movie/MovieController.java
package com.graphqlguy.moviedb.movie;
import com.graphqlguy.moviedb.person.Person;
import com.graphqlguy.moviedb.shared.DeleteMovieResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.MutationMapping;
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 MovieService movieService;
@QueryMapping
List<Movie> movies() {
return movieService.findAll();
}
@QueryMapping
Movie movie(@Argument Long id) {
return movieService.findById(id).orElse(null);
}
@QueryMapping
List<Movie> searchMovies(@Argument String title) {
return movieService.searchByTitle(title);
}
@SchemaMapping
List<Person> directors(Movie movie) {
return movie.getDirectors();
}
@SchemaMapping
List<MovieCast> cast(Movie movie) {
return movie.getCast();
}
@MutationMapping
DeleteMovieResponse deleteMovie(@Argument Long id) {
return movieService.deleteMovie(id);
}
}
@MutationMapping works identically to @QueryMapping. It maps a method to a schema field, but under the Mutation root type instead of Query. The method name matches the schema field name by default.
deleteMovie binds its id argument as @Argument Long id rather than @Argument String id. The next tip explains why that works even though GraphQL's ID type serializes as a string on the wire.
@Argument String idGraphQL's ID type serializes as a string on the wire, which might suggest you have to bind your argument as @Argument String id and parse it to Long yourself. You do not. Spring GraphQL's GraphQlArgumentBinder runs Spring's standard ConversionService over argument values, so binding directly to Long, Integer, or UUID works automatically as long as the incoming string is parseable into the target type. You only need @Argument String id when the IDs in your domain do not parse cleanly into any of those.
Class 2 already demonstrated this with movie(@Argument Long id). deleteMovie above uses the same pattern: @Argument Long id binds the incoming string ID directly to a Long, no manual parsing required, and deletePerson reuses it later.
Long IDs vs UUIDsSpring binds any of Long, Integer, or UUID for you (as the previous tip showed), which raises a natural follow-up: which should you actually declare in your domain? This chapter uses Long because the application is a single-database JPA monolith, the case where numeric IDs are at their best: more compact on the wire and in storage, faster to index, and free to allocate because the database generates them on insert.
UUIDs become the right choice in three specific situations, none of which apply here but all of which come up in production systems often enough to be worth knowing.
The first is optimistic mutations with stable IDs. With UUIDs, the client generates the ID locally before sending the create mutation, renders the new entity in its UI immediately, and uses that same ID in any follow-up operations. The cache key never has to change. With server-generated Long IDs, the client renders a placeholder, waits for the mutation response, and then swaps the placeholder for the assigned ID, which can cause UI flicker in lists or anywhere the entity is referenced.
The second is distributed ID generation. In a microservices architecture where several services may create entities of the same type, UUIDs let each service allocate IDs independently without coordinating, because UUID collisions are astronomically unlikely. Long IDs in this setting either require a shared sequence (a single point of contention) or a partitioning scheme that the application carries through every query.
The third is privacy. Sequential numeric IDs leak information about the size and growth rate of the system. A competitor seeing a customer ID of 100 versus 100000 learns roughly how many customers you have, and an attacker can enumerate IDs to probe for unauthorized access. UUIDs reveal nothing about the underlying record count or order.
If your system needs none of these three things, Long is the simpler, faster, smaller choice. If it needs any of them, the cost of switching later is real: every Long id field becomes UUID id on the Java side, every JPA generation strategy has to change, and any code that assumed monotonic ID ordering has to be revisited. Pick the type that fits your system's shape from the start.
Person Operations
The person side carries all four operations: read, create, update, and delete. We build them in the same read-before-write order the schema followed. The people query comes first, because once you can list people you can watch every later mutation take effect. Then InvalidInputException, which the update's validation throws; the repository helpers the delete check needs; and finally the create, update, and delete methods themselves.
Reading People
The read path is a two-line service method and a one-line controller mapping. Start with a PersonService that does nothing but list people. Its only dependency is PersonRepository.
📁 src/main/java/com/graphqlguy/moviedb/person/PersonService.java
package com.graphqlguy.moviedb.person;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PersonService {
private final PersonRepository personRepository;
public List<Person> findAll() {
return personRepository.findAll();
}
}
📁 src/main/java/com/graphqlguy/moviedb/person/PersonController.java
package com.graphqlguy.moviedb.person;
import lombok.RequiredArgsConstructor;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.stereotype.Controller;
import java.util.List;
@Controller
@RequiredArgsConstructor
public class PersonController {
private final PersonService personService;
@QueryMapping
List<Person> people() {
return personService.findAll();
}
}
@QueryMapping maps people() to the people field on the Query root, exactly as the movie queries map in MovieController. Restart the app and run { people { id name } } in GraphiQL: you should see whoever the Class 3 data initializer seeded. That list is the instrument we use to confirm the writes below.
InvalidInputException
Before writing the create, update, and delete methods, define a small exception class that service-layer rules can throw when they detect an invalid input. Class 5 will wire this to a global exception handler that maps it to a structured GraphQL error.
📁 src/main/java/com/graphqlguy/moviedb/exception/InvalidInputException.java
package com.graphqlguy.moviedb.exception;
import lombok.Getter;
@Getter
public class InvalidInputException extends RuntimeException {
private final String field;
public InvalidInputException(String field, String message) {
super(message);
this.field = field;
}
}
Repository Method Additions
The PersonService write path checks whether a person is referenced as a director or cast member before allowing a delete. Two repository methods support this check. Neither exists yet in the tutorial's codebase, so add them now.
📁 src/main/java/com/graphqlguy/moviedb/movie/MovieRepository.java (add one line)
boolean existsByDirectorsContaining(Person person);
📁 src/main/java/com/graphqlguy/moviedb/movie/MovieCastRepository.java (add one line)
boolean existsByPerson(Person person);
Both are Spring Data derived query methods: Spring reads the method name and generates the SQL automatically. This is the same mechanism that powered findByTitleContainingIgnoreCase in Class 3. existsByDirectorsContaining translates to a join between Movie and its directors collection and checks whether the given Person appears. existsByPerson does the same against MovieCast.
PersonService: Create, Update, Delete
Now grow the service. The complete PersonService keeps the findAll method from above and adds the three write methods, plus two new dependencies, MovieRepository and MovieCastRepository, that the delete check needs.
📁 src/main/java/com/graphqlguy/moviedb/person/PersonService.java
package com.graphqlguy.moviedb.person;
import com.graphqlguy.moviedb.exception.InvalidInputException;
import com.graphqlguy.moviedb.movie.MovieCastRepository;
import com.graphqlguy.moviedb.movie.MovieRepository;
import com.graphqlguy.moviedb.shared.DeletePersonResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.graphql.data.ArgumentValue;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PersonService {
private final PersonRepository personRepository;
private final MovieRepository movieRepository;
private final MovieCastRepository movieCastRepository;
public List<Person> findAll() {
return personRepository.findAll();
}
@Transactional
public Person createPerson(CreatePersonInput input) {
return personRepository.save(
Person.builder()
.name(input.name())
.birthYear(input.birthYear())
.nationality(input.nationality())
.build()
);
}
@Transactional
public Person updatePerson(UpdatePersonInput input) {
// Load the target first. Class 4 has no not-found type yet, so we throw a plain
// RuntimeException; Class 5 adds EntityNotFoundException to replace it.
Person person = personRepository.findById(input.id())
.orElseThrow(() -> new RuntimeException("Person not found: " + input.id()));
validate(input);
applyIfPresent(input.name(), person::setName);
applyIfPresent(input.birthYear(), person::setBirthYear);
applyIfPresent(input.nationality(), person::setNationality);
return personRepository.save(person);
}
@Transactional
public DeletePersonResponse deletePerson(Long id) {
Optional<Person> person = personRepository.findById(id);
if (person.isEmpty()) {
return new DeletePersonResponse(false, "Person not found: " + id, null);
}
// Being linked to a movie is an expected state, not invalid input, so we report a
// structured failure response rather than throwing an exception.
if (movieRepository.existsByDirectorsContaining(person.get())
|| movieCastRepository.existsByPerson(person.get())) {
return new DeletePersonResponse(false, "Person is linked to a movie", null);
}
personRepository.deleteById(id);
return new DeletePersonResponse(true, "Person deleted successfully", id);
}
// ArgumentValue<T> fields are invisible to Bean validation, so the partial update
// re-expresses CreatePersonInput's constraints here, checking only the fields the
// client actually sent.
private void validate(UpdatePersonInput input) {
if (!input.name().isOmitted()) {
String name = input.name().value();
// Person.name is String! in the output schema, so it cannot be cleared.
if (name == null) {
throw new InvalidInputException("name", "Name cannot be cleared");
}
if (name.isBlank()) {
throw new InvalidInputException("name", "Name must not be blank");
}
if (name.length() > 200) {
throw new InvalidInputException("name", "Name must be at most 200 characters");
}
}
if (!input.birthYear().isOmitted()) {
Integer birthYear = input.birthYear().value();
if (birthYear != null && birthYear < 1850) {
throw new InvalidInputException("birthYear", "Birth year must be 1850 or later");
}
}
if (!input.nationality().isOmitted()) {
String nationality = input.nationality().value();
if (nationality != null && (nationality.length() < 2 || nationality.length() > 100)) {
throw new InvalidInputException("nationality", "Nationality must be between 2 and 100 characters");
}
}
}
private <T> void applyIfPresent(ArgumentValue<T> arg, Consumer<T> setter) {
if (!arg.isOmitted()) {
setter.accept(arg.value());
}
}
}
createPerson receives an already-validated CreatePersonInput. Bean validation fires at the controller call site (the controller below shows @Valid on the controller parameter), so by the time this service method is invoked the input constraints have already been checked.
updatePerson does not carry @Valid. Bean validation cannot meaningfully evaluate ArgumentValue<T> constraints because the wrapper type is not a primitive or a string. Annotations like @NotBlank would be placed on the inner type, but they cannot inspect the "was this field omitted entirely?" state that ArgumentValue tracks. So updatePerson does by hand the three things the create path got for free from annotations.
First, it loads the target with findById and throws an exception if no person has that id. createPerson never needs this because it is making a new row, but an update has to confirm the row exists before it can change it. Class 4 has no not-found exception type yet, so we throw a plain RuntimeException; Class 5 introduces EntityNotFoundException and a handler that turns it into a clean NOT_FOUND error, which you would use here in place of the raw exception.
Second, the private validate method re-expresses the same constraints CreatePersonInput declared with annotations (@NotBlank, @Size(max = 200), @Min(1850), @Size(min = 2, max = 100)), but only for the fields the client actually sent. This is the answer to "how do you validate a partial update when Bean validation is off the table": you read the unwrapped value of each present field in the service and throw InvalidInputException with the offending field name. Omitted fields are skipped, so a client that only sends nationality is never bothered about name or birthYear.
Third, the present-with-null case for name is rejected outright, because Person.name is declared String! in the output schema and clearing it would make every subsequent read fail. Omitting name leaves it untouched; sending name: null is the error.
deletePerson refuses the delete when the person is still referenced in any movie, whether as a director or a cast member. This is a service-layer rule because no schema annotation or Bean validation constraint can express a cross-entity relationship check. Notice how it communicates that refusal: instead of throwing an exception, it returns DeletePersonResponse(false, "Person is linked to a movie", null), the same structured-failure shape as the not-found branch above it. A person who is linked to a movie is an expected, recoverable condition the client should handle in normal flow, not malformed input, so a failure response fits better than an exception. That is a deliberate contrast with updatePerson, which throws an exception for genuinely bad input. Class 5 revisits whether a case like this is eventually better served by a dedicated exception.
When deleting an entity that other entities reference, there are three common policies.
Block (what we implement here): refuse the delete and report a failure (success: false) if any reference exists. This is the safest default because it prevents orphaned references without making assumptions about what the caller wants to do with them. It forces the caller to clean up references explicitly.
Cascade-delete: delete the parent and all referencing children automatically. Appropriate when children have no meaning without the parent, for example deleting a user and all their reviews at once. In JPA, this is cascade = CascadeType.REMOVE on the relationship.
Cascade-remove from join: remove only the join-table rows that point to the parent, leaving the children intact. Appropriate when the children can exist independently and the delete just severs the connection. In JPA this is orphanRemoval = true on a collection.
We pick block because directors and cast members are independent entities. Quentin Tarantino's person record should not disappear from the database just because we deleted one movie he directed. The caller should decide what to do with those references before deleting the person.
PersonController: the Mutations
With the write methods in place, extend the controller. The complete PersonController keeps the people query and adds one mapping per mutation.
📁 src/main/java/com/graphqlguy/moviedb/person/PersonController.java
package com.graphqlguy.moviedb.person;
import com.graphqlguy.moviedb.shared.DeletePersonResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.MutationMapping;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.stereotype.Controller;
import jakarta.validation.Valid;
import java.util.List;
@Controller
@RequiredArgsConstructor
public class PersonController {
private final PersonService personService;
@QueryMapping
List<Person> people() {
return personService.findAll();
}
@MutationMapping
Person createPerson(@Argument @Valid CreatePersonInput input) {
return personService.createPerson(input);
}
@MutationMapping
Person updatePerson(@Argument UpdatePersonInput input) {
return personService.updatePerson(input);
}
@MutationMapping
DeletePersonResponse deletePerson(@Argument Long id) {
return personService.deletePerson(id);
}
}
@Valid on the createPerson parameter is what enables Bean validation. When Spring GraphQL prepares to call this method, it runs all the annotations on CreatePersonInput first. If any constraint fires, the method is never invoked and Spring returns a BAD_REQUEST error automatically.
updatePerson omits @Valid because Bean validation cannot meaningfully evaluate ArgumentValue<T> constraints. The wrapper type tracks the omitted vs. present-with-null distinction that we rely on for partial updates, but annotation processors cannot inspect that state. The update's invariants are enforced in PersonService.updatePerson instead.
deletePerson binds its id as @Argument Long id, the same ID coercion shown for deleteMovie above. The incoming string ID binds straight to a Long with no manual parsing.
The Three Validation Layers
Validation in Spring GraphQL happens in three distinct places, and most tutorials conflate them. Knowing which layer is responsible for which kind of constraint is the difference between code that fails predictably and code that fails everywhere at once.
The first layer is schema-level validation, which graphql-java runs before any Java code touches the request. It rejects type mismatches, missing required fields, and unknown field names outright. The second layer is Bean validation, which fires after Spring GraphQL has deserialized the input into your Java record. Annotations like @NotBlank and @Min express constraints that apply to individual field values. The third layer is service-layer validation, which is the only place where constraints involving runtime state, cross-field relationships, or domain invariants can live. Together the three layers form a graduated defense: the outer two layers catch structural problems cheaply, the inner layer catches semantic problems where real knowledge is required.
The request lifecycle through the validation layers:
Each failure branch is a halt point. A request that fails at schema validation never reaches argument binding. A request that fails Bean validation never reaches the resolver method. Failures at outer layers are therefore cheaper and easier to reason about.
Schema-Level Validation
Schema validation catches type mismatches before any Java code runs. Sending an integer where the schema expects a string is enough to trigger it. To see this, send:
mutation {
createPerson(input: { name: 123 }) {
id
}
}
graphql-java rejects this immediately and returns a response with data: null and a ValidationError classification:
{
"errors": [
{
"message": "Validation error (WrongType@[createPerson/input/name]) : Expected a value of type 'String' but it was 'IntValue'",
"locations": [{ "line": 2, "column": 31 }],
"extensions": {
"classification": "ValidationError"
}
}
],
"data": null
}
Notice that data is null rather than an empty object. This happens because schema validation runs before Spring or your service have had any chance to execute. No partial result is possible.
Bean Validation
Bean validation fires after the input has been deserialized into the Java record, before the resolver method is called. The @NotBlank annotation on CreatePersonInput.name means that an empty string is a legal value in the GraphQL type system (it is still a String) but illegal as far as the Java record is concerned. To trigger it, send:
mutation {
createPerson(input: { name: "" }) {
id
}
}
Spring GraphQL catches the resulting ConstraintViolationException and surfaces it as a BAD_REQUEST error automatically. You do not need a custom exception handler for this case: the @Valid annotation on the @Argument parameter is all that is required.
{
"errors": [
{
"message": "Validation error for object='createPersonInput', field='name': must not be blank",
"extensions": {
"classification": "BAD_REQUEST"
}
}
],
"data": null
}
Bean validation also supports class-level constraints for rules that span multiple fields, via custom @Constraint annotations or @ScriptAssert. Our domain does not need this today, but it is the natural Bean-layer answer when cross-field rules arise.
Service-Layer Validation
Service-layer validation is where everything graphql-java and Bean validation cannot express must live. Two examples illustrate why this layer is necessary.
Present-with-null rejection. The UpdatePersonInput schema makes name nullable so that clients can omit it in a partial update. But the output type declares Person.name: String!, meaning the entity cannot legally have a null name. Bean validation cannot catch this case because @NotNull on an ArgumentValue<String> field would reject the omitted case too, breaking the intentional three-state semantics. The service enforces it instead. To trigger the guard, send name: null explicitly (not simply omitted):
mutation {
updatePerson(input: { id: "5", name: null }) {
id
name
}
}
The service sees input.name().isOmitted() as false (the field was provided) and input.name().value() as null (the value is null), so it throws an exception rather than writing a null name to the entity:
{
"errors": [
{
"message": "Name cannot be cleared",
"extensions": {
"classification": "BAD_REQUEST",
"field": "name"
}
}
],
"data": null
}
This is the constraint the type system and annotations together cannot reach. The schema says the input field is optional; the service says clearing it is not allowed. The gap between those two statements is where service-layer validation lives.
Out-of-range update rejection. The create path gets its range and length checks from the Bean validation annotations on CreatePersonInput. The update path cannot, because its fields are ArgumentValue<T> wrappers that annotations cannot see, so updatePerson re-expresses the same constraints by hand for the fields the client actually sent. Send a birth year the create path would have rejected:
mutation {
updatePerson(input: { id: "24", birthYear: 1 }) {
id
birthYear
}
}
The service sees birthYear is present and below the 1850 floor, so it throws an exception before touching the database:
{
"errors": [
{
"message": "Birth year must be 1850 or later",
"extensions": {
"classification": "BAD_REQUEST",
"field": "birthYear"
}
}
],
"data": null
}
This is the service-layer counterpart to create's Bean validation: the same rule, enforced in Java because the wrapper puts it out of annotation reach.
Not every service-layer rule throws an exception, though. deletePerson refuses to remove a person who is still linked to a movie, but it returns DeletePersonResponse(success: false, message: "Person is linked to a movie", deletedId: null) rather than raising an error, because a linked person is an expected condition the client should handle, not malformed input. No schema rule and no annotation can express "this id refers to a person other rows still point at"; that knowledge lives in the database and the service that queries it. Class 5 revisits when a failure like this is better modeled as a dedicated exception.
Where Does Each Constraint Go?
A small lookup for which layer handles which kind of constraint:
| Constraint type | Layer | Example |
|---|---|---|
| Type or presence | Schema | birthYear must be Int, not String |
| Format, range, length | Bean | @NotBlank, @Min(1850), @Size(max=200) |
| Business rules | Service | Birth year must not be in the future; cannot delete a referenced person |
When a non-null mutation field throws an exception (for example, deleteMovie: DeleteMovieResponse!), the null propagates all the way to the response root. Class 5 looks at this propagation in detail.
Run and Test
Restart your application and open GraphiQL at http://localhost:8080/graphiql. The demos below walk through the full Person CRUD cycle, using the people query to confirm each change actually landed, then close with a Movie delete to show the same response pattern works on that side too.
List All People
Start with the read we added first. It is the instrument every later step relies on, so run it before changing anything to see the people the Class 3 data initializer seeded:
query {
people {
id
name
birthYear
nationality
}
}
Note the IDs and names in the response. We will watch this list grow when we create a person and shrink when we delete one. Without it, you would have no way to confirm a write did what it claimed.
Create a Person
Start by creating Quentin Tarantino. We will use this record throughout the remaining demos.
mutation {
createPerson(input: {
name: "Quentin Tarantino"
birthYear: 1963
nationality: "American"
}) {
id
name
birthYear
nationality
}
}
Response:
{
"data": {
"createPerson": {
"id": "24",
"name": "Quentin Tarantino",
"birthYear": 1963,
"nationality": "American"
}
}
}
createPerson returns Person! directly, so you can request any combination of Person fields in the selection set. The response includes exactly the fields you asked for and nothing more. Now run the people query again: Quentin Tarantino appears in the list with the id you just got back. That list is how you confirm a create actually persisted rather than just echoing your input.
Update the Person (Partial Update)
Now update only the nationality. Every other field stays exactly as it was:
mutation {
updatePerson(input: {
id: "24"
nationality: "Irish"
}) {
id
name
birthYear
nationality
}
}
Response:
{
"data": {
"updatePerson": {
"id": "24",
"name": "Quentin Tarantino",
"birthYear": 1963,
"nationality": "Irish"
}
}
}
name and birthYear are absent from the input entirely. Because they are ArgumentValue<T> fields, the service reads them as omitted and skips the setter calls. Only nationality changes.
Clear a Field (Explicit Null)
What if we want to remove the nationality entirely? Omitting the field from the input would leave it unchanged. We have to send null explicitly:
mutation {
updatePerson(input: {
id: "24"
nationality: null
}) {
id
name
nationality
}
}
Response:
{
"data": {
"updatePerson": {
"id": "24",
"name": "Quentin Tarantino",
"nationality": null
}
}
}
This is the payoff of using ArgumentValue<T>. Three inputs that look similar on the wire carry three different meanings:
| Request shape | Server interpretation |
|---|---|
{ id: "24", nationality: "American" } | Set nationality to "American" |
{ id: "24" } | Leave nationality unchanged |
{ id: "24", nationality: null } | Clear nationality |
Without ArgumentValue, the second and third rows would be indistinguishable on the server. Note that attempting to clear name this way, with name: null, triggers the service-layer rule we wrote in PersonService and returns an error, because Person.name: String! is non-nullable in the output schema.
Delete the Person (Success Case)
mutation {
deletePerson(id: "24") {
success
message
deletedId
}
}
Response:
{
"data": {
"deletePerson": {
"success": true,
"message": "Person deleted successfully",
"deletedId": "24"
}
}
}
deletedId contains the ID of the record that was removed. Apollo Client and urql use this field to evict the entry from their normalized caches without making a follow-up query. If you are not using a caching client today, the field is harmless; if you add one later, the hook is already there. Run the people query one more time and Quentin Tarantino is gone from the list, which is the read-side confirmation that the delete took effect.
Delete a Non-Existent Person (Failure Case)
mutation {
deletePerson(id: "24") {
success
message
deletedId
}
}
Response (running the same mutation a second time, after the person no longer exists):
{
"data": {
"deletePerson": {
"success": false,
"message": "Person not found: 24",
"deletedId": null
}
}
}
The mutation does not fail. It returns a structured response with success: false and deletedId: null. This is delete's idempotency at work: deleting twice produces the same end state, and the second call communicates clearly what happened without throwing an error.
Delete a Movie (Same Pattern)
For completeness, here is deleteMovie using the same response shape:
mutation {
deleteMovie(id: "1") {
success
message
deletedId
}
}
Response:
{
"data": {
"deleteMovie": {
"success": true,
"message": "Movie deleted successfully",
"deletedId": "1"
}
}
}
DeleteMovieResponse and DeletePersonResponse are separate types with identical shapes. In Class 5 we will revisit deleteMovie and refactor it to throw an Exception rather than return a failure response, demonstrating when each pattern is the better fit.
Project Structure So Far
moviedb/
├── src/main/java/com/graphqlguy/moviedb/
│ ├── MoviedbApplication.java
│ ├── config/
│ │ └── DataInitializer.java
│ ├── exception/
│ │ └── InvalidInputException.java ← NEW
│ ├── movie/
│ │ ├── Movie.java
│ │ ├── MovieCast.java
│ │ ├── MovieCastRepository.java
│ │ ├── MovieController.java
│ │ ├── MovieRepository.java
│ │ └── MovieService.java ← NEW
│ ├── person/
│ │ ├── Person.java
│ │ ├── PersonController.java ← NEW
│ │ ├── PersonRepository.java
│ │ ├── PersonService.java ← NEW
│ │ ├── CreatePersonInput.java ← NEW
│ │ └── UpdatePersonInput.java ← NEW
│ └── shared/
│ ├── DeleteMovieResponse.java ← NEW
│ ├── DeletePersonResponse.java ← NEW
│ └── Genre.java
├── src/main/resources/
│ ├── application.yaml
│ └── graphql/
│ └── schema.graphqls
└── pom.xml
Exercises
Exercise 1: Add a Cross-Field Constraint to CreatePersonInput
CreatePersonInput currently validates each field in isolation: @NotBlank checks that name is not empty or whitespace, @Min(1850) checks that birthYear is plausible. Bean validation also supports class-level constraints for rules that span multiple fields.
Add an @AssertTrue method to the CreatePersonInput record that returns false when the name consists entirely of whitespace characters. The method must be annotated with @AssertTrue and named is... (by convention). It can access all fields in the record.
This is one way to express a single-field rule that @NotBlank would already cover, but the exercise is to practice the class-level pattern for when you need a rule that genuinely spans two fields, for example: "if nationality is provided, birthYear must also be provided."
Solution
CreatePersonInput.java: add the method to the existing record:
package com.graphqlguy.moviedb.person;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CreatePersonInput(
@NotBlank @Size(max = 200) String name,
@Min(1850) Integer birthYear,
@Size(min = 2, max = 100) String nationality
) {
@AssertTrue(message = "name must not be blank or whitespace only")
public boolean isNameNotWhitespaceOnly() {
return name != null && !name.isBlank();
}
}
Note that @NotBlank already covers this case for the blank string. The value of this exercise is recognizing the class-level @AssertTrue pattern: you annotate an is...() method on the record, and Bean validation calls it as part of the same @Valid pass. For a rule that genuinely spans two fields, you would read both fields inside the method body.
Test it with:
mutation {
createPerson(input: {
name: " "
birthYear: 1963
}) {
id
}
}
The response includes a BAD_REQUEST error from the isNameNotWhitespaceOnly constraint, caught before the resolver method is ever called.
Exercise 2: Mutation Variables
In GraphiQL, use query variables instead of inline values. This is how real clients send mutations because it separates the query structure from the data, which prevents injection and allows the client to cache the query shape.
mutation CreatePerson($input: CreatePersonInput!) {
createPerson(input: $input) {
id
name
birthYear
nationality
}
}
In the Variables panel (bottom-left of GraphiQL):
{
"input": {
"name": "Quentin Tarantino",
"birthYear": 1963,
"nationality": "American"
}
}
Run it. Then change the variable JSON to use a different name without touching the query. Notice that the query document is reusable: only the data changes.
Exercise 3: Observe deletedId and Idempotency
This exercise makes the delete response concrete.
Create a new Person and note the id in the response. Then delete that Person:
mutation {
deletePerson(id: "YOUR_ID_HERE") {
success
message
deletedId
}
}
Observe that deletedId in the response matches the id you noted. Now run the exact same mutation a second time without changing anything. What is the response? What is deletedId this time?
The second delete returns success: false and deletedId: null. The person is gone either way. This is delete's idempotency: the end state is identical regardless of how many times you call it, and the response communicates clearly what actually happened on this particular call.
Common Issues
Issue: jakarta.validation imports won't resolve
IntelliJ or the compiler reports that jakarta.validation.constraints.NotBlank, @Size, @Min, or jakarta.validation.Valid cannot be found, often shown as a "missing dependency" or "cannot resolve symbol" error. The project generated in Class 1 does not include Bean Validation, and since Spring Boot 2.3 the spring-boot-starter-web starter no longer brings it in transitively. Add spring-boot-starter-validation to your pom.xml (see the callout in the CreatePersonInput section above), then reload the Maven project so the IDE picks up the new dependency. The starter supplies both the jakarta.validation annotation API and the Hibernate Validator implementation that enforces the constraints at runtime, so adding it resolves the imports and makes @Valid actually take effect.
Issue: "Variable input has an invalid value"
GraphiQL shows a variable type mismatch. Make sure your variable JSON matches the input type exactly. birthYear must be a number, not a string. nationality is optional but, if provided, must be a string. GraphQL validates the variable types before the request reaches your Java code; mismatches are caught at the schema-validation layer, not the service layer.
Issue: Update did not change anything
updatePerson returns the person with values identical to before the mutation. The most common cause is that all the fields you intended to update were omitted from the input, meaning ArgumentValue treated them as "not present" and skipped the setter calls. For example, { "input": { "id": "24" } } changes nothing, because name, birthYear, and nationality are all omitted. To change a field, include it explicitly: { "input": { "id": "24", "nationality": "American" } }. To clear a nullable field, pass it as null: { "input": { "id": "24", "nationality": null } }. To verify which fields the service actually applied, add a breakpoint in applyIfPresent and watch which setter calls fire.
Issue: Clearing name returns an error
Sending updatePerson(input: { id: "24", name: null }) triggers a BAD_REQUEST error with message "Name cannot be cleared." This is intentional. The output schema declares Person.name: String! as non-nullable, so storing null in the database would cause every subsequent read to fail with a null-propagation error. The service-layer rule in updatePerson catches this case and rejects it before the database write, which is exactly the kind of constraint that belongs at the service layer rather than the schema or Bean validation layer.
Summary
- Mutations vs. queries: mutations signal write intent and execute sequentially; queries signal read intent and can run in parallel.
- Input types and response types: input types bundle arguments into clean, typed objects; response types wrap return data with context. We use response types for delete mutations (where
success,message, anddeletedIdare all meaningful) and return the entity directly for create and update. - Partial updates with
ArgumentValue<T>: the three states (omitted, present-with-value, present-with-null) are distinguishable only throughArgumentValue. Without it, omitted and null are the same. @MutationMapping: works exactly like@QueryMappingbut maps to theMutationroot type. The argument binding, validation, andConversionServicebehavior are identical.- Three validation layers: schema catches type and presence errors before any Java runs; Bean validation catches format, range, and length errors after deserialization; service-layer rules catch business constraints that the other two layers cannot express.
- Service layers with
@Transactional:@Transactional(readOnly = true)at the class level optimizes reads, with individual write methods overriding to@Transactional.
What's Next?
In Class 5: Error Handling, we build on what we have. The chapter covers custom exceptions and how Spring GraphQL maps them to structured errors, the difference between returning a failure response and throwing an exception, and error classifications that clients can use to decide how to react. The concrete refactor targets are two of the deletes: we change deleteMovie from returning success: false to throwing an EntityNotFoundException, and we evolve deletePerson so its free-text message becomes a typed error enum the client can switch on - working through exactly when each pattern is the right choice.