HomeSpring BootJPA Entity Lifecycle in Spring Boot: States, Callbacks, Cascade
Spring Boot

JPA Entity Lifecycle in Spring Boot: States, Callbacks, Cascade

JPA entity lifecycle explained: transient, managed, detached and removed states, persist vs merge, flush, @PrePersist/@PostLoad callbacks, cascade and common pitfalls.

JPA Entity Lifecycle in 30 Seconds

A JPA entity is always in one of four states, and the EntityManager (or a Spring Data repository) moves it between them: 1. Transient: created with new, no database row, not tracked. 2. Managed: attached to a persistence context. Changes are detected automatically (dirty checking) and written to the database when the context flushes, normally at transaction commit. 3. Detached: was managed, but the persistence context closed or the entity was detached. Changes are no longer tracked. 4. Removed: scheduled for deletion. The DELETE runs at flush. Transitions: - new → transient - persist(), or save() on a new entity → managed - find(), findById() and query results → managed - detach(), clear(), or the end of the transaction/session → detached - merge(detached) → returns a managed copy (the argument stays detached) - remove(managed) → removed Rule of thumb: inside a @Transactional method, load an entity, change it, and let dirty checking write the UPDATE. You do not need to call save() on a managed entity. Note: with Spring Boot 3 / Hibernate 6 the annotations live in jakarta.persistence.*; Spring Boot 2.x uses javax.persistence.*.

The Four Entity States

An entity instance is always in one of four states. Transient: created with new, not associated with any persistence context, no database row. Managed: associated with an active persistence context; any change is automatically flushed to the database at transaction commit. Detached: was once managed, but the persistence context has closed or the entity was explicitly detached; changes are not tracked. Removed: scheduled for deletion; the row will be deleted at flush time. The example below walks through all four states. Pay attention to the detach and merge steps: merge() copies the state of the detached object onto a managed instance, so a change made while detached is only saved if you merge it back.
Java
@Service
@RequiredArgsConstructor
public class EntityLifecycleDemo {

    private final EntityManager       em;
    private final ProductRepository   productRepo;

    @Transactional
    public void demonstrateStates() {

        // ── 1. TRANSIENT — not associated with any persistence context ─
        Product product = new Product();
        product.setName("Widget");
        product.setPrice(BigDecimal.valueOf(9.99));
        // No id, no persistence context association, no DB row

        // ── 2. MANAGED — associated with the persistence context ───────
        em.persist(product);
        // product is now managed: id assigned (immediately with IDENTITY,
        // at flush with SEQUENCE), tracked for changes

        product.setPrice(BigDecimal.valueOf(12.99));
        // No explicit save() needed — dirty checking will flush this
        // change at transaction commit automatically

        // ── 3. DETACHED — context closed or explicit detach ────────────
        em.detach(product);
        // product is now detached: changes are NOT tracked automatically.
        // Pending changes that were not flushed yet (such as the price
        // change above) are no longer tracked either.

        product.setName("Super Widget");
        // Not tracked while detached. Without merge() this change would be lost.

        // Re-attach by merging — returns a managed instance
        Product managed = em.merge(product);
        // merge() copies the detached state (name "Super Widget", price 12.99)
        // onto the managed instance. 'managed' is tracked; 'product' (the
        // argument) is still detached. Always continue with the returned object.

        // ── 4. REMOVED — scheduled for deletion ───────────────────────
        em.remove(managed);
        // DELETE executed at flush — row gone at transaction commit
    }

    @Transactional
    public void repositoryStates() {

        // save() on a NEW entity → em.persist() (transient → managed)
        // save() on an entity with an existing id → em.merge() (returns a different instance!)
        Product p = productRepo.save(new Product("Widget", BigDecimal.TEN));

        // findById() returns a managed entity
        Product found = productRepo.findById(p.getId()).orElseThrow();
        found.setName("Updated");
        // No save() needed — dirty checking flushes the UPDATE

        // delete() → removed state
        productRepo.delete(found);
    }
}

