I'm wondering what would be the best way to implement optimistic locking (optimistic concurrency control) in a system where entity instances with a certain version can not be kept between requests. This is actually a pretty common scenario but almost all examples are based on applications that would hold the loaded entity between requests (in a http session).
How could optimistic locking be implemented with as little API pollution as possible?
Constraints
- The system is developed based on Domain Driven Design principles.
- Client/server system
- Entity instances can not be kept between requests (for availability and scalability reasons).
- Technical details should pollute the API of the domain as little as possible.
The stack is Spring with JPA (Hibernate), if this should be of any relevance.
Problem using @Version
only
In many documents it looks like all you need to do would be to decorate a field with @Version
and JPA/Hibernate would automatically check versions. But that only works if the loaded objects with their then current version are kept in memory until the update changes the same instance.
What would happen when using @Version
in a stateless application:
- Client A loads item with
id = 1
and getsItem(id = 1, version = 1, name = "a")
- Client B loads item with
id = 1
and getsItem(id = 1, version = 1, name = "a")
- Client A modifies the item and sends it back to the server:
Item(id = 1, version = 1, name = "b")
- The server loads the item with the
EntityManager
which returnsItem(id = 1, version = 1, name = "a")
, it changes thename
and persistItem(id = 1, version = 1, name = "b")
. Hibernate increments the version to2
. - Client B modifies the item and sends it back to the server:
Item(id = 1, version = 1, name = "c")
. - The server loads the item with the
EntityManager
which returnsItem(id = 1, version = 2, name = "b")
, it changes thename
and persistItem(id = 1, version = 2, name = "c")
. Hibernate increments the version to3
. Seemingly no conflict!
As you can see in step 6, the problem is that the EntityManager reloads the then current version (version = 2
) of the Item immediately before the update. The information that Client B started editing with version = 1
is lost and the conflict can not be detected by Hibernate. The update request performed by Client B would have to persist Item(id = 1, version = 1, name = "b")
instead (and not version = 2
).
The automatic version check provided by JPA/Hibernate would only work if the instances loaded on the the initial GET request would be kept alive in some kind of client session on the server, and would be updated later by the respective client. But in a stateless server the version coming from the client must be taken into consideration somehow.
Possible solutions
Explicit version check
An explicit version check could be performed in a method of an application service:
@Transactional
fun changeName(dto: ItemDto) {
val item = itemRepository.findById(dto.id)
if (dto.version > item.version) {
throw OptimisticLockException()
}
item.changeName(dto.name)
}
Pros
- The domain class (
Item
) doesn't need a way to manipulate the version from the outside. - Version checking is not part of the domain (except the version property itself)
Cons
- easy to forget
- Version field must be public
- automatic version checking by the framework (at the latest possible point in time) is not used
Forgetting the check could be prevented through an additional wrapper (ConcurrencyGuard
in my example below). The repository would not directly return the item, but a container that would enforce the check.
@Transactional
fun changeName(dto: ItemDto) {
val guardedItem: ConcurrencyGuard<Item> = itemRepository.findById(dto.id)
val item = guardedItem.checkVersionAndReturnEntity(dto.version)
item.changeName(dto.name)
}
A downside would be that the check is unnecessary in some cases (read-only access). But there could be another method returnEntityForReadOnlyAccess
. Another downside would be that the ConcurrencyGuard
class would bring a technical aspect to the domain concept of a repository.
Loading by ID and version
Entities could be loaded by ID and version, so that the conflict would show at load time.
@Transactional
fun changeName(dto: ItemDto) {
val item = itemRepository.findByIdAndVersion(dto.id, dto.version)
item.changeName(dto.name)
}
If findByIdAndVersion
would find an instance with the given ID but with a different version, an OptimisticLockException
would be thrown.
Pros
- impossible to forget handle the version
version
doesn't pollute all methods of the domain object (though repositories are domain objects, too)
Cons
- Pollution of the repository API
findById
without version would be needed anyway for initial loading (when editing starts) and this method could be easily used accidentally
Updating with explicit version
@Transactional
fun changeName(dto: itemDto) {
val item = itemRepository.findById(dto.id)
item.changeName(dto.name)
itemRepository.update(item, dto.version)
}
Pros
- not every mutating method of the entity must be polluted with a version parameter
Cons
- Repository API is polluted with the technical parameter
version
- Explicit
update
methods would contradict the "unit of work" pattern
Update version property explicitly on mutation
The version parameter could be passed to mutating methods which could internally update the version field.
@Entity
class Item(var name: String) {
@Version
private version: Int
fun changeName(name: String, version: Int) {
this.version = version
this.name = name
}
}
Pros
- impossible to forget
Cons
- technical details leaks in all mutating domain methods
- easy to forget
- It is not allowed to change the version attribute of managed entities directly.
A variant of this pattern would be to set the version directly on the loaded object.
@Transactional
fun changeName(dto: ItemDto) {
val item = itemRepository.findById(dto.id)
it.version = dto.version
item.changeName(dto.name)
}
But that would expose the version directly expose for reading and writing and it would increase the possibility for errors, since this call could be easily forgotten. However, not every method would be polluted with a version
parameter.
Create a new Object with the same ID
A new object with the same ID as the object to be update could created in the application. This object would get the version property in the constructor. The newly created object would then be merged into the persistence context.
@Transactional
fun update(dto: ItemDto) {
val item = Item(dto.id, dto.version, dto.name) // and other properties ...
repository.save(item)
}
Pros
- consistent for all kinds of modifications
- impossible to forget version attribute
- immutable objects are easy to create
- no need to load the existing object first in many cases
Cons
- ID and version as technical attributes are part of the interface of domain classes
- Creating new objects would prevent the usage of mutation methods with a meaning in the domain. Maybe there is a
changeName
method that should perform a certain action only on changes but not on the initial setting of the name. Such a method wouldn't be called in this scenario. Maybe this downside could be mitigated with specific factory methods. - Conflicts with the "unit of work" pattern.
Question
How would you solve it and why? Is there a better idea?
Related
- Optimistic locking in a RESTful application
- Managing concurrency in a distributed RESTful environment with Spring Boot and Angular 2 (this is basically the "explicit version check" from above implemented with HTTP headers)
version
in every read query is wrong. You read only by ID. Version is used for write operations. No polution in API , no concurrent modification allowed. Remember that it is not versioning system. It is more like artificial composite PK in context of write operations. IMHO this is all you need and should fit yor requirement. There is no nee to use such things asfindByIdAndVersion
justfindById
– AntoniossssEntityManager#merge
for that ? If you update by hand (as you do in your example snippets) than no wonder it is not working for you. Instead of fetchig beforehand, just doEntityManager#merge(dto)
. I think it is XY question about versioning not working due to missuse. – Antoniossss