Determinism vs Abstraction: The cost of choosing JPA for simple DB queries
The most memorable of lessons are learnt not when we make things work but when things break. When one sets out seeking answers.
Determinism vs Abstraction: The cost of choosing JPA for simple DB queries
The most memorable of lessons are learnt not when we make things work but when things break. When one sets out in pursuit of answers and not just fixes that get things work.
JPA and Hibernate address a genuinely complex problem: mapping object relationships to relational data. Their value becomes clear when rich domain models with many attributes and associations can be seamlessly persisted to a database. Beyond basic mapping, Hibernate provides powerful capabilities such as dirty tracking, caching, and entity state management, giving developers a broad set of tools to work efficiently with persistence. Spring Data JPA builds on this by adding convenience through built-in repository methods that eliminate much of the boilerplate and reduce the need for manual SQL. It’s no surprise that Spring Data JPA has become the default choice for many teams.
However, not every database interaction benefits from this level of abstraction. For deterministic queries or constraint-driven writes, Hibernate can introduce unnecessary complexity, reduce clarity, and sometimes create more risk than value.
Case Study : Lock username in the DB to preserve uniqueness
Imagine a distributed Spring application responsible for account registration. One of the requirements is to lock a username in the relational database during the registration process, ensuring that no two accounts can be created with the same username concurrently.
To achieve this, a dedicated table is introduced where the username serves as the primary key, along with a timestamp column to track when the lock was created. Spring Data JPA is chosen as the database abstraction layer. The developer’s idea is straightforward: attempt to insert a row for the username and rely on the database’s unique constraint. If another request tries to register the same username, the resulting unique constraint violation will safely prevent the duplicate registration. Method save() provided by Spring Data’s Repository interface can be used to write to DB and the exception shall abort the process.
Hidden quirk: Save() Repository method != SQL INSERT query
One subtle but important nuance of Spring Data’s save() method is that it does not directly translate to an INSERT statement. Under the hood, Spring Data JPA delegates to Hibernate, which first determines whether the entity is considered new. Based on that decision, Hibernate chooses between two different operations:
- If the entity is new →
persist()→INSERT - If the entity is not new →
merge()→UPDATE(often preceded by aSELECT)
This decision is driven by Hibernate’s entity state management. In particular, Hibernate infers “newness” from the identifier strategy. When the ID is database-generated (e.g., auto-increment), Hibernate treats a null ID as new and issues an INSERT. However, when the ID is application-assigned, using the username itself as the primary key, Hibernate assumes the entity might already exist and calls merge() instead.
As a result, instead of issuing an INSERT, Hibernate executes an UPDATE. No insert is attempted, and therefore the database’s unique constraint is never triggered. The exception the developer was relying on simply never occurs.
Classic trap: Too late to go back to drawing board
By the time this Hibernate behavior becomes apparent, it’s often too late to rethink the design. Dependencies are already wired, entities are modeled, and integration tests are in place. Reworking the persistence strategy now feels expensive, so the developer starts looking for ways to patch the behavior instead.
This is where many of us make a mistake. Frameworks exist to simplify problems and reduce boilerplate. If a framework starts making a simple requirement harder, either it’s being misused or it’s the wrong tool for the job. In this case, rather than stepping back, the temptation is to fight the framework. Spring Data JPA does provide hooks to override Hibernate’s “new entity” detection logic, and the entity ends up being repurposed to force the desired behaviour. In short, always consider the entity to be “new” and execute INSERT query.
@Table(name = "duplicate_username_prevention", schema = "duplicate_username_prevention")
public class DoubleRegistrationPreventionEntity implements Persistable<String> {
@Id
@NotEmpty(message = "username is mandatory!")
@Column(name = "username")
private String username;
@Column(name = "created_at")
private Timestamp createdAt;
@Transient
private boolean update;
@Override
public String getId() {
return this.username;
}
@Override
public boolean isNew() {
return !this.update;
}
@PrePersist
@PostLoad
void markUpdated() {
this.update = true;
}
}
Hidden Cost: Long held connections that showed up during rolling re-deploys
The workaround appeared to succeed at first. The application behaved correctly and the unique constraint reliably prevented duplicate usernames. Problem solved — or so it seemed.
Over time, SQL queries became slower and database connections were held longer than expected. Eventually, connection errors started surfacing due to HikariCP pool exhaustion. Surprisingly, the issue didn’t appear under high traffic, where you would normally expect pressure on the database. Instead, it showed up in low-traffic environments and became especially pronounced during Kubernetes rolling updates.
Increasing the connection pool size didn’t help.
A deeper investigation revealed the real cause: Hibernate’s flushing and entity state management introduced unpredictable query timing. What should have been a simple, deterministic INSERT turned into a sequence of abstracted operations whose execution depended on the persistence context and transaction lifecycle. As a result, connections were held longer than necessary, and during frequent connect/disconnect cycles — such as rolling deployments — the problem worsened.
Replacing Spring Data JPA with explicit SQL statement using a JDBC client immediately stabilized the system. Queries became predictable, connections were released promptly, and the flaky behaviour disappeared
Key take-away: Abstractions hurt when not needed
Many of us mistakenly think that Spring Data JPA is the popular and only means for database interactions in Spring applications. This is a myth. Spring Data JPA sits on top of Hibernate, which was built to solve a genuinely hard problem: mapping rich object graphs to relational tables. To achieve that, it brings a great deal of machinery — entity state management, dirty tracking, caching, flushing, and other behavior that can feel almost magical — the “Hibernate Magic”.
If at some point, a framework becomes the beast — it is a signal not a challenge. It would be a good time to re-consider the choice and go back to drawing board. Simple solutions tend to be more predictable, easier to reason about, and far more durable in production.
메타데이터
- post_id
- 26643fa6bfa6
- slug
- determinism-vs-abstraction-the-cost-of-choosing-jpa-for-simple-db-queries-26643fa6bfa6
- url
- https://medium.com/@shilpa.gore/determinism-vs-abstraction-the-cost-of-choosing-jpa-for-simple-db-queries-26643fa6bfa6
- canonical_url
- https://medium.com/@shilpa.gore/determinism-vs-abstraction-the-cost-of-choosing-jpa-for-simple-db-queries-26643fa6bfa6
- author_url
- https://medium.com/@shilpa.gore
- status
- ok
- fetched_at
- 2026-07-30 07:15:55