Watch the States Change

Instead of memorizing the states, observe them. EntityManager.contains(entity) tells you whether an instance is currently managed, and enabling SQL logging shows exactly when statements are sent. Run the method below and compare the log with the comments. To see the SQL, add logging.level.org.hibernate.SQL=DEBUG to application.properties (or spring.jpa.show-sql=true for a quick look).
Java
@Service
@Slf4j
@RequiredArgsConstructor
public class StateObserver {

    private final EntityManager em;

    @Transactional
    public void observe() {
        Product p = new Product("Widget", BigDecimal.TEN);
        log.info("after new:     managed={}, id={}", em.contains(p), p.getId());
        // managed=false, id=null                       -> TRANSIENT

        em.persist(p);
        log.info("after persist: managed={}, id={}", em.contains(p), p.getId());
        // managed=true,  id=1 (with IDENTITY)          -> MANAGED

        em.flush();
        em.detach(p);
        log.info("after detach:  managed={}", em.contains(p));
        // managed=false                                -> DETACHED

        Product m = em.merge(p);       // SELECT, because the row must be loaded first
        log.info("merge returned a different instance: {}", m != p);
        log.info("p managed={}, m managed={}", em.contains(p), em.contains(m));
        // true
        // p managed=false, m managed=true

        em.remove(m);
        log.info("after remove:  managed={}", em.contains(m));
        // managed=false                                -> REMOVED (DELETE at commit)
    }
}

# application.properties — see the SQL timing:
# logging.level.org.hibernate.SQL=DEBUG

persist vs merge vs save

These three operations are the most common source of confusion. persist(entity): makes a NEW (transient) entity managed. The same object becomes managed, so you keep using it. Calling it on a detached entity throws an exception (detached entity passed to persist). merge(entity): copies the state of the given entity onto a managed instance with the same id (loading it with a SELECT if necessary) and returns that managed instance. If no row exists, it inserts a new one. The object you passed in stays as it was, so always use the returned value. save(entity) in Spring Data JPA: calls persist() when the entity is new (id is null, or version is null for entities with @Version) and merge() otherwise. For an entity that is already managed inside a transaction, save() does nothing useful, because dirty checking already tracks it. Practical guidance: - Updating an existing row: load it inside a @Transactional method, modify it, and let dirty checking write the UPDATE. - Receiving a detached object from outside (for example a DTO converted to an entity with an id): prefer loading the managed entity and copying the fields onto it, instead of merging an object built from a request. Merge overwrites every mapped field, including those missing from the request, with null. - Use the return value of save() and merge(); do not keep using the argument.

The Persistence Context

The persistence context is a first-level cache and change tracker. Within a single transaction every managed entity is cached, so a second findById() with the same ID returns the same Java object from the cache, not a second database query. At flush time Hibernate compares every managed entity's current state against its snapshot taken at load time and generates UPDATE statements for any field that changed. In Spring, a transaction-scoped persistence context is created when a @Transactional method starts and closed when it ends. That is why entities become detached as soon as the transactional service method returns (unless Open Session In View keeps the context open; see the pitfalls section).
Java
@Service
@RequiredArgsConstructor
public class PersistenceContextDemo {

    private final ProductRepository productRepo;
    private final EntityManager     em;

    // ── First-level cache ──────────────────────────────────────────────
    @Transactional
    public void firstLevelCache(Long id) {
        Product a = productRepo.findById(id).orElseThrow(); // SELECT
        Product b = productRepo.findById(id).orElseThrow(); // no SELECT — cache hit

        System.out.println(a == b);   // true — same Java object
    }

    // ── Dirty checking — automatic UPDATE on commit ────────────────────
    @Transactional
    public void dirtyChecking(Long id) {
        Product product = productRepo.findById(id).orElseThrow();
        product.setPrice(BigDecimal.valueOf(19.99));
        // No save() call — Hibernate detects the change on flush
        // UPDATE products SET price = 19.99 WHERE id = ?
    }

