Transactions
A transaction is a group of database changes that runs completely or not at all. If something goes wrong halfway, the database returns to the state before the start — this is called a rollback. A classic example is importing ten posts where the fifth one is invalid. Without a transaction, four posts are already saved and the rest are not — the data is half broken. With a transaction, none is saved.
@Transactional
In Quarkus a transaction is opened with the @Transactional annotation on a bean method. The transaction starts when the method is entered and is committed when it returns. If an unchecked exception (RuntimeException) leaves the method, all changes are rolled back.
Let's create an import service that saves a list of posts and refuses repeated titles:
// src/main/java/com/example/blog/PostService.java
package com.example.blog;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import java.util.ArrayList;
import java.util.List;
@ApplicationScoped
public class PostService {
@Transactional
public List<Post> importAll(List<NewPost> newPosts) {
List<Post> saved = new ArrayList<>();
for (NewPost newPost : newPosts) {
if (Post.titleExists(newPost.title())) {
throw new DuplicateTitleException(newPost.title());
}
Post post = new Post();
post.title = newPost.title();
post.body = newPost.body();
post.persist();
saved.add(post);
}
return saved;
}
}// src/main/java/com/example/blog/DuplicateTitleException.java
package com.example.blog;
public class DuplicateTitleException extends RuntimeException {
public DuplicateTitleException(String title) {
super("A post titled '" + title + "' already exists");
}
}We wrote the titleExists method in the lesson on queries. The exception needs a mapper with the 409 Conflict status — add it to ExceptionMappers:
// src/main/java/com/example/blog/ExceptionMappers.java
@ServerExceptionMapper
public RestResponse<ErrorMessage> mapDuplicate(DuplicateTitleException e) {
return RestResponse.status(Response.Status.CONFLICT, new ErrorMessage(e.getMessage()));
}And an endpoint in the resource:
// src/main/java/com/example/blog/PostResource.java
@Inject
PostService postService;
@POST
@Path("/import")
public List<Post> importPosts(List<@Valid NewPost> posts) {
return postService.importAll(posts);
}List<@Valid NewPost> validates every element of the list. The endpoint itself has no @Transactional: the service opens the transaction.
Checking the rollback
First import two new posts — that makes four:
curl -X POST http://localhost:8080/posts/import -H "Content-Type: application/json" \
-d '[{"title":"Part 1","body":"a"},{"title":"Part 2","body":"b"}]'
curl http://localhost:8080/posts/countThe answer is a list of the two saved posts and the number 4. Now import a new post and a post with an existing title:
curl -i -X POST http://localhost:8080/posts/import -H "Content-Type: application/json" \
-d '[{"title":"Part 3","body":"c"},{"title":"Part 1","body":"d"}]'
curl http://localhost:8080/posts/countHTTP/1.1 409 Conflict
Content-Type: application/json;charset=UTF-8
{"error":"A post titled 'Part 1' already exists"}
4
The Part 3 post was saved with persist() before the error, but after the exception the transaction was rolled back, and there are still four posts.
Programmatic transactions
Sometimes only part of a method needs a transaction. For that there is QuarkusTransaction from the io.quarkus.narayana.jta package:
// src/main/java/com/example/blog/PostService.java
public void rename(long id, String title) {
QuarkusTransaction.requiringNew().run(() -> {
Post post = Post.findById(id);
post.title = title;
});
}A change to an entity field inside a transaction is saved automatically; there is no need to call persist() for an object that is already stored.
Common mistake
A transaction is rolled back only on unchecked exceptions. If a method throws a checked one, such as IOException, the changes are committed even though the method failed. Extend RuntimeException for your exceptions, as DuplicateTitleException does, or say it explicitly: @Transactional(rollbackOn = IOException.class).
The second trap is @Transactional on a private method. Quarkus cannot intercept such a call and stops the build with a clear message:
DeploymentException: @Transactional will have no effect on method com.example.blog.PostService.importAll()
because the method is private. Either remove the annotation from the method, or turn this exception into
a simple warning by setting configuration property 'quarkus.arc.fail-on-intercepted-private-method' to 'false'.
Make the method public or package-private.