When developers start using Hibernate (via Spring Data JPA), it can seem highly automated—define a repository interface, call findById, and the data is retrieved. However, this abstraction comes with underlying assumptions, and without a clear understanding of them, subtle production issues can arise. This post explores three common pitfalls: Hibernate’s first-level cache, why checked exceptions do not trigger rollbacks, and why Kotlin’s runCatching may not behave as expected in a Spring Data setup.

All examples are from a small Spring Boot + Kotlin + PostgreSQL project built specifically to observe these behaviours in real time.

1. Hibernate’s First-Level Cache (The Persistence Context)

What is it?

Every Hibernate Session maintains an identity map — a HashMap<EntityKey, Object> that tracks every entity loaded from database during that session. This is called the first-level (L1) cache, and it is always on. You cannot disable it.

The rule is simple:

If you load the same entity (same type + same primary key) more than once within a single session, Hibernate returns the cached Java object and never hits the database again.

The Code

@GetMapping("/{id}")
@Transactional
fun getUserById(@PathVariable id: Long): ResponseEntity<User> {
    val user = userRepository.findById(id)   // SELECT hits the DB
    println(user)

    Thread.sleep(20_000)                      // simulate slow work - DB record could be updated externally here

    val user2 = userRepository.findById(id)  // same id → Hibernate returns cached object, NO SQL fired
    println(user2)

    return if (user2.isPresent) {
        ResponseEntity.ok(user2.get())
    } else {
        ResponseEntity.notFound().build()
    }
}

With spring.jpa.show-sql=true you’ll notice only one SELECT statement in the logs, no matter how many times you call findById with the same id inside the same transaction.

Why does this matter?

During the 20-second sleep, another request could have updated the user’s name in the database. But this endpoint will still return the stale version cached in memory — it never re-queries.

Bypassing the Cache with entityManager.refresh()

@GetMapping("/{id}")
@Transactional
fun getUserById(@PathVariable id: Long): ResponseEntity<User> {
    val user = userRepository.findById(id)
    println(user)  // may be stale

    Thread.sleep(20_000) // some computation

    val user2 = userRepository.findById(id)  // still cached
    println(user2)

    return if (user2.isPresent) {
        entityManager.refresh(user2.get())   // force re-SELECT from DB, updates the object in-place
        println(user2)                        // now reflects the current DB state
        ResponseEntity.ok(user2.get())
    } else {
        ResponseEntity.notFound().build()
    }
}

entityManager.refresh(entity) issues a fresh SELECT and overwrites the in-memory object with whatever is currently in the database. This is the standard escape hatch when you know the data may have changed externally.

The Lock Variant

If rows are locked midway through a transaction instead of at the beginning, the corresponding in-memory entity may already be stale. Therefore, it’s important to refresh the entity before performing any operations.

@GetMapping("/lock/{id}")
@Transactional
fun getUserByIdLock(@PathVariable id: Long): ResponseEntity<User> {
    val user = userRepository.findById(id)          // regular SELECT, goes into L1 cache
    println(user)

    Thread.sleep(20_000)

    val user2 = userRepository.findForUpdateById(id) // SELECT ... FOR UPDATE (pessimistic write lock)
    println(user2)
    entityManager.refresh(user2!!)                   // refresh after acquiring the lock
    println(user2)
    // do anything
    return ResponseEntity.ok(user2)
}

findForUpdateById uses @Lock(LockModeType.PESSIMISTIC_WRITE):

@Repository
interface UserRepository : JpaRepository<User, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    fun findForUpdateById(id: Long): User?
}

This translates to SELECT … FOR UPDATE in PostgreSQL — it locks the row so no other transaction can modify it until this one commits. The refresh after the lock ensures the entity reflects the state at the moment the lock was acquired, not from the earlier cached read.

First-Level Cache: Key Takeaways

Scenario

  • Calling findById(1) twice in the same transaction: the second call returns the cached object — no SQL is fired.
  • Calling entityManager.refresh(entity): forces a fresh SELECT and updates the object in-place.
  • Calling findForUpdateById(1): issues SELECT … FOR UPDATE but still interacts with the L1 cache — a refresh is needed to get the latest state.
  • When the transaction ends (commit or rollback), the L1 cache is cleared entirely.

2. Checked Exceptions Do Not Trigger a Rollback

