Encodera

Thanks for reading!

Donate to the site — we work hard for you.

Contents

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 methodWhat the server does
The method finished normallyCommits the transaction
An unchecked exception (RuntimeException) was thrownRolls the transaction back
A checked exception (Exception) was thrownCommits, 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/stats
Posts 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

ParameterWhat it changes
value = TxType.REQUIREDDefault: join a transaction or open a new one
TxType.REQUIRES_NEWAlways a new transaction, the outer one is suspended
TxType.MANDATORYA transaction must exist, otherwise an exception
TxType.NEVERThere must be no transaction
rollbackOn = IOException.classAlso 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.

Thanks for reading!

Donate to the site — we work hard for you.