    // ── Explicit flush — send SQL before transaction commits ──────────
    @Transactional
    public void explicitFlush(Long id) {
        Product product = productRepo.findById(id).orElseThrow();
        product.setPrice(BigDecimal.valueOf(24.99));

        em.flush();     // SQL sent to DB now but not yet committed
        // Useful to surface constraint violations early, or to be sure
        // pending changes are visible to your own SQL in this transaction.
        // (With FlushMode AUTO, Hibernate usually flushes before queries anyway.)
        Number count = (Number) em.createNativeQuery(
            "SELECT COUNT(*) FROM products WHERE price > 20")
            .getSingleResult();
        // Cast to Number: depending on the database and Hibernate version the
        // result is Long or BigInteger, so (Long) can throw ClassCastException.
        long expensive = count.longValue();
    }

    // ── Clear — detach all entities, reset first-level cache ─────────
    @Transactional
    public void batchProcess(List<Long> ids) {
        int count = 0;
        for (Long id : ids) {
            Product product = productRepo.findById(id).orElseThrow();
            product.setPrice(product.getPrice()
                .multiply(BigDecimal.valueOf(0.9)));  // 10% discount
            count++;
            if (count % 50 == 0) {
                em.flush();   // flush batch to DB
                em.clear();   // detach all — prevent OutOfMemoryError
            }
        }
    }
    // For real batch performance also set:
    //   spring.jpa.properties.hibernate.jdbc.batch_size=50
    // IDENTITY id generation disables JDBC insert batching; prefer SEQUENCE.

    // ── Merge detached entity ─────────────────────────────────────────
    @Transactional
    public Product reattach(Product detached) {
        // merge() copies detached state onto the managed instance
        // Returns the managed instance — NOT the parameter
        return em.merge(detached);
    }

    // ── Read-only transactions skip dirty checking ────────────────────
    @Transactional(readOnly = true)
    public Product load(Long id) {
        return productRepo.findById(id).orElseThrow();
        // Hibernate does not flush changes in a read-only transaction,
        // which is cheaper for pure reads.
    }
}

When Does Flush Happen?

Flush is the moment Hibernate synchronizes the persistence context with the database by sending the pending INSERT, UPDATE and DELETE statements. It is not the same as commit: the SQL is sent, but the changes only become permanent when the transaction commits. With the default flush mode (AUTO), a flush happens: 1. When the transaction commits. 2. Before a JPQL, HQL or Criteria query that touches tables with pending changes, so the query sees them. 3. When you call em.flush() (or repository.flush() / saveAndFlush()). Id generation affects timing. With GenerationType.IDENTITY, the INSERT must run immediately on persist() to obtain the id. With GenerationType.SEQUENCE, the id comes from the sequence and the INSERT is delayed until flush, which allows batching. Because of this, lifecycle callbacks such as @PostPersist and @PostUpdate run when the statement is executed, which is not necessarily at commit. If a transaction later rolls back, those callbacks have already run. Do not use them for actions that must only happen after a successful commit (sending e-mails or messages, for example); use @TransactionalEventListener(phase = AFTER_COMMIT) for that.

Lifecycle Callback Annotations

