Skip to main content

Mastering Mutations in Spring GraphQL - CRUD Operations Done Right

· 7 min read
GraphQL Guy

GraphQL Mutations

Queries fetch data. Mutations change it. Learn how to implement create, update, and delete operations in Spring GraphQL with proper validation and error handling.

Understanding Mutations

In GraphQL, mutations are operations that modify server-side data. While queries are read-only, mutations can:

  • Create new records
  • Update existing records
  • Delete records
  • Trigger side effects (sending emails, notifications, etc.)

By convention, mutations are named with action verbs: createBook, updateAuthor, deleteReview.

Defining Mutations in Your Schema

Let's extend our book library schema with mutations:

type Query {
books: [Book!]!
bookById(id: ID!): Book
}

type Mutation {
createBook(input: CreateBookInput!): Book!
updateBook(id: ID!, input: UpdateBookInput!): Book!
deleteBook(id: ID!): DeleteResult!
}

input CreateBookInput {
title: String!
authorId: ID!
publishedYear: Int
genre: String
isbn: String
}

input UpdateBookInput {
title: String
publishedYear: Int
genre: String
}

type DeleteResult {
success: Boolean!
message: String
}

type Book {
id: ID!
title: String!
author: Author!
publishedYear: Int
genre: String
isbn: String
createdAt: String
updatedAt: String
}

type Author {
id: ID!
name: String!
books: [Book!]!
}

Why Use Input Types?

Notice we use CreateBookInput instead of individual arguments. This is a GraphQL best practice:

  1. Cleaner signatures - One input parameter vs. many arguments
  2. Easier evolution - Add fields without breaking clients
  3. Better documentation - Input types are self-documenting
  4. Validation grouping - Validate the entire input object

Implementing Mutations in Spring

The Controller

@Controller
public class BookMutationController {

private final BookService bookService;

public BookMutationController(BookService bookService) {
this.bookService = bookService;
}

@MutationMapping
public Book createBook(@Argument CreateBookInput input) {
return bookService.createBook(input);
}

@MutationMapping
public Book updateBook(@Argument String id,
@Argument UpdateBookInput input) {
return bookService.updateBook(id, input);
}

@MutationMapping
public DeleteResult deleteBook(@Argument String id) {
return bookService.deleteBook(id);
}
}

Input Classes

Create Java records matching your GraphQL input types:

public record CreateBookInput(
String title,
String authorId,
Integer publishedYear,
String genre,
String isbn
) {}

public record UpdateBookInput(
String title,
Integer publishedYear,
String genre
) {}

public record DeleteResult(
boolean success,
String message
) {}

The Service Layer

@Service
@Transactional
public class BookService {

private final BookRepository bookRepository;
private final AuthorRepository authorRepository;

public BookService(BookRepository bookRepository,
AuthorRepository authorRepository) {
this.bookRepository = bookRepository;
this.authorRepository = authorRepository;
}

public Book createBook(CreateBookInput input) {
// Validate author exists
authorRepository.findById(input.authorId())
.orElseThrow(() -> new AuthorNotFoundException(input.authorId()));

// Validate ISBN uniqueness
if (input.isbn() != null && bookRepository.existsByIsbn(input.isbn())) {
throw new DuplicateIsbnException(input.isbn());
}

Book book = new Book(
UUID.randomUUID().toString(),
input.title(),
input.authorId(),
input.publishedYear(),
input.genre(),
input.isbn(),
Instant.now(),
Instant.now()
);

return bookRepository.save(book);
}

public Book updateBook(String id, UpdateBookInput input) {
Book existing = bookRepository.findById(id)
.orElseThrow(() -> new BookNotFoundException(id));

Book updated = new Book(
existing.id(),
input.title() != null ? input.title() : existing.title(),
existing.authorId(),
input.publishedYear() != null ? input.publishedYear() : existing.publishedYear(),
input.genre() != null ? input.genre() : existing.genre(),
existing.isbn(),
existing.createdAt(),
Instant.now()
);

return bookRepository.save(updated);
}

public DeleteResult deleteBook(String id) {
if (!bookRepository.existsById(id)) {
return new DeleteResult(false, "Book not found with id: " + id);
}

bookRepository.deleteById(id);
return new DeleteResult(true, "Book deleted successfully");
}
}

