Transactions and @Transactional
A transaction is a group of database changes that is applied completely or not at all. If an import of ten posts fails on the seventh, the database must not keep six "half-done" ones. In Jakarta EE the server manages transactions (JTA — Jakarta Transactions): you put the @Transactional annotation on a method, and the server opens a transaction before the call and commits or rolls it back afterwards.
How it works
| What happened in the method | What the server does |
|---|---|
| The method finished normally | Commits the transaction |
An unchecked exception (RuntimeException) was thrown | Rolls the transaction back |
A checked exception (Exception) was thrown | Commits, unless configured otherwise |
@Transactional works only on CDI beans and only for calls from outside: a call from the same object (this.save(...)) bypasses the proxy and does not open a transaction.
Importing posts
Let's add an import of several posts at once to the service. The full src/main/java/com/example/blog/PostService.java:
// src/main/java/com/example/blog/PostService.java
package com.example.blog;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Event;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import java.time.Clock;
import java.time.LocalDate;
import java.util.List;
@ApplicationScoped
public class PostService {
@Inject
PostRepository repository;
@Inject
Event<PostCreated> postCreated;
@Inject
Clock clock;
public Post publish(NewPost data) {
Post post = repository.save(new Post(data.title(), data.body(), LocalDate.now(clock)));
PostCreated event = new PostCreated(post.getId(), post.getTitle());
postCreated.fire(event);
postCreated.fireAsync(event);
return post;
}
@Transactional
public int importPosts(List<NewPost> items) {
for (NewPost item : items) {
if (item.title() == null || item.title().isBlank()) {
throw new IllegalArgumentException("Post without a title in the import");
}
repository.save(new Post(item.title(), item.body(), LocalDate.now(clock)));
}
return items.size();
}
}importPosts opens a transaction. The repository.save method is also marked @Transactional, but by default it joins the transaction that is already open instead of creating its own. So all posts of the import are saved together.
The resource
// src/main/java/com/example/blog/ImportResult.java
package com.example.blog;
public record ImportResult(int imported) {
}// src/main/java/com/example/blog/PostResource.java (fragment)
@POST
@Path("/import")
@Consumes(MediaType.APPLICATION_JSON)
public ImportResult importPosts(List<NewPost> items) {
return new ImportResult(service.importPosts(items));
}Checking the rollback
First a successful import of two posts, then an import where the second post has an empty title:
curl http://localhost:8080/blog/stats
curl -H "Content-Type: application/json" -d '[{"title":"Import one","body":"First body text"},{"title":"Import two","body":"Second body text"}]' http://localhost:8080/blog/api/posts/import
curl http://localhost:8080/blog/stats
curl -i -H "Content-Type: application/json" -d '[{"title":"Import three","body":"Third body text"},{"title":"","body":"No title here"}]' http://localhost:8080/blog/api/posts/import
curl http://localhost:8080/blog/statsPosts in the blog: 2
{"imported":2}
Posts in the blog: 4
HTTP/1.1 500 Internal Server Error
Post without a title in the import
Posts in the blog: 4
The post "Import three" did pass through save, but after the exception the transaction was rolled back, and it is not in the database — the counter stayed at 4. In a real API you would turn this exception into a 400 response with a mapper, as in the REST chapter.
Annotation settings
| Parameter | What it changes |
|---|---|
value = TxType.REQUIRED | Default: join a transaction or open a new one |
TxType.REQUIRES_NEW | Always a new transaction, the outer one is suspended |
TxType.MANDATORY | A transaction must exist, otherwise an exception |
TxType.NEVER | There must be no transaction |
rollbackOn = IOException.class | Also roll back on this checked exception |
dontRollbackOn = ... | Do not roll back on this exception |
For example, @Transactional(TxType.REQUIRES_NEW) suits an audit log: the log entry must be saved even if the main operation is rolled back.
Common mistake
Calling persist without a transaction. If you remove @Transactional from PostRepository.save, creating a post fails:
jakarta.persistence.TransactionRequiredException: No active transaction for PuId=blog#blog.war#blog
Reading works without a transaction, changing does not: persist, merge and remove need a method higher up the call chain to be marked @Transactional.