JPA provides seven lifecycle callback annotations. @PrePersist and @PostPersist fire before and after INSERT. @PreUpdate and @PostUpdate fire before and after UPDATE. @PreRemove and @PostRemove fire before and after DELETE. @PostLoad fires after an entity is loaded from the database (or refreshed). Callbacks can be declared on the entity itself or on a separate EntityListener class. Rules to remember: - A callback method must return void, take no arguments (inside the entity), and must not be static or final. It may have any visibility. - Do not call EntityManager methods, run queries, or touch other entities inside a callback. The JPA specification discourages it and it can fail unpredictably. - @PreUpdate runs only when the entity actually changed (is dirty). Loading an entity and changing nothing does not trigger it. - A callback that throws a runtime exception marks the transaction for rollback. - @PostPersist and the other "Post" callbacks run when the SQL is executed, not at commit (see the flush section).
Java
@Entity
@Table(name = "orders")
@Getter @Setter @NoArgsConstructor
@Slf4j
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private OrderStatus status;

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal total;

    @Column(name = "created_at", updatable = false)
    private LocalDateTime createdAt;

    @Column(name = "updated_at")
    private LocalDateTime updatedAt;

    @Transient
    private String displayLabel;       // not persisted — set after load

    // ── @PrePersist — runs before INSERT ──────────────────────────────
    @PrePersist
    protected void onPrePersist() {
        LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
        this.createdAt = now;
        this.updatedAt = now;
        if (this.status == null) {
            this.status = OrderStatus.PENDING;
        }
        log.debug("About to persist order, status={}", status);
    }

    // ── @PostPersist — runs after the INSERT statement executes (id is set) ─
    @PostPersist
    protected void onPostPersist() {
        log.info("Order {} persisted with status {}", id, status);
    }

    // ── @PreUpdate — runs before UPDATE (only if the entity is dirty) ──
    @PreUpdate
    protected void onPreUpdate() {
        this.updatedAt = LocalDateTime.now(ZoneOffset.UTC);
        log.debug("About to update order {}", id);
    }

    // ── @PostUpdate — runs after the UPDATE statement executes ────────
    @PostUpdate
    protected void onPostUpdate() {
        log.info("Order {} updated, new status={}", id, status);
    }

    // ── @PreRemove — runs before DELETE ──────────────────────────────
    @PreRemove
    protected void onPreRemove() {
        log.warn("About to delete order {}", id);
    }

    // ── @PostRemove — runs after the DELETE statement executes ───────
    @PostRemove
    protected void onPostRemove() {
        log.warn("Order {} deleted", id);
    }

    // ── @PostLoad — runs after SELECT loads the entity ────────────────
    @PostLoad
    protected void onPostLoad() {
        // Compute derived / display fields after loading
        this.displayLabel = "Order #" + id + " ("
            + (status == null ? "unknown" : status.name().toLowerCase()) + ")";
    }
}

External EntityListener Classes

Move lifecycle callbacks to a separate class when they need other beans, or when you want to reuse them across entities. Entity classes themselves are created by Hibernate, not by Spring, so @Autowired does not work inside an entity. Entity listeners are different: since Spring Boot 2.1 (with Hibernate 5.3+), Spring Boot plugs Hibernate into the Spring bean container, so an @EntityListeners class can use constructor or @Autowired injection like a normal bean. No static ApplicationContext holder is needed. Two cautions. First, listeners are created while the EntityManagerFactory is starting, so injecting a repository or service that itself depends on the EntityManagerFactory can cause a circular dependency; mark such dependencies @Lazy, or publish a Spring event with ApplicationEventPublisher and handle it elsewhere. Second, a listener method receives the entity as its parameter (for example public void onPostPersist(Object entity)), unlike callbacks declared inside the entity, which take no arguments. For Spring Data auditing (@CreatedDate, @LastModifiedDate) register AuditingEntityListener and add @EnableJpaAuditing to a configuration class.
Java
// ── Entity listener with injected Spring bean (Spring Boot 2.1+) ─────
@Component
public class AuditEntityListener {

    private final ApplicationEventPublisher events;

    // Constructor injection works because Spring Boot sets Hibernate's bean container.
    // For repositories/services that depend on the EntityManagerFactory, use @Lazy:
    //   public AuditEntityListener(@Lazy AuditService auditService) { ... }
    public AuditEntityListener(ApplicationEventPublisher events) {
        this.events = events;
    }

    @PostPersist
    public void onPostPersist(Object entity) {
        events.publishEvent(new EntityCreatedEvent(entity));
    }

    @PostUpdate
    public void onPostUpdate(Object entity) {
        events.publishEvent(new EntityUpdatedEvent(entity));
    }

