A Hibernate migration nearly removed a behavior that an external change feed depended on. The replacement code could save the data. It could pass tests that verified repository calls. But a child edit would no longer produce the parent audit row used to discover that change.

The old helper detached the parent and called update. Replacing that operation with merge looked reasonable. The missing requirement was that the parent had to be written even when none of its own business fields changed.

The regression was caught during review, before the migration shipped. Restoring the audit behavior exposed another problem: merging the parent while iterating one of its collections could mutate that same collection.

This post walks through that migration using generic entity names and simplified code. It was a preparatory change on Hibernate 6, ahead of a Hibernate 7 upgrade. The source review covered roughly 180 files and 45 legacy cascade annotations. The useful lesson is how to preserve the behavior hidden behind those calls.

What Hibernate 7 Removes

Hibernate 7 removes Session.save, Session.update, Session.saveOrUpdate, and the Hibernate-specific CascadeType.SAVE_UPDATE. The 7.0 migration guide lists their replacements.

Those Session methods were already deprecated in Hibernate 6. The Session Javadoc for 6.6 makes the distinction explicit: persist a new instance, or merge detached state.

That creates an opportunity to do the work before the major upgrade. Add the new repository verbs, migrate callers while still on Hibernate 6, and test the resulting behavior. Keep the framework version change small enough that a failure has a manageable set of possible causes.

Decide From Entity State, Not the Old Method Name

The old repository API had one convenient verb for graphs containing new, detached, and managed entities. Replacing it requires knowing which state each call site actually receives.

State in the current persistence contextUsual operationObject to use afterward
New, intended to be insertedpersist(entity)The original instance
Managed and modified in the current transactionNo explicit saveThe managed instance
Detached, with state to applymerge(entity)The returned managed instance
Mixed or uncertain inputsClarify the contract; use merge only if copying that state is intendedThe returned instances

This is the distinction in the Jakarta Persistence EntityManager contract. merge is not a universal upsert: removed entities are invalid inputs, stale versions can fail optimistic locking, and database constraints still apply.

An assigned identifier does not automatically make an entity detached. A new entity with an application-assigned key can be persisted. Choose between inserting and merging based on the operation’s intent, not merely whether getId() is non-null.

New Objects: Preserve the Original Instance

For a known-new document:

Document document = new Document();
document.setTitle("Draft");
entityManager.persist(document);
return document;

Generated identifier timing depends on the strategy and provider. A sequence-generated identifier may be available before the INSERT; do not generalize that timing to every mapping.

Detached Objects: Keep the Merge Result

This is the mistake to look for in repository wrappers:

entityManager.merge(document);
document.setTitle("Reviewed");
return document;

For detached input, the later edit is on the wrong instance. Keep the managed result:

Document managed = entityManager.merge(document);
managed.setTitle("Reviewed");
return managed;

The same rule applies to a newly constructed object passed to merge. If a caller keeps the original and later treats it as new again, another insert can result. A mergeAll helper that discards all return values also needs a clear contract: callers must not expect those original objects to become managed or receive generated state.

Managed Objects: Dirty Checking Is Enough

Inside the transaction that loaded the document:

Document document = entityManager.find(Document.class, documentId);
document.setTitle("Reviewed");
// Dirty checking detects the change at flush.

Adding merge here is not necessarily harmless bookkeeping. Although the root is already managed, the operation can still cascade through its associations. That distinction matters later in the collection example.

Change Cascades and Call Sites Together

A SAVE_UPDATE graph could hide decisions about which nodes were new and which were detached. Replacing the method while leaving the graph assumptions unchanged is how duplicate inserts and detached-entity exceptions appear.

In this migration, parent-to-child relationships generally needed PERSIST and MERGE. Child-to-parent references generally kept MERGE without adding PERSIST, because a new child could refer to an existing parent. These were decisions about this model, not universal rules for every relationship.

Use JPA cascade attributes for the replacement:

import jakarta.persistence.CascadeType;

@OneToMany(mappedBy = "document", cascade = {
    CascadeType.PERSIST,
    CascadeType.MERGE,
    CascadeType.DETACH
})
private Set<Section> sections = new HashSet<>();

For a child reference where those are the intended operations:

@ManyToOne(cascade = {CascadeType.MERGE, CascadeType.DETACH})
@JoinColumn(name = "document_id", nullable = false)
private Document document;

Do not substitute ALL to make failures disappear. It also introduces REMOVE and REFRESH. Likewise, retain DETACH only when the application needs that behavior.

Hibernate 7 deprecates org.hibernate.annotations.Cascade itself for removal. Moving from @Cascade(SAVE_UPDATE) to @Cascade(PERSIST) leaves another migration waiting. The JPA attribute avoids that particular dependency.

Adding PERSIST can also affect flushing of managed graphs, even when a code path never calls persist explicitly. Test association changes under the application’s actual bootstrap and transaction configuration.

Persist the Parent Before the Child

Without an upward persist cascade, a new child cannot simply refer to an unsaved parent and rely on the old behavior:

Document parent = new Document();
entityManager.persist(parent);

Section child = new Section();
child.setDocument(parent);
entityManager.persist(child);

If the parent came from merge, assign that returned instance to the child. Also review code that merges a parent containing a new child and later persists the original child separately. The merge may already have created a persistent copy.

The Audit Revision Was Part of the Save Contract

The difficult helper existed because the application discovered changed parents through Envers revisions.

Its child collections were excluded from auditing and optimistic-lock participation. The application also used:

org.hibernate.envers.revision_on_collection_change=false

Under that mapping, changing a child did not, by itself, produce the parent revision required by the change feed. The repository deliberately forced a parent update:

// Legacy Hibernate implementation, simplified for an existing document.
Session session = entityManager.unwrap(Session.class);
session.evict(document);
session.update(document);

