How Hibernate Turned a Preview into a Write

6 min read
Share:

The bug that shouldn’t have existed

The report read like a riddle: edit a record, preview the impact (to confirm/or not confirm), click Cancel, refresh — and the changes are still there. No error.

Cancel is the one button whose entire job is to not do things — and yet the changes had been saved anyway.

The app in question manages versioned plans: before a user approves a new plan version, we show a preview — “if you approve this, here’s what it will affect.”  That preview is where the trouble hid.

 (The scenario below is anonymised, but the bug, cause and fix are exactly as they happened.)

The Reproduction

Change a version’s dates, open the preview, hit Cancel, refresh — and the version shows the new dates and an APPROVED status, as if I’d saved.

Here’s the twist that took me longest to see: the write never came from Cancel. Cancel is pure UI — it discards the form and never calls the server. The change  was persisted one step earlier, when the preview asked the backend “what would this affect?” By the time I clicked Cancel, it was already committed, so Cancel had nothing left to undo.

(And a preview should call the backend — only the database knows what’s related. The sin wasn’t the call; it was that a read had a write hiding inside it.)

The SQL gave it away

No exception, no error page — so I stopped guessing and turned on the one witness that never lies:
  logging.level.org.hibernate.SQL=DEBUG
And there it sat, in the middle of a read:
  update plan_version set start_date=?, status=? where id=?
An UPDATE I never wrote, fired during a preview. The reads were writing.

What Hibernate was doing under the hood

When you load an entity inside a transaction, Hibernate keeps a snapshot of it and watches for changes — this is dirty checking. Change a field and the entity is  dirty. With FlushMode.AUTO (the default), Hibernate may flush pending changes before executing a query when it determines that the query could be affected by  those changes — and it always flushes at commit. The intent is friendly: keep your query results consistent with what you’ve already changed in memory.

So the sequence that bit me was:

1.     The preview mutates managed entities in memory (APPROVED status, new dates, etc).

2.     It runs a look-up to build the impact list.

3.     Hibernate decides that query could be affected by the dirty entities, so it flushes them — writing the pretend changes for real.

4.     The read-write transaction commits at the end of the request, sealing them.

And this isn’t a Grails quirk (could be assumed from the given example in Grails). The persistence context, dirty checking and FlushMode.AUTO all live in  Hibernate  — Grails and Spring merely delegate to the same session. The identical bug reproduces in plain Spring + Hibernate or Spring Data JPA: a read-write  @Transactional method that loads an entity, mutates it, then runs a query will flush that mutation just the same. (The one Grails-specific footnote is that its services are transactional by default, so you can be sitting in a read-write transaction without ever having asked for one.)

Why save() wasn’t required

Here’s the offending method, boiled down. It’s Groovy, from a Grails service — and remember, Grails services run in a read-write transaction by default. Its job is display-only: take the plan the user is editing, simulate the proposed approval in memory, then query what that change would affect so the UI can render a preview.  Notice there is no save(), no persist(), no flush() anywhere — which is exactly why the write is so surprising.

// The snippets are Groovy, from a Grails service using GORM (Grails' data-access layer, which 
// runs on Hibernate underneath) — and Grails services run in a read-write transaction by default. 
// The behaviour is identical on any Grails/Hibernate or Spring/Hibernate stack.

@Transactional                           // read-write → Hibernate flush mode AUTO
class PlanVersionService {

    Preview buildPreview(Command command) {
        // 1. Load managed entities — Hibernate's session now tracks them for changes.
        def plan = planRepository.findById(command.planId)

        // 2. "Simulate" the proposed change by mutating those managed entities in memory.
        // We never call save() — in our heads this is just a harmless what-if.
        def simulated = plan.versions.collect { version ->
            if (version.id == command.versionId) {
                version.startDate = command.startDate
                version.status    = APPROVED       // ← entity is now DIRTY (in memory only… we hope)
            }
            version
        }.toSet()

        // 3. Run a query to work out the impact.
        // Because the transaction is read-write, AUTO flush fires BEFORE this SELECT —
        // Hibernate pushes the dirty entities from step 2 to the DB as a real UPDATE.
        def affected = customRepository.findAffected(simulated, command)

        // 4. Method returns → the read-write transaction commits → that UPDATE is now permanent.
        return new Preview(affected)               // user hasn't confirmed anything yet
    }
}

The entities mutated in step 2 are managed — loaded inside the transaction and tracked by Hibernate’s persistence context. When step 3 runs a query, Hibernate  may flush those pending changes first so the query sees a consistent picture, and “flush” means a real UPDATE. The transaction then commits it. save() was  never required because the query did the writing for us.

The one-line fix

The preview is display-only, so it should never have been in a read-write transaction in the first place. One annotation says so:

@Transactional(readOnly = true)
Map buildApprovalImpactPreview(...) {
    // simulate, query, preview — but never persist
}

With the Spring/Hibernate configuration used here, @Transactional(readOnly = true) switches the session to a read-only transaction mode that prevents the dirty  changes from being automatically flushed. The mutations from the simulation remain in the persistence context, are never written, and are discarded when the  persistence context ends. (The class had a bare, read-write @Transactional at the top, which is why every method defaulted to auto-flush until told otherwise.)

The deeper design smell

Because the real smell isn’t the missing annotation — it’s using live, managed entities as a scratchpad. Safer options:

  • Project into DTOs / read models — Hibernate can’t flush what it isn’t tracking.
  • Detach or clone before mutating — session.evict(entity) (or a defensive copy) lets you change a throwaway, not the tracked entity.
  • Keep genuinely read-only work in a read-only transaction — belt and braces on top of the design fix.

What I learned

If I could hand my past self a checklist, it would be this.

For preview / simulation code

  • Don’t mutate managed entities.
  • Prefer DTOs/projections for simulations.
  • Use @Transactional(readOnly = true) for genuinely read-only operations.
  • Don’t assume “no save() = no database write.”
  • Enable Hibernate SQL logging when investigating unexpected persistence.
  • Understand the persistence context and dirty checking before debugging the service layer.

The fix was a single annotation but it made me stop treating the ORM as a black box and start understanding the machinery underneath: the persistence context, dirty checking, flush modes, and the well-meaning reflex that turns a preview into a commit.

 

Leave a Reply

Your email address will not be published. Required fields are marked *