    @PostRemove
    public void onPostRemove(Object entity) {
        events.publishEvent(new EntityDeletedEvent(entity));
    }
}

// ── React only after the transaction really committed ────────────────
@Component
@Slf4j
public class AuditEventHandler {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onCreated(EntityCreatedEvent event) {
        log.info("Committed: {}", event.entity());
        // safe to send e-mails, messages, etc.
    }
}

// ── Enable Spring Data auditing ───────────────────────────────────────
@Configuration
@EnableJpaAuditing
public class JpaAuditingConfig { }

// ── Register on a @MappedSuperclass ───────────────────────────────────
@MappedSuperclass
@EntityListeners({
    AuditingEntityListener.class,  // Spring Data auditing
    AuditEntityListener.class      // custom event publisher
})
@Getter
public abstract class AuditableEntity {

    @CreatedDate
    @Column(name = "created_at", updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column(name = "updated_at")
    private LocalDateTime updatedAt;
}

// ── Fallback for older setups: static ApplicationContext holder ───────
@Component
public class ApplicationContextHolder
        implements ApplicationContextAware {

    private static ApplicationContext context;

    @Override
    public void setApplicationContext(ApplicationContext ctx) {
        ApplicationContextHolder.context = ctx;
    }

    public static <T> T getBean(Class<T> type) {
        return context.getBean(type);
    }
}

// ── One method can handle several events ──────────────────────────────
public class DomainEventListener {

    @PostPersist
    @PostUpdate
    @PostRemove
    public void onEvent(Object entity) {
        if (entity instanceof DomainEventSource source) {
            ApplicationContextHolder
                .getBean(ApplicationEventPublisher.class)
                .publishEvent(new DomainEntityEvent(source));
        }
    }
}

// Alternative: Spring Data's @DomainEvents with AbstractAggregateRoot
// publishes events when repository.save() is called.

Cascade and Orphan Removal

CascadeType propagates lifecycle operations from a parent entity to its children. PERSIST cascades persist; REMOVE cascades delete; MERGE cascades merge; REFRESH and DETACH cascade those operations; ALL combines all of them. orphanRemoval = true automatically deletes child entities that are removed from the parent's collection, even without an explicit em.remove() call. Things to know: - Cascading a delete loads the children and removes them one by one, which can mean many DELETE statements. For large collections consider a bulk delete query or database-level ON DELETE CASCADE. - With orphanRemoval = true, never replace the collection instance (order.setItems(new ArrayList<>())). Hibernate throws "A collection with cascade=all-delete-orphan was no longer referenced". Modify the existing collection with clear(), add() and remove(), or expose only helper methods. - Cascade only from a parent to children it owns (order → items). Do not cascade REMOVE towards shared entities.
Java
@Entity
@Table(name = "orders")
@Getter
@NoArgsConstructor
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // ── CascadeType.ALL — all lifecycle events propagate to items ─────
    // ── orphanRemoval — removing item from collection deletes the row ─
    @OneToMany(
        mappedBy      = "order",
        cascade       = CascadeType.ALL,
        orphanRemoval = true,
        fetch         = FetchType.LAZY
    )
    private List<OrderItem> items = new ArrayList<>();   // no public setter on purpose

    // Helper — keeps both sides in sync
    public void addItem(OrderItem item) {
        items.add(item);
        item.setOrder(this);
    }

    public void removeItem(OrderItem item) {
        items.remove(item);
        item.setOrder(null);
        // orphanRemoval = true → DELETE issued for item at flush
    }
}

@Entity
@Table(name = "order_items")
@Getter @Setter @NoArgsConstructor
public class OrderItem {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id", nullable = false)
    private Order order;

    @Column(nullable = false)
    private int quantity;

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal unitPrice;
}

