Back to Entity Lifecycle & Auditing
Q2Question 2 of 2

What are entity lifecycle callbacks?

Key Concepts
  • @PrePersist fires before INSERT and is used to set defaults like creation timestamps and reference numbers.
  • @PostPersist fires after INSERT when the generated ID is available, ideal for publishing domain events.
  • @PreUpdate fires before UPDATE only if dirty checking detects actual changes to the entity.
  • @PreRemove fires before DELETE for cleanup, logging, or soft-delete interception.
  • @PostLoad fires after an entity is loaded from the database, ideal for computing @Transient derived fields.
  • Callbacks must not call EntityManager operations (no queries, no flush) to avoid recursion.
  • For cross-cutting logic across entities, use a shared listener class registered with @EntityListeners.

Definition

JPA lifecycle callbacks are annotated methods that fire automatically around INSERT, UPDATE, DELETE, and LOAD operations without any manual invocation.
  • @PrePersist -- fires before the INSERT is flushed, used to set creation defaults like timestamps and reference numbers.
  • @PostPersist -- fires after the INSERT when the generated ID is available, used for event publishing and logging.
  • @PreUpdate -- fires before an UPDATE is flushed, only if Hibernate's dirty checking detected actual changes.
  • @PostUpdate -- fires after the UPDATE completes.
  • @PreRemove -- fires before the DELETE is flushed, used for cleanup, audit logging, or soft-delete interception.
  • @PostRemove -- fires after the DELETE completes.
  • @PostLoad -- fires after an entity is loaded from the database, used for computing @Transient derived fields.
  • @EntityListeners -- registers external listener classes for cross-cutting callback logic shared across entities.

At National Bank of India, lifecycle callbacks are automatic stamps the system puts on a form at each stage. @PrePersist is the stamp right before a new account form is filed: "set the open date, generate the reference number." @PostPersist is the confirmation stamp after filing: "send the welcome SMS." @PreUpdate stamps just before edits are saved: "record the modification time." @PreRemove stamps before a file is archived: "mark as closed, notify compliance." @PostLoad is what happens the moment a clerk pulls a file from the cabinet: "compute the display balance." Nobody tells the clerk each time; these happen automatically.

Understanding the Concepts

What are JPA lifecycle callbacks?

JPA lifecycle callbacks are methods annotated to run automatically at specific points in an entity's persistence lifecycle. They fire without any manual invocation from service code. This moves cross-cutting persistence logic out of the service layer and into a predictable, centralized place.

  • Seven callback annotations cover the full lifecycle: create, update, delete, and load.
  • Callbacks can be placed directly on entity methods or in separate listener classes.
  • They are called by the JPA provider (Hibernate) during flush or load operations.
JAVA
package com.bank.model;

import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;

@Entity
@Table(name = "transactions")
public class Transaction {

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

    @Column(name = "reference", unique = true)
    private String reference;

    @Column(name = "account_id")
    private String accountId;

    @Column(precision = 15, scale = 2)
    private BigDecimal amount;

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

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

    @PrePersist
    public void onPrePersist() {
        this.createdAt = Instant.now();
        this.updatedAt = Instant.now();
        this.reference = "TXN-" + UUID.randomUUID().toString()
            .substring(0, 8).toUpperCase();
    }

    @PreUpdate
    public void onPreUpdate() {
        this.updatedAt = Instant.now();
    }
}
TEXT
Callback firing order for a new transaction:
1. new Transaction()          -> just a Java object (transient)
2. em.persist(transaction)    -> @PrePersist fires
   createdAt = 2024-01-15T10:30:00Z
   reference = "TXN-A1B2C3D4"
3. flush / commit             -> INSERT INTO transactions ...
4. After INSERT               -> @PostPersist fires
   Generated ID is now available

How does @PrePersist work?

@PrePersist fires before the INSERT is issued to the database. It runs during the flush cycle, before Hibernate generates the SQL. This is the correct place to set creation defaults, generate reference numbers, validate invariants, or compute initial values.

JAVA
package com.bank.model;

import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;

@Entity
@Table(name = "accounts")
public class Account {

    @Id
    @Column(name = "account_id")
    private String accountId;

    @Column(name = "holder_name")
    private String holderName;