In the configuration described by the migration, reattaching this way produced an UPDATE, a version increment, and an Envers revision. That observation should not become a claim that every legacy update always writes under every mapping and dirty-checking configuration.

The replacement changed the observable behavior:

Document managed = entityManager.merge(document);

If the parent’s state matched the loaded database state, dirty checking had no parent change to write. Saving the child still worked. The parent disappeared from the feed because the feed looked for an audit row that was never created.

A mock verifying that merge was called cannot detect this failure.

Why a Version Bump Was Not Enough

Optimistic locking and auditing serve different purposes. The Envers configuration reference documents that version properties are excluded from auditing by default through do_not_audit_optimistic_locking_field.

The migration’s companion test report compared several approaches with the relevant mapping:

Operation on an otherwise unchanged parentReported version behaviorReported parent audit revision
mergeNo incrementNone
evict followed by mergeNo incrementNone
OPTIMISTIC_FORCE_INCREMENTIncrementedNone
PESSIMISTIC_FORCE_INCREMENTIncrementedNone
Change an audited touch property, then mergeIncremented on updateCreated

Those are reported results from a small JPA/Envers fixture on Hibernate 6.6 and 7.4, not a guarantee for every mapping. The important acceptance test is the actual audit row and the change feed that consumes it. A version assertion alone is insufficient.

Native SQL that increments the version would also bypass the ordinary entity-update path. It does not establish the Envers contract the helper needs to satisfy.

Make the Audit Requirement an Explicit Change

The chosen solution was a counter with no business meaning. Changing it makes the parent dirty through Hibernate’s normal persistence machinery.

For a simplified audited Document entity using field access:

// Inside an @Audited entity; this field must not be @NotAudited.
@Column(name = "audit_touch")
private Long auditTouch;

public void touchForAudit() {
    auditTouch = auditTouch == null ? 1L : auditTouch + 1L;
}

The column must exist in the base table and its audit table. For a PostgreSQL schema with the names shown here:

ALTER TABLE document ADD COLUMN audit_touch bigint;
ALTER TABLE document_aud ADD COLUMN audit_touch bigint;

Existing rows can remain null because the first touch handles that value. A missing audit-table column would make the audit write fail.

The helper below deliberately improves one detail from the migration implementation: it returns the merge result, rather than returning a potentially detached argument. It assumes a generated identifier, an audited and versioned document mapping, and an active transaction:

public Document forceSave(Document document) {
    if (document.getId() == null) {
        entityManager.persist(document);
        return document;
    }

    document.touchForAudit();
    return entityManager.merge(document);
}

Callers that continue working with a detached input must keep this return value. After the transaction, even that returned object will no longer be managed by the closed persistence context.

This is a schema-level workaround with a narrow purpose. It adds audit noise and should not become a general-purpose way to force arbitrary entities through the database. A fallback that quietly uses evict plus merge cannot promise the same audit behavior.

Also, a helper invocation is not an audit event boundary. Multiple touches before one flush can result in one update, and Envers groups audited changes into transaction revisions. The contract is visibility of the committed change, not one revision per call.

The Second Failure: Merging While Iterating

Restoring the missing audit call exposed another consequence of merge cascades. Code iterated a live child collection and force-saved the parent inside that loop:

for (Section section : document.getSections()) {
    changeState(section);
    forceSave(document);
}

The migration’s reproducer used a sorted child collection and reported a ConcurrentModificationException: the merge cascade rebuilt the collection being traversed. The parent was managed, but merging it still cascaded into its children.

The local fix was to iterate a snapshot:

for (Section section : new ArrayList<>(document.getSections())) {
    changeState(section);
    forceSave(document);
}

Where the transaction and business contract allow it, changing the children first and touching the parent afterward is another option. Establish that behavior with a test before moving the call: its placement may have been serving a requirement that is not obvious from the method name.

Test the Feed, Not Just the Repository Call

The regression test needed to reproduce the consumer’s view:

  1. Commit the fixture setup and capture a revision boundary.
  2. Confirm the fixture is absent from changes after that boundary.
  3. Change a child through the application operation in a new transaction.
  4. Commit, then assert that the parent has a newer audit revision.
  5. Assert that the public change-feed operation returns the parent.

Keep an HTTP test if the externally visible API accepts timestamps rather than revision numbers. Make sure fixture revisions fall outside its query window so setup work cannot produce a false pass.

The wider migration also needs integration coverage for new and detached graphs, use of the merge result, parent-before-child ordering, and the collection iteration path. Replacing verify(save(...)) with verify(merge(...)) is a useful mechanical edit, but it does not verify any of those contracts.

Make the Preparatory Migration Finite

The first pass did not eliminate every legacy call. Some batch helpers suppressed deprecation warnings internally without marking their public methods deprecated. Their callers therefore looked clean to the compiler while still reaching APIs that Hibernate 7 removes.

Before moving the dependency version:

  • Search both direct Session calls and repository wrappers, including batch operations.
  • Review each call’s entity state, cascade direction, and returned-instance use.
  • Replace Hibernate cascade annotations with the required JPA operations.
  • Test audit-dependent behavior through a committed transaction and its consumer.
  • Remove legacy wrappers after their callers reach zero.

The touch column solved a specific compatibility problem. The more durable improvement was making the parent audit revision an explicit requirement with an integration test. Future persistence changes can then be judged against the behavior the application actually needs.

For the broader framework upgrade, see the Spring Boot 3 to 4 migration guide. If your team needs help finding these hidden contracts before an upgrade, discuss the migration with Katyella.

Java Modernization Readiness Assessment

15 questions your team should answer before starting a migration. Takes 10 minutes. Could save you months.