// ── Service — cascade in action ────────────────────────────────────────
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepo;

    @Transactional
    public Order create(CreateOrderRequest request) {
        Order order = new Order();
        request.items().forEach(itemReq -> {
            OrderItem item = new OrderItem();
            item.setQuantity(itemReq.quantity());
            item.setUnitPrice(itemReq.unitPrice());
            order.addItem(item);    // CascadeType.PERSIST → items saved too
        });
        return orderRepo.save(order);   // one save — order + all items
    }

    @Transactional
    public void removeItem(Long orderId, Long itemId) {
        Order order = orderRepo.findById(orderId).orElseThrow();
        order.getItems().removeIf(i -> i.getId().equals(itemId));
        // orphanRemoval=true → DELETE FROM order_items WHERE id = itemId
    }

    @Transactional
    public void deleteOrder(Long orderId) {
        orderRepo.deleteById(orderId);
        // CascadeType.REMOVE → items are loaded and deleted (one DELETE each),
        // then the order is deleted
    }
}

Common Lifecycle Pitfalls

Most entity lifecycle bugs come from: accessing lazy collections after the transaction has ended, modifying detached entities and expecting the change to persist, ignoring the return value of save() or merge(), misconfigured cascades, and unsafe equals/hashCode implementations. A note on Open Session In View (OSIV): Spring Boot enables spring.jpa.open-in-view by default, which keeps the persistence context open for the whole web request. In a controller, lazy loading then appears to work even after the service method returned, and the LazyInitializationException shows up only in other places (scheduled jobs, async code, tests, or when OSIV is turned off). It also holds a database connection for longer. Many teams disable it (spring.jpa.open-in-view=false) and load what the view needs explicitly with a fetch join, an entity graph, or a DTO projection.
Java
// ── Pitfall 1: LazyInitializationException ────────────────────────────
@Service
public class OrderService {

    @Transactional(readOnly = true)
    public Order findById(Long id) {
        return orderRepo.findById(id).orElseThrow();
    }   // transaction closes here — items collection is still an uninitialized lazy proxy
}

@RestController
public class OrderController {

    @GetMapping("/{id}")
    public OrderResponse findById(@PathVariable Long id) {
        Order order = orderService.findById(id);
        order.getItems().size();   // ← LazyInitializationException (with OSIV off, or outside a web request)
        return OrderResponse.from(order);
    }
}
// FIX 1 — fetch what you need in the query:
@Query("SELECT o FROM Order o LEFT JOIN FETCH o.items WHERE o.id = :id")
Optional<Order> findByIdWithItems(@Param("id") Long id);

// FIX 2 — entity graph:
@EntityGraph(attributePaths = "items")
Optional<Order> findWithItemsById(Long id);

// FIX 3 — return a DTO from the service, built inside the transaction.
// (Avoid JOIN FETCH on collections together with pagination: Hibernate then paginates in memory.)

// ── Pitfall 2: Modifying a detached entity ────────────────────────────
@Transactional(readOnly = true)
public Order loadForDisplay(Long id) {
    return orderRepo.findById(id).orElseThrow();
}   // entity detached here (when the persistence context closes with the transaction)

public void updateDetached(Long id) {
    Order order = loadForDisplay(id);
    order.setStatus(OrderStatus.SHIPPED);  // change is LOST
    // No exception — just silently ignored
}
// FIX — load and modify within the same transaction:
@Transactional
public void updateStatus(Long id, OrderStatus status) {
    Order order = orderRepo.findById(id).orElseThrow();
    order.setStatus(status);
}   // dirty check flushes UPDATE on commit

// ── Pitfall 3: Ignoring the return value of save() / merge() ─────────
@Transactional
public void wrong(Order detached) {
    orderRepo.save(detached);          // returns a NEW managed instance
    detached.setStatus(OrderStatus.CANCELLED);   // changes the detached object — not saved
}
@Transactional
public Order right(Order detached) {
    Order managed = orderRepo.save(detached);
    managed.setStatus(OrderStatus.CANCELLED);    // tracked
    return managed;
}