Input Validation

Spring GraphQL integrates with Bean Validation. Add validation annotations to your input classes:

public record CreateBookInput(
@NotBlank(message = "Title is required")
@Size(max = 200, message = "Title must be less than 200 characters")
String title,

@NotBlank(message = "Author ID is required")
String authorId,

@Min(value = 1000, message = "Published year must be after 1000")
@Max(value = 2100, message = "Published year must be before 2100")
Integer publishedYear,

@Size(max = 50, message = "Genre must be less than 50 characters")
String genre,

@Pattern(regexp = "^(?:ISBN(?:-13)?:? )?(?=[0-9]{13}$|(?=(?:[0-9]+[- ]){4})[- 0-9]{17}$).*$",
message = "Invalid ISBN format")
String isbn
) {}

Enable validation in your controller:

@MutationMapping
public Book createBook(@Argument @Valid CreateBookInput input) {
return bookService.createBook(input);
}

Custom Validation

For complex validations, create custom validators:

@Constraint(validatedBy = UniqueIsbnValidator.class)
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface UniqueIsbn {
String message() default "ISBN already exists";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

public class UniqueIsbnValidator implements ConstraintValidator<UniqueIsbn, String> {

private final BookRepository bookRepository;

public UniqueIsbnValidator(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}

@Override
public boolean isValid(String isbn, ConstraintValidatorContext context) {
if (isbn == null) return true;
return !bookRepository.existsByIsbn(isbn);
}
}

Handling Partial Updates

In UpdateBookInput, all fields are optional. The client only sends fields they want to change. Here's a pattern for handling partial updates cleanly:

public Book updateBook(String id, UpdateBookInput input) {
Book existing = bookRepository.findById(id)
.orElseThrow(() -> new BookNotFoundException(id));

Book.Builder builder = existing.toBuilder()
.updatedAt(Instant.now());

// Only update fields that are provided
Optional.ofNullable(input.title()).ifPresent(builder::title);
Optional.ofNullable(input.publishedYear()).ifPresent(builder::publishedYear);
Optional.ofNullable(input.genre()).ifPresent(builder::genre);

return bookRepository.save(builder.build());
}

Or use a dedicated mapper:

@Component
public class BookMapper {

public Book applyUpdate(Book existing, UpdateBookInput input) {
return new Book(
existing.id(),
coalesce(input.title(), existing.title()),
existing.authorId(),
coalesce(input.publishedYear(), existing.publishedYear()),
coalesce(input.genre(), existing.genre()),
existing.isbn(),
existing.createdAt(),
Instant.now()
);
}

private <T> T coalesce(T newValue, T existingValue) {
return newValue != null ? newValue : existingValue;
}
}

Returning Created/Updated Objects

A mutation should return the affected object so clients can update their cache:

mutation {
createBook(input: {
title: "The Pragmatic Programmer"
authorId: "author-123"
publishedYear: 1999
genre: "Technology"
}) {
id # Get the generated ID
title
createdAt # Get server-set timestamps
author {
name # Can even traverse relationships
}
}
}

This single request creates the book AND fetches all needed data for the UI.

Batch Mutations

Sometimes you need to create or update multiple items:

type Mutation {
createBooks(inputs: [CreateBookInput!]!): BatchCreateResult!
}

type BatchCreateResult {
books: [Book!]!
errors: [BatchError!]!
}

type BatchError {
index: Int!
message: String!
}

Implementation:

@MutationMapping
public BatchCreateResult createBooks(@Argument List<CreateBookInput> inputs) {
List<Book> created = new ArrayList<>();
List<BatchError> errors = new ArrayList<>();

for (int i = 0; i < inputs.size(); i++) {
try {
Book book = bookService.createBook(inputs.get(i));
created.add(book);
} catch (Exception e) {
errors.add(new BatchError(i, e.getMessage()));
}
}

return new BatchCreateResult(created, errors);
}

Testing Mutations

Use Spring's GraphQlTester for testing:

@SpringBootTest
@AutoConfigureGraphQlTester
class BookMutationControllerTest {

@Autowired
private GraphQlTester graphQlTester;

@Test
void shouldCreateBook() {
graphQlTester.document("""
mutation {
createBook(input: {
title: "Test Book"
authorId: "1"
publishedYear: 2024
}) {
id
title
publishedYear
}
}
""")
.execute()
.path("createBook.title").entity(String.class).isEqualTo("Test Book")
.path("createBook.publishedYear").entity(Integer.class).isEqualTo(2024)
.path("createBook.id").entity(String.class).matches(s -> !s.isEmpty());
}

@Test
void shouldRejectInvalidInput() {
graphQlTester.document("""
mutation {
createBook(input: {
title: ""
authorId: "1"
}) {
id
}
}
""")
.execute()
.errors()
.expect(error -> error.getMessage().contains("Title is required"));
}
}

Mutation Best Practices

1. Use Verbs for Naming

# Good
createBook, updateBook, deleteBook, publishBook, archiveBook

# Bad
book, newBook, bookMutation

2. Return Affected Objects

Always return the created/updated object so clients can update their state.

3. Make Mutations Idempotent When Possible

If a mutation is called twice with the same input, the result should be the same:

public Book createOrUpdateBook(String isbn, CreateBookInput input) {
return bookRepository.findByIsbn(isbn)
.map(existing -> updateExisting(existing, input))
.orElseGet(() -> createNew(input));
}

4. Use Transactions

Wrap mutations in transactions to ensure atomicity:

@Service
@Transactional // All public methods are transactional
public class BookService {
// ...
}

5. Validate Early, Fail Fast

Check business rules before making changes:

public Book createBook(CreateBookInput input) {
// Validate first
validateAuthorExists(input.authorId());
validateIsbnUnique(input.isbn());
validateQuota(getCurrentUser());

// Then create
return bookRepository.save(mapToBook(input));
}

Complete Mutation Flow

Here's the complete flow of a mutation request:

┌──────────────────────────────────────────────────────────────────┐
│ Client Request │
│ mutation { createBook(input: {...}) { id title } } │
└────────────────────────────┬─────────────────────────────────────┘


┌──────────────────────────────────────────────────────────────────┐
│ Spring GraphQL │
│ 1. Parse mutation │
│ 2. Validate against schema │
│ 3. Call @MutationMapping method │
└────────────────────────────┬─────────────────────────────────────┘


┌──────────────────────────────────────────────────────────────────┐
│ Controller Layer │
│ 1. Bean validation (@Valid) │
│ 2. Delegate to service │
└────────────────────────────┬─────────────────────────────────────┘


┌──────────────────────────────────────────────────────────────────┐
│ Service Layer │
│ 1. Business validation │
│ 2. Database operations (transactional) │
│ 3. Return created entity │
└────────────────────────────┬─────────────────────────────────────┘


┌──────────────────────────────────────────────────────────────────┐
│ Response │
│ { "data": { "createBook": { "id": "123", "title": "..." } } } │
└──────────────────────────────────────────────────────────────────┘

Summary

Mutations in Spring GraphQL follow familiar Spring patterns:

TaskAnnotation
Define mutation@MutationMapping
Get input@Argument
Validate input@Valid + Bean Validation
Return typeThe affected object

Remember: Mutations should be predictable, validated, and return useful data. Your clients will thank you!

In the next post, we'll explore Error Handling - how to return meaningful errors to clients when things go wrong.