Spring BootEntity Lifecycle
Spring Boot

Entity Lifecycle

JPA entities move through four states — transient, managed, detached, and removed — as they interact with the EntityManager and the persistence context. Understanding the lifecycle is essential for avoiding LazyInitializationException, unexpected updates, detached entity errors, and lost changes. This entry covers all four states, every lifecycle callback, the persistence context, and common pitfalls.

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.
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, tracked for changes

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

        Long id = product.getId();

        // ── 3. DETACHED — context closed or explicit detach ────────────
        em.detach(product);
        // product is now detached: changes are NOT tracked

        product.setName("Super Widget");
        // This change will be LOST — entity is not managed

        // Re-attach by merging — returns a new managed instance
        Product managed = em.merge(product);
        // managed is now tracked; product (the original) is still detached

        // ── 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 → persist (transient → managed)
        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);
    }
}

The Persistence Context

The persistence context is a first-level cache and change tracker. Within a single transaction every managed entity is cached — 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.
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 before a native query that reads the same table
        Long count = (Long) em.createNativeQuery(
            "SELECT COUNT(*) FROM products WHERE price > 20")
            .getSingleResult();
    }

    // ── 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
            }
        }
    }

    // ── 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);
    }
}

Lifecycle Callback Annotations

JPA provides six 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. Callbacks can be on the entity itself or on a separate EntityListener class.
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 INSERT (id is now set) ─────────────
    @PostPersist
    protected void onPostPersist() {
        log.info("Order {} persisted with status {}", id, status);
    }

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

    // ── @PostUpdate — runs after UPDATE ──────────────────────────────
    @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 DELETE ──────────────────────────────
    @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.name().toLowerCase() + ")";
    }
}

External EntityListener Classes

Move lifecycle callbacks to a separate class when they need Spring beans injected — entity classes cannot be Spring beans, so @Autowired does not work inside them directly. Register the listener with @EntityListeners on the entity or @MappedSuperclass. Use SpringBeanAutowiringSupport or a static ApplicationContext holder to access Spring beans from an EntityListener.
Java
// ── Entity listener with Spring bean access ──────────────────────────
@Component
public class AuditEntityListener {

    // Static holder — EntityListener instances are not Spring beans
    private static ApplicationContext context;

    @Autowired
    public void setApplicationContext(ApplicationContext ctx) {
        AuditEntityListener.context = ctx;
    }

    private AuditEventPublisher publisher() {
        return context.getBean(AuditEventPublisher.class);
    }

    @PostPersist
    public void onPostPersist(Object entity) {
        publisher().publish(new EntityCreatedEvent(entity));
    }

    @PostUpdate
    public void onPostUpdate(Object entity) {
        publisher().publish(new EntityUpdatedEvent(entity));
    }

    @PostRemove
    public void onPostRemove(Object entity) {
        publisher().publish(new EntityDeletedEvent(entity));
    }
}

// ── 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;
}

// ── ApplicationContext holder (alternative approach) ──────────────────
@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);
    }
}

// ── Use in any EntityListener ─────────────────────────────────────────
public class DomainEventListener {

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

Cascade and Orphan Removal

CascadeType propagates lifecycle operations from a parent entity to its children. PERSIST cascades save; REMOVE cascades delete; MERGE cascades re-attachment; ALL combines all types. orphanRemoval = true automatically deletes child entities that are removed from the parent's collection, even without an explicit em.remove() call.
Java
@Entity
@Table(name = "orders")
@Getter @Setter @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<>();

    // 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 → DELETE all order_items first, then order
    }
}

Common Lifecycle Pitfalls

Most entity lifecycle bugs stem from accessing lazy collections outside a transaction, modifying detached entities and expecting the change to persist, calling @Transactional methods internally (bypassing the proxy), and cascade misconfiguration causing accidental deletes or missing inserts.
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 still lazy
}

@RestController
public class OrderController {

    @GetMapping("/{id}")
    public OrderResponse findById(@PathVariable Long id) {
        Order order = orderService.findById(id);
        order.getItems().size();   // ← LazyInitializationException
    }
}
// FIX — use a fetch join or DTO projection in the repository:
@Query("SELECT o FROM Order o LEFT JOIN FETCH o.items WHERE o.id = :id")
Optional<Order> findByIdWithItems(@Param("id") Long id);

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

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: Unintended cascade delete ─────────────────────────────
// CascadeType.ALL on a @ManyToMany will delete the shared entities:
@ManyToMany(cascade = CascadeType.ALL)   // WRONG for ManyToMany
private Set<Tag> tags;
// Removing a tag from one product deletes it from ALL products

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

// ── Pitfall 4: equals/hashCode on mutable fields ─────────────────────
// WRONG — using mutable name field:
@Override public boolean equals(Object o) {
    return o instanceof Product p && name.equals(p.name);
}
// FIX — use the database ID (handle null for transient entities):
@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Product p)) return false;
    return id != null && id.equals(p.id);
}
@Override
public int hashCode() {
    return getClass().hashCode();   // stable across transient → managed
}