// ── Pitfall 4: Unintended cascade delete ─────────────────────────────
// CascadeType.ALL (or REMOVE) on a @ManyToMany means deleting ONE product
// also deletes its tags, even though other products still use them
// (a foreign key error, or lost shared data):
@ManyToMany(cascade = CascadeType.ALL)   // WRONG for ManyToMany
private Set<Tag> tags;

// FIX — use only PERSIST and MERGE on @ManyToMany:
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private Set<Tag> tags;

// ── Pitfall 5: equals/hashCode on mutable fields or proxy fields ─────
// WRONG — using a mutable name field:
@Override public boolean equals(Object o) {
    return o instanceof Product p && name.equals(p.name);
}
// FIX — compare the database id, via the getter so Hibernate proxies work
// (reading p.id directly on a lazy proxy returns null):
@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Product p)) return false;
    return id != null && id.equals(p.getId());
}
@Override
public int hashCode() {
    return getClass().hashCode();   // stable across transient → managed
}

// ── Pitfall 6: Replacing an orphanRemoval collection ─────────────────
order.setItems(new ArrayList<>());   // HibernateException: A collection with
                                     // cascade="all-delete-orphan" was no longer referenced
order.getItems().clear();            // correct

Frequently Asked Questions

What are the entity states in JPA? Transient (new, not tracked), managed (tracked by a persistence context), detached (was managed, no longer tracked) and removed (scheduled for deletion). Do I need to call save() to update an entity? Not inside a @Transactional method when the entity was loaded in that transaction. It is managed, so dirty checking writes the UPDATE at flush. Call save() for new entities and when you work with a detached entity (and use the returned instance). What is the difference between persist() and merge()? persist() makes a new entity managed and keeps the same object. merge() copies the state of a possibly detached object onto a managed instance and returns that instance; the argument stays detached. What does detached mean? The entity has an identity (an id) but is not associated with any open persistence context, so changes are not tracked and lazy associations cannot be loaded. Why do I get LazyInitializationException? You accessed a lazy association or collection after the persistence context closed. Fetch the data inside the transaction using a fetch join, an entity graph or a DTO projection. When is @PreUpdate called? Before an UPDATE statement is executed, and only if the entity is dirty. If nothing changed, no UPDATE is issued and the callback does not run. Does @PostPersist mean the data is committed? No. It runs after the INSERT executes, which can be before commit. If the transaction rolls back, the callback has still run. Use @TransactionalEventListener(AFTER_COMMIT) for post-commit actions. Can I inject Spring beans into an entity listener? Yes, in Spring Boot 2.1 and later, because Hibernate uses the Spring bean container. Use @Lazy for dependencies that would create a circular dependency with the EntityManagerFactory. Entities themselves cannot use injection. What does em.flush() do? It sends pending SQL to the database without committing. The first-level cache stays intact. em.clear() is what detaches all managed entities. What is the first-level cache? The persistence context itself. Within one transaction, loading the same entity id twice returns the same object without a second query. What is the difference between orphanRemoval and CascadeType.REMOVE? CascadeType.REMOVE deletes children when the parent is deleted. orphanRemoval also deletes a child when it is removed from the parent's collection.

Key Takeaways

- Four states: transient, managed, detached, removed. Only managed entities are tracked and dirty-checked. - Load, modify, commit: inside a @Transactional method you rarely need save() on an existing entity. - merge() and save() on a detached entity return a new managed instance; always use the return value. - Flush sends SQL, commit makes it permanent; @Post* callbacks run at SQL execution, so use AFTER_COMMIT events for side effects. - Entity listeners can use Spring injection in Spring Boot 2.1+; use @Lazy to avoid circular dependencies. - Cascade from parent to owned children only, never REMOVE on shared @ManyToMany targets, and never replace an orphanRemoval collection. - Fix LazyInitializationException with fetch joins, entity graphs or DTOs, and consider disabling open-in-view. - Related topics: JPA Relationships, Transactions and Propagation, N+1 Query Problem, Spring Data Auditing, Optimistic Locking.