Common Login Portal

Spring Data JPA's null-version trap

Ashish Bhagat · 13 Aug 2026

An update to an existing row, on an entity with @Version-based optimistic locking, isn't supposed to fail with a duplicate-key violation. It's supposed to either succeed or throw OptimisticLockException if someone else updated the row first. Getting the wrong one of those two outcomes — a duplicate-key error where an optimistic-lock exception belonged — is a specific, well-known Spring Data JPA trap, and Common Login Portal's data layer was built around avoiding it deliberately, not by accident.

toDomain, fromDomain, applyDomain

This convention exists for the same reason the rest of CLP's backend does: keeping the domain model — plain objects with no idea what database or ORM sits underneath them — completely separate from the JPA entities that actually get persisted. A domain object doesn't have a @Version field, doesn't know what optimistic locking is, and shouldn't have to. That knowledge belongs entirely to the persistence adapter, which is exactly where @Version-related bugs like this one end up hiding — not in business logic, but in the seam between a framework-free domain and a framework that has very specific opinions about how it decides what's new.

CLP's JPA entities each follow the same three-method convention so the domain model never touches persistence directly: toDomain() converts a JPA entity into the pure domain object, fromDomain() builds a brand-new JPA entity from a domain object, and applyDomain() mutates an existing, already-loaded JPA entity's fields to match a domain object's current state. Three methods that look almost interchangeable and are not — the difference between the second and third is exactly where this trap lives.

What save() actually does with @Version

Spring Data JPA's default repository implementation, SimpleJpaRepository, doesn't have a single unconditional path for saving an entity. Its save() method checks whether the entity is "new" and branches: if it's new, it calls entityManager.persist(); if it isn't, it calls entityManager.merge(). Those two calls do genuinely different things at the SQL level — persist inserts, merge updates (after first reading the current state).

How "new" gets decided matters more than most people expect. For an entity that doesn't implement Persistable itself, and that has an @Version field, Spring Data JPA's default new-entity check treats the entity as new whenever that version field is null — regardless of whether the entity's ID already exists as a row in the database. The ID isn't what decides new-versus-existing here. The version field is.

The trap

Which is exactly where fromDomain() becomes dangerous if it's used somewhere it shouldn't be. Building a fresh JPA entity instance from a domain object, the ordinary way, doesn't automatically know what version number is currently sitting in the database for that row — a brand-new object's version field starts at whatever the field default is, effectively null, unless something explicitly copies the current version into it. Call save() on that fresh instance for what's meant to be an update to a row that already exists, and Spring Data JPA sees a null version, decides the entity is new, and calls persist() — attempting an INSERT against a primary key that's already taken. The database doesn't reject that with anything resembling an optimistic-locking message. It rejects it as a duplicate-key violation, because as far as the database is concerned, that's exactly what was attempted.

It's also a bug that doesn't announce itself as a versioning problem when it shows up. A duplicate-key exception on an update reads, at first glance, like a data-integrity issue or a bad ID generation strategy — not like an optimistic-locking convention getting bypassed three method calls upstream. And because it only fires on the specific call paths that happen to construct a fresh entity instead of loading the managed one, it can pass most of a test suite cleanly and only surface under a particular sequence of operations, which is exactly the kind of failure that's expensive to track back to its actual cause days or weeks after the code that causes it was written.

It's a genuinely easy mistake to make, because the code that produces it looks correct: build the entity from the domain object, save it. Nothing about that reads as wrong until you know that "build a fresh entity" and "carry forward the row's real version" are not the same operation, and JPA's new-entity detection is quietly depending on the second one.

Find, then update

The pattern CLP's repositories use instead is find-then-update: load the existing entity by ID first — a managed entity, already carrying its real, current @Version value straight from the row Hibernate just read — and call applyDomain() on that loaded instance to copy the domain object's current field values onto it, rather than constructing a new instance with fromDomain(). Saving that entity afterward is no longer ambiguous: it has a real version, Spring Data JPA correctly treats it as existing, calls merge(), and the update goes out with the actual version in its WHERE clause the way optimistic locking is supposed to work. If someone else updated the row in between the find and the save, that update now correctly throws OptimisticLockException instead of quietly colliding on the primary key. fromDomain() stays reserved for the one case where a null version is actually correct: creating a genuinely new row for the first time.

Not the literal exception text from CLP's logs, but the shape SQL Server gives a collision like this when the trap isn't avoided — a duplicate-key violation, not an optimistic-lock exception:

Violation of PRIMARY KEY constraint. Cannot insert duplicate key in object.

It's worth being precise about what the correctly-thrown exception would have meant, too, because it's easy to conflate the two failure modes once you know they can look similar. A genuine OptimisticLockException means two callers actually raced to update the same row, and one of them lost — a real concurrency conflict the caller needs to handle, typically by retrying against fresh data. A duplicate-key violation from the null-version trap means nothing about concurrency at all. It means the code tried to insert a row that already exists, because it never correctly identified the row as existing in the first place. One of those is a signal about contention in the system. The other is a signal about a bug in how an entity was constructed, wearing a database error that makes it look like the first one.

What I'd do differently

This convention worked because it was applied consistently — every entity, the same three methods, the same rule about which one to use for updates. The risk with a pattern like this is entirely social, not technical: it only takes one repository method built by copy-pasting the wrong example to reintroduce fromDomain() where applyDomain() belonged, and the bug it reintroduces doesn't announce itself as a JPA versioning mistake — it just looks like an occasional, hard-to-reproduce duplicate-key error under concurrent writes. If I were doing this again, I'd want a lightweight static check or code-review rule that flags any repository save path using fromDomain() outside of a genuine create operation, rather than relying on everyone remembering the convention correctly by hand.


More from this series:

Hexagonal architecture you can verify with grep · Three clocks racing: a payment reconciliation bug