Spring Data JDBC: Optimistic Locking with @Version
In concurrent database operations, preventing lost updates when multiple transactions modify the same record is crucial. In this tutorial, we will explore how to implement optimistic locking with Spring Data JDBC using the @Version annotation.
Background
Optimistic locking assumes that multiple transactions can complete without affecting each other. Before committing an update or delete, the transaction verifies that no other transaction has modified the record.
Spring Data JDBC supports optimistic locking natively via the @Version annotation on numeric fields (such as Long or Integer). When updating an entity, Spring Data JDBC issues an update statement with the version included in the WHERE clause:
UPDATE users SET name = :name, username = :username, version = :newVersion WHERE id = :id AND version = :version
If the row count returned by the update is 0, indicating that another transaction has modified or deleted the row concurrently, Spring Data JDBC throws an OptimisticLockingFailureException.
Entity Class
Let’s define our aggregate root User class with @Id and @Version annotations:
@Table("users")
class User {
@Id
private Long id;
@Version
private Long version;
private final String name;
private String username;
User(String name, String username) {
this.name = name;
this.username = username;
}
public User username(String username) {
this.username = username;
return this;
}
}
User entity has the version field marked with @Version. Spring Data JDBC automatically handles initializing and incrementing this field during persistence operations.
Repository Interface
Next, we create the repository interface - UserRepository which extends Spring Data’s CrudRepository:
interface UserRepository extends CrudRepository<User, Long> {
}
Verification
We will verify our optimistic locking implementation using database integration tests with @Testcontainers, @DataJdbcTest, and PostgreSQL.
Spring Data JDBC does not automatically generate database schemas, so we will use @Sql to create the users table including the version column before running the tests:
@Testcontainers
@DataJdbcTest
@Sql(
executionPhase = BEFORE_TEST_CLASS,
statements = "CREATE TABLE users (id BIGSERIAL PRIMARY KEY, version BIGINT, name TEXT NOT NULL, username TEXT NOT NULL)"
)
class UserOptimisticLockingTests {
@Container
@ServiceConnection
private static final PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(DockerImageName.parse("postgres:latest"));
@Autowired
private UserRepository repository;
}
Initial Persistence
When persisting a new User, Spring Data JDBC automatically initializes the version field to 1:
class UserOptimisticLockingTests {
@Test
@DisplayName("When a user is persisted Then version field is set to 1")
void create() {
var user = repository.save(new User("Rashidi Zin", "rashidi"));
assertThat(ReflectionTestUtils.getField(user, "version")).isEqualTo(1L);
}
}
Incremental Updates
When updating an existing entity, Spring Data JDBC increments the version field automatically:
class UserOptimisticLockingTests {
@Test
@DisplayName("Given an existing user When I update its username Then version field is incremented")
@Sql(statements = "INSERT INTO users (id, version, name, username) VALUES (84, 1, 'Rashidi Zin', 'rashidi');")
void update() {
var user = repository.findById(84L).orElseThrow();
user.username("rashidi.zin");
var updatedUser = repository.save(user);
assertThat(ReflectionTestUtils.getField(updatedUser, "version")).isEqualTo(2L);
}
}
Concurrent Modification Detection
If a transaction attempts to update a record using an outdated version, Spring Data JDBC detects that no rows were updated and throws an OptimisticLockingFailureException:
class UserOptimisticLockingTests {
@Test
@DisplayName("Given an outdated version When updating the user Then OptimisticLockingFailureException should be thrown")
@Sql(statements = "INSERT INTO users (id, version, name, username) VALUES (85, 1, 'Rashidi Zin', 'rashidi');")
void concurrentUpdate() {
var firstCopy = repository.findById(85L).orElseThrow();
var secondCopy = repository.findById(85L).orElseThrow();
firstCopy.username("rashidi.updated");
repository.save(firstCopy);
secondCopy.username("rashidi.concurrent");
assertThatThrownBy(() -> repository.save(secondCopy))
.isInstanceOf(OptimisticLockingFailureException.class);
}
}
Full implementation of the test can be found in UserOptimisticLockingTests.