Spring Data JPA: Soft Delete, Entity Listeners & @EntityGraph

Soft delete keeps rows in the database with a deleted flag instead of physically removing them, enabling audit trails and data recovery. Entity listeners hook into JPA lifecycle events to run cross-cutting logic. @EntityGraph controls fetch plans to eliminate N+1 query problems. Together these three patterns cover a large share of real-world JPA challenges.

Contents

Soft Delete with @SQLDelete & @SQLRestriction

Hibernate's @SQLDelete replaces the physical DELETE with a custom SQL UPDATE that sets a deleted_at column. @SQLRestriction (Hibernate 6 / Spring Boot 3) automatically appends a WHERE clause to every query so soft-deleted rows are invisible.

Java
@Entity
@Table(name = "products")
@SQLDelete(sql = "UPDATE products SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")   // Hibernate 6+ (Spring Boot 3)
// For Hibernate 5 / Spring Boot 2.x: @Where(clause = "deleted_at IS NULL")
public class Product {

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

    private String name;
    private BigDecimal price;

    @Column(name = "deleted_at")
    private Instant deletedAt;   // null = active, non-null = soft-deleted

    // getters / setters
}
Java
public interface ProductRepository extends JpaRepository<Product, Long> {
    // All standard methods automatically exclude deleted_at IS NOT NULL rows
    List<Product> findByPriceLessThan(BigDecimal price);
}
Java
@Service
@Transactional
public class ProductService {

    private final ProductRepository repo;

    // "Deletes" the product — runs UPDATE SET deleted_at = NOW()
    public void softDelete(Long id) {
        repo.deleteById(id);
    }

    // Returns only active products (deleted_at IS NULL)
    public List<Product> findAll() {
        return repo.findAll();
    }
}

Restoring Soft-Deleted Records

Since Spring Data filters out soft-deleted rows, you need native queries to find and restore them.

Java
public interface ProductRepository extends JpaRepository<Product, Long> {

    // Find all deleted products — bypass the @SQLRestriction filter
    @Query(value = "SELECT * FROM products WHERE deleted_at IS NOT NULL",
           nativeQuery = true)
    List<Product> findAllDeleted();

    // Find a specific product including deleted ones
    @Query(value = "SELECT * FROM products WHERE id = :id",
           nativeQuery = true)
    Optional<Product> findByIdIncludingDeleted(@Param("id") Long id);

    // Restore — clear the deleted_at timestamp
    @Modifying
    @Query(value = "UPDATE products SET deleted_at = NULL WHERE id = :id",
           nativeQuery = true)
    void restoreById(@Param("id") Long id);
}

Soft Delete with Relationships

Apply @SQLRestriction on the owning entity; Hibernate propagates the filter through joins automatically for eager/lazy associations.

Java
@Entity
@Table(name = "orders")
@SQLDelete(sql = "UPDATE orders SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class Order {

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

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderItem> items = new ArrayList<>();

    @Column(name = "deleted_at")
    private Instant deletedAt;
}

// OrderItem also soft-deletable
@Entity
@Table(name = "order_items")
@SQLDelete(sql = "UPDATE order_items SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class OrderItem {

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

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

    private String productId;
    private Integer quantity;

    @Column(name = "deleted_at")
    private Instant deletedAt;
}

Entity Listeners & Lifecycle Callbacks

JPA lifecycle callbacks let you run logic at specific points in an entity's lifecycle: validation, default value initialisation, cache invalidation, and event publishing.

AnnotationWhen Called
@PrePersistBefore INSERT
@PostPersistAfter INSERT (id is assigned)
@PreUpdateBefore UPDATE
@PostUpdateAfter UPDATE
@PreRemoveBefore DELETE
@PostRemoveAfter DELETE
@PostLoadAfter SELECT (entity loaded into context)
Java
public class ProductEventListener {

    @PrePersist
    public void beforeCreate(Product product) {
        if (product.getPrice() == null || product.getPrice().compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Product price must be non-negative");
        }
    }

    @PostPersist
    public void afterCreate(Product product) {
        // Publish domain event — Spring ApplicationEventPublisher via static holder
        SpringContext.publishEvent(new ProductCreatedEvent(product.getId()));
    }

    @PostLoad
    public void afterLoad(Product product) {
        // Decrypt sensitive fields loaded from DB
        // e.g., product.setVendorCode(cipher.decrypt(product.getVendorCode()));
    }
}
Java
@Entity
@Table(name = "products")
@EntityListeners(ProductEventListener.class)
@SQLDelete(sql = "UPDATE products SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class Product {
    // ...
}

@EntityGraph: Controlling Fetch Plans

By default JPA uses LAZY loading for @OneToMany and @ManyToMany. An @EntityGraph on a repository method switches specific associations to EAGER for that query only, without changing the entity mapping.

Java
@Entity
@Table(name = "orders")
@NamedEntityGraph(
    name = "Order.withItemsAndCustomer",
    attributeNodes = {
        @NamedAttributeNode("items"),
        @NamedAttributeNode("customer")
    }
)
public class Order {

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id")
    private Customer customer;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderItem> items = new ArrayList<>();

    private OrderStatus status;
    private BigDecimal total;
}
Java
public interface OrderRepository extends JpaRepository<Order, Long> {

    // Use the named graph — fetches items + customer in a single JOIN query
    @EntityGraph("Order.withItemsAndCustomer")
    Optional<Order> findById(Long id);

    // Ad-hoc graph — no need for @NamedEntityGraph on the entity
    @EntityGraph(attributePaths = {"items", "customer"})
    List<Order> findByStatus(OrderStatus status);

    // Only fetch items (not customer) for the order list page
    @EntityGraph(attributePaths = "items")
    List<Order> findByCustomerId(Long customerId);
}

N+1 Detection & Prevention

The N+1 problem: fetching 100 orders then lazy-loading each order's items fires 101 SQL queries. Detection and prevention strategies:

YAML
# application.yml — log SQL with parameters for development
spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true
        generate_statistics: true   # prints query count on session close
XML
<!-- pom.xml: p6spy for detailed SQL logging including actual bind values -->
<dependency>
  <groupId>com.github.gavlyukovskiy</groupId>
  <artifactId>datasource-proxy-spring-boot-starter</artifactId>
  <version>1.9.1</version>
</dependency>
Java
// ❌ N+1 — loads all orders then fires a query per order for its items
List<Order> orders = orderRepo.findAll();
orders.forEach(o -> o.getItems().size());  // N extra queries!

// ✅ Fix 1: @EntityGraph — JOIN FETCH in one query
@EntityGraph(attributePaths = "items")
List<Order> findAll();

// ✅ Fix 2: JPQL JOIN FETCH
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items WHERE o.status = :status")
List<Order> findByStatusWithItems(@Param("status") OrderStatus status);

// ✅ Fix 3: @BatchSize — issues one IN query instead of N queries
@OneToMany(mappedBy = "order")
@BatchSize(size = 30)    // loads items for up to 30 orders per query
private List<OrderItem> items;
Never use FetchType.EAGER on @OneToMany or @ManyToMany. It causes Cartesian products for multiple eager collections and loads associations even when you don't need them. Use @EntityGraph per query instead.