    @Column(precision = 15, scale = 2)
    private BigDecimal balance;

    @Column(name = "status")
    private String status;

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

    @PrePersist
    public void setDefaults() {
        if (this.status == null) {
            this.status = "ACTIVE";
        }
        if (this.balance == null) {
            this.balance = BigDecimal.ZERO;
        }
        this.createdAt = Instant.now();
    }
}
TEXT
Before @PrePersist:
  status = null, balance = null, createdAt = null

After @PrePersist:
  status = "ACTIVE", balance = 0.00, createdAt = 2024-01-15T10:30:00Z

SQL:
  INSERT INTO accounts (account_id, holder_name, balance, status, created_at)
  VALUES ('SAV2001', 'Rajesh Kumar', 0.00, 'ACTIVE', '2024-01-15T10:30:00Z')

How does @PostPersist work?

@PostPersist fires after the INSERT completes and the generated ID is available. This is the correct place to publish domain events, send notifications, or log the creation.

JAVA
package com.bank.model;

import jakarta.persistence.*;
import java.math.BigDecimal;
import java.util.logging.Logger;

@Entity
@Table(name = "transactions")
public class Transaction {

    private static final Logger LOG =
        Logger.getLogger(Transaction.class.getName());

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

    @Column(name = "account_id")
    private String accountId;

    @Column(precision = 15, scale = 2)
    private BigDecimal amount;

    @PostPersist
    public void afterInsert() {
        LOG.info("Transaction created: id=" + id
            + ", account=" + accountId
            + ", amount=" + amount);
        // Generated ID is now available
        // Publish domain event: new TransactionCreatedEvent(id)
    }
}
TEXT
Output after INSERT:
  INFO: Transaction created: id=42, account=SAV2001, amount=50000.00

How does @PreUpdate work?

@PreUpdate fires before an UPDATE is flushed. An important detail is that it only fires if Hibernate's dirty checking actually detects a change. If nothing changed, no UPDATE is generated and @PreUpdate does not fire.

JAVA
package com.bank.model;

import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;

@Entity
@Table(name = "accounts")
public class Account {

    @Id
    @Column(name = "account_id")
    private String accountId;

    @Column(precision = 15, scale = 2)
    private BigDecimal balance;

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

    @PreUpdate
    public void onPreUpdate() {
        this.updatedAt = Instant.now();
    }
}
JAVA
package com.bank.service;

import com.bank.model.Account;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class BalanceUpdateService {

    @PersistenceContext
    private EntityManager em;

    @Transactional
    public void credit(String accountId, BigDecimal amount) {
        Account account = em.find(Account.class, accountId);
        account.setBalance(account.getBalance().add(amount));
        // Dirty checking detects balance change
        // @PreUpdate fires: updatedAt = now()
        // UPDATE SET balance=?, updated_at=? WHERE account_id=?
    }

    @Transactional
    public void noOpUpdate(String accountId) {
        Account account = em.find(Account.class, accountId);
        // No field changed
        // Dirty checking finds nothing to update
        // @PreUpdate does NOT fire
        // No UPDATE generated
    }
}

How does @PostLoad work?

@PostLoad fires after an entity is loaded from the database, whether through find(), a JPQL query, or a collection load. This is the correct place to compute @Transient derived fields.

JAVA
package com.bank.model;

import jakarta.persistence.*;
import java.math.BigDecimal;

@Entity
@Table(name = "accounts")
public class Account {

    @Id
    @Column(name = "account_id")
    private String accountId;

    @Column(name = "first_name")
    private String firstName;

    @Column(name = "last_name")
    private String lastName;

    @Column(precision = 15, scale = 2)
    private BigDecimal balance;

    @Column(name = "hold_amount", precision = 15, scale = 2)
    private BigDecimal holdAmount;

    @Transient
    private String fullName;

    @Transient
    private BigDecimal availableBalance;

    @PostLoad
    public void computeDerivedFields() {
        this.fullName = firstName + " " + lastName;
        this.availableBalance = balance.subtract(
            holdAmount != null ? holdAmount : BigDecimal.ZERO
        );
    }
}
TEXT
After loading account SAV2001:
  firstName = "Rajesh", lastName = "Kumar"
  balance = 150000.00, holdAmount = 10000.00

