Transactions
A transaction is a group of database actions that runs entirely or not at all. If an error happens halfway, the database rolls back to the state before the group started. In the tutorial you managed transactions through JDBC: setAutoCommit(false), commit, rollback. In Spring an @Transactional annotation on a service method is enough.
Annotations in the service
// src/main/java/com/example/blog/PostService.java
@Service
@Transactional(readOnly = true)
public class PostService {
// ...
@Transactional
public PostResponse create(NewPostRequest request) {
User author = users.findById(request.authorId())
.orElseThrow(() -> new IllegalArgumentException("Unknown author " + request.authorId()));
Post post = new Post(request.title(), request.body(), author, Instant.now(clock));
return PostResponse.from(posts.save(post));
}
}The annotation on the class applies to all its public methods: each runs in a read-only transaction, which the database and Hibernate can speed up. Methods that change data are marked with plain @Transactional: an annotation on a method beats the one on the class. Add a handler for IllegalArgumentException with code 400 to ApiExceptionHandler, just like the one for PostNotFoundException.
All or nothing
Let's add creating several posts in one request:
// src/main/java/com/example/blog/PostService.java
@Transactional
public List<PostResponse> createAll(List<NewPostRequest> requests) {
return requests.stream().map(this::create).toList();
}// src/main/java/com/example/blog/PostController.java
@PostMapping("/batch")
@ResponseStatus(HttpStatus.CREATED)
public List<PostResponse> createAll(@RequestBody List<@Valid NewPostRequest> requests) {
return postService.createAll(requests);
}Send two posts, the second with an author that does not exist:
curl -X POST http://localhost:8080/api/posts/batch -H "Content-Type: application/json" \
-d '[{"title":"One","body":"1","authorId":1},{"title":"Two","body":"2","authorId":42}]'{"detail":"Unknown author 42","instance":"/api/posts/batch","status":400,"title":"Bad Request"}The first post did reach the database, but the exception on the second rolled back the whole transaction. curl http://localhost:8080/api/posts shows the same number of posts as before — "totalElements":1. With valid authors both posts arrive, with ids 3 and 4: the database gave id 2 to the rolled-back insert, and it is not reused.
Changes without save
Inside a transaction Hibernate keeps track of loaded entities. If you change a field, it runs an update itself when the transaction ends:
// src/main/java/com/example/blog/PostService.java
@Transactional
public PostResponse update(Long id, NewPostRequest request) {
Post post = getPost(id);
post.setTitle(request.title());
post.setBody(request.body());
return PostResponse.from(post);
}There is no save call here, yet the log shows:
Hibernate: select p1_0.id,p1_0.author_id,p1_0.body,p1_0.created_at,p1_0.title from posts p1_0 where p1_0.id=?
Hibernate: select u1_0.id,u1_0.username from users u1_0 where u1_0.id=?
Hibernate: update posts set author_id=?,body=?,created_at=?,title=? where id=?This mechanism is called dirty checking.
@Transactional rules
| Rule | What it means |
|---|---|
| Put it on the service | The transaction covers the whole operation, not one query |
| Works only on public methods of a bean | Spring wraps the bean in a proxy |
| Rolls back on unchecked exceptions | RuntimeException and its subclasses; checked ones need rollbackFor |
| A call from the same class bypasses the proxy | The annotation on the called method has no effect then |
Common mistake
A transaction rolls back only on unchecked exceptions. If a service method throws a checked one, for example an IOException while reading a file, Spring commits whatever was written so far, and half-finished data stays in the database. Either throw subclasses of RuntimeException, like PostNotFoundException, or request the rollback explicitly: @Transactional(rollbackFor = Exception.class).