An assigned primary key is not evidence that a row already exists; make new-state detection explicit before using repository save.
Spring Data JPA assigned IDs: tell save when an entity is new
The ambiguous insert
A receipt import assigns its own stable ID before persistence. Spring Data JPA must decide whether save calls persist or merge. Without a nullable version property, a non-null ID can make a new object look existing. A merge may perform an avoidable lookup and returns a managed copy, which matters if callers keep changing the original instance. Entity lifecycle explains why this difference is observable.
Choose one state contract
A nullable @Version field lets the repository regard a null version as new, while also supporting optimistic locking. For an assigned-ID entity without a suitable version, implement Persistable and keep a transient new flag. Flip that flag with @PostPersist and @PostLoad. This is a domain decision: a reconstructed object representing an existing row must not claim it is new. Do not infer existence from an HTTP request field.
Prove both paths
In a repository integration test, save a new assigned-ID receipt and assert one row appears. Load it, change a mutable field, save again, and assert that the same row changes rather than a duplicate insert occurring. Repeat with a detached instance because that route exercises merge. Flush timing can expose SQL but does not prove a committed outcome.
Implementation contract
@Entity
class ReceiptLedger {
@Id UUID receiptId;
@Version Long revision;
String status;
}
// A null revision marks a newly assigned-ID entity for persist().Cost and verification
Choosing persist avoids a merge lookup for new records. A version column adds storage and conflict checks; correctness is more valuable than hiding one small field.
Common Mistakes
- Do not equate a non-null, application-assigned ID with an existing database row.
- Do not keep mutating the original detached object after merge returns a managed copy.
- Do not use a primitive version field when null must mean new.
Read next
Spring JPA entity lifecycle: managed changes and detached objects, Spring JPA optimistic locking: reject a stale stock update, Spring Data JPA saveAndFlush is a SQL boundary, not a commit, Spring JDBC versioned tenant update: inspect the affected row count, Spring Data JPA repositories: derive a query from mapped properties.