@PostLoad computes:
  fullName = "Rajesh Kumar"
  availableBalance = 140000.00

These fields exist only in Java memory, not in the database.

How do you use @EntityListeners for shared logic?

When the same callback logic applies across many entities, extract it into a separate listener class and register it with @EntityListeners. The listener methods receive the entity as a parameter.

JAVA
package com.bank.audit;

import jakarta.persistence.*;
import java.time.Instant;
import java.util.logging.Logger;

public class AuditListener {

    private static final Logger LOG =
        Logger.getLogger(AuditListener.class.getName());

    @PrePersist
    public void onPrePersist(Object entity) {
        LOG.info("Creating: " + entity.getClass().getSimpleName());
    }

    @PreUpdate
    public void onPreUpdate(Object entity) {
        LOG.info("Updating: " + entity.getClass().getSimpleName());
    }

    @PreRemove
    public void onPreRemove(Object entity) {
        LOG.info("Removing: " + entity.getClass().getSimpleName());
    }
}
JAVA
package com.bank.model;

import com.bank.audit.AuditListener;
import jakarta.persistence.*;
import java.math.BigDecimal;

@Entity
@Table(name = "accounts")
@EntityListeners(AuditListener.class)
public class Account {

    @Id
    @Column(name = "account_id")
    private String accountId;

    @Column(precision = 15, scale = 2)
    private BigDecimal balance;
}

@Entity
@Table(name = "transactions")
@EntityListeners(AuditListener.class)
public class Transaction {

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

    @Column(precision = 15, scale = 2)
    private BigDecimal amount;
}
// Both entities share the same audit logging via AuditListener

What are common mistakes with lifecycle callbacks?

The most common mistakes involve calling EntityManager from within callbacks and placing heavy logic in callback methods.

JAVA
// WRONG: calling EntityManager inside a callback
@PrePersist
public void onPrePersist() {
    // DO NOT DO THIS!
    Account existing = em.find(Account.class, this.accountId);
    // Triggers a query inside the flush cycle
    // Can cause recursion, ConcurrentModificationException,
    // or undefined behavior
}

// CORRECT: validate without EntityManager calls
@PrePersist
public void onPrePersist() {
    if (this.balance == null) {
        this.balance = BigDecimal.ZERO;
    }
    this.createdAt = Instant.now();
    // Only set fields on THIS entity. No queries.
}
JAVA
// WRONG: sending email in @PostPersist (blocks flush)
@PostPersist
public void afterInsert() {
    emailService.sendWelcomeEmail(this.holderName, this.email);
    // If email service is slow, the transaction is delayed
    // If email fails, the transaction might roll back
}

// CORRECT: publish an event, handle asynchronously
@PostPersist
public void afterInsert() {
    DomainEventPublisher.publish(
        new AccountCreatedEvent(this.accountId)
    );
    // Lightweight: just publishes an event
    // Async consumer sends the email outside this transaction
}

What is the callback firing order?

The callbacks fire in a specific order depending on the operation. For a complete lifecycle, the order is:

TEXT
New entity creation:
  1. @PrePersist
  2. INSERT SQL
  3. @PostPersist

Entity load:
  1. SELECT SQL
  2. @PostLoad

Entity update:
  1. @PostLoad (when entity is first loaded)
  2. ... (field modifications in Java)
  3. @PreUpdate (on flush, if dirty)
  4. UPDATE SQL
  5. @PostUpdate

Entity deletion:
  1. @PostLoad (when entity is loaded)
  2. @PreRemove
  3. DELETE SQL
  4. @PostRemove

With @EntityListeners:
  Listener callbacks fire BEFORE entity callbacks.
  Example: AuditListener.onPrePersist() -> Account.onPrePersist()

What about @PreRemove and soft deletes?

@PreRemove fires before the DELETE is issued. In banking, physical deletion is rare. Instead, @PreRemove can intercept the delete and convert it to a soft delete by setting a status flag.

JAVA
package com.bank.model;

import jakarta.persistence.*;
import java.time.Instant;

@Entity
@Table(name = "accounts")
public class Account {

    @Id
    @Column(name = "account_id")
    private String accountId;

    @Column(name = "status")
    private String status;

    @Column(name = "closed_at")
    private Instant closedAt;