Spring’s Default Rollback Rule

@Transactional in Spring follows a rule inherited from EJB:

Rollback only happens automatically for RuntimeException (unchecked) and Error. Checked exceptions (those that extend Exception but not RuntimeException) are treated as expected application behaviour — the transaction commits even if a checked exception is thrown.

A Concrete Example

Consider a service that saves a user and then tries to call an external payment API. The payment API throws a checked exception on failure:

// A checked exception
class PaymentException(message: String) : Exception(message)

@Service
class UserService(
    private val userRepository: UserRepository
) {

    @Transactional
    @Throws(PaymentException::class)
    fun registerAndCharge(user: User) {
        userRepository.save(user)          // user IS written to DB...
        chargePaymentGateway(user)         // ...this throws a checked exception
    }

    private fun chargePaymentGateway(user: User) {
        throw PaymentException("Card declined for ${user.email}")
    }
}

You might expect: exception thrown → transaction rolls back → no user in DB.

What actually happens: the user is persisted, the exception propagates to the caller, and the transaction commits. There is an unpaid user in the database.

How to Fix It

Option 1 — Declare rollback on checked exceptions explicitly:

@Transactional(rollbackFor = [PaymentException::class])
@Throws(PaymentException::class)
fun registerAndCharge(user: User) {
    userRepository.save(user)
    chargePaymentGateway(user)
}

Option 2 — Use an unchecked exception:

// Extend RuntimeException instead
class PaymentException(message: String) : RuntimeException(message)

Now @Transactional rolls back automatically without any extra configuration. This is why most Spring applications define their domain exceptions as RuntimeException subclasses.

Rollback Rules: Quick Reference

  • RuntimeException (unchecked) — Spring rolls back automatically.
  • Error— Spring rolls back automatically.
  • Checked exception (extends Exception, not RuntimeException) — Spring commits. The transaction proceeds as if nothing went wrong.
  • Any exception with rollbackFor = [MyEx::class] declared — Spring rolls back regardless of exception type.

3. Kotlin runCatching Can Break Transactions

Spring transactions rely on exceptions being thrown out of the transactional method. If no exception escapes the method, the transaction is considered committed.

Behaviour of runCatching

runCatching {
    // code that may throw
}

This construct:

  • Catches any exception
  • Wraps it in a Result
  • Prevents it from propagating

Example

@Transactional
fun createUser() {
    runCatching {
        userRepository.save(User(...))
        throw RuntimeException("failure")
    }
}

Outcome

  • The exception is captured inside Result
  • The method completes normally
  • The transaction commits

This is a known limitation in Spring projects, and it is not expected to be addressed in the near term.

Why Spring Does Not Interact with Result

Spring’s transaction management:

  • Observes method execution via proxies
  • Decides commit/rollback based on thrown exceptions

It does not:

  • Inspect return values
  • Interpret Result.failure

So a method returning Result is always considered successful from the transaction perspective.


Using runCatching Safely

If runCatching is used, the exception must be rethrown:

runCatching {
    ...
}.getOrThrow()

This restores normal rollback behavior.


Recommendation

Avoid using runCatching (or Result) inside @Transactional methods unless exceptions are explicitly rethrown.

If you prefer functional-style error handling—such as returning a Result/Either type instead of throwing exceptions—it is better to apply this approach outside the transactional boundary, for example in the service or controller layer rather than within the transaction itself.

Summary

These three behaviours — the always-on L1 cache and the checked-exception commit — are not bugs. They are deliberate design decisions in Hibernate and Spring. But they are also the source of a disproportionate number of production incidents for teams that haven’t explicitly studied them.

The practical rules of thumb:

  1. Never assume findById is fresh inside a long-running transaction. If the data might have changed externally, use entityManager.refresh() or a pessimistic lock at the start of the transaction.
  2. Never assume @Transactional protects you just because an exception was thrown. If you use checked exceptions, you must add rollbackFor — or switch to unchecked exceptions, which is the Spring idiom anyway.
  3. runCatching can prevent exceptions from propagating, which affects rollback behaviour.

Understanding the persistence context lifecycle and Spring’s transaction proxy behaviour shifts the perspective from “Hibernate operations appear implicit” to having clear visibility into what SQL is executed and when a transaction commits or rolls back—confidence that is highly valuable.