    @PreRemove
    public void onPreRemove() {
        // Log before deletion
        System.out.println("Account " + accountId
            + " is being removed. Status: " + status);
        // In banking, consider soft-delete instead:
        // this.status = "CLOSED";
        // this.closedAt = Instant.now();
        // But note: @PreRemove cannot PREVENT the delete.
        // For true soft-delete, use @SQLDelete annotation
        // or handle in the service layer.
    }
}

What should a senior engineer know about callbacks?

Senior engineers should understand the limitations and best practices for callbacks in production systems.

  • Callbacks must not call EntityManager operations. No queries, no flush, no persist of other entities.
  • Keep callbacks lightweight. Heavy operations like email, HTTP calls, or file I/O should be handled asynchronously via events.
  • @PreUpdate only fires on dirty entities. If nothing changed, no callback fires and no UPDATE is generated.
  • Listener callbacks fire before entity callbacks. Use this ordering to separate cross-cutting concerns from entity-specific logic.
  • In Spring Boot, you can use @RegisterDefaultEntityCallbackListeners to apply listeners globally.
JAVA
// Summary of callback rules:
// 1. Keep callbacks side-effect-light (set fields, publish events)
// 2. Never call EntityManager from callbacks
// 3. Never throw checked exceptions from callbacks
// 4. Use @EntityListeners for cross-cutting logic
// 5. Use @PostPersist/@PostUpdate + events for heavy async work
// 6. @PreUpdate only fires if entity is actually dirty

Visual Overview

DIAGRAM
  Entity Lifecycle Callbacks:

  ┌──────────┐   @PrePersist   ┌────────┐   @PostPersist   ┌──────────┐
  │ Transient │ ──────────────> │ INSERT │ ──────────────> │ Managed  │
  │ (new)     │  set defaults   │  SQL   │  log / event    │          │
  └──────────┘                 └────────┘                 └──────────┘
       │                                                       │
       │                                                       │
       │         @PostLoad                                     │
       │  <────────────────  (on find / query)                 │
       │   compute @Transient                                  │
       │                                                       │
       │                    @PreUpdate    ┌────────┐   @PostUpdate
       │                  ──────────────> │ UPDATE │ ──────────────>
       │                   set updatedAt  │  SQL   │   log / event
       │                                 └────────┘
       │
       │                    @PreRemove    ┌────────┐   @PostRemove
       │                  ──────────────> │ DELETE │ ──────────────>
       │                   audit / check  │  SQL   │   cleanup
       │                                 └────────┘

  Firing order with @EntityListeners:
  ┌──────────────┐    ┌──────────────┐
  │ Listener     │ -> │ Entity       │
  │ callback     │    │ callback     │
  │ (fires FIRST)│    │ (fires SECOND)│
  └──────────────┘    └──────────────┘

Key Takeaway

JPA lifecycle callbacks are automatic hooks that fire at well-defined points in an entity's persistence lifecycle, removing the need to remember to call them from service code. @PrePersist runs before the insert and is where you set creation timestamps and generate reference numbers. @PostPersist runs after the insert when the generated ID exists, making it ideal for logging or publishing domain events. @PreUpdate runs before an update but only if Hibernate's dirty checking detected an actual change.

@PostLoad is the callback developers most often forget. It runs immediately after an entity is loaded from the database, making it the correct place to compute derived @Transient fields like available balance (balance minus hold amount) or full name (first name plus last name). In our National Bank of India transaction service, @PrePersist guarantees every transaction gets a creation timestamp and a unique TXN reference before insertion, eliminating a whole class of bugs where developers forgot to set those fields.

For cross-cutting logic that applies across many entities, move the callback methods into a shared listener class registered with @EntityListeners. The listener's callbacks fire before the entity's own callbacks, providing a clean separation between generic audit logging and entity-specific initialization. The critical rule is that callbacks must stay lightweight and must never call EntityManager to run queries or force a flush, because that can cause recursion inside the flush cycle.

Remember one sentence: lifecycle callbacks fire automatically around INSERT, UPDATE, DELETE, and LOAD so you never forget to set defaults, stamp timestamps, or compute derived fields, but keep them lightweight and never call EntityManager from within them.

---

Share
Use ← → arrow keys to navigate