Auditing & Soft Deletes

@CreatedDate/@LastModifiedDate with @EntityListeners, and implementing soft deletes with @SQLDelete/@SQLRestriction.

Automatic timestamps with @CreatedDate/@LastModifiedDate

Recording who created a row and when, and when it was last touched, is needed on almost every entity in a real application. Spring Data JPA automates this instead of setting these fields by hand in every service method.

Enable auditing once, on a configuration class:

Java
@Configuration
@EnableJpaAuditing
public class JpaAuditingConfig {
}

Add a shared base class entities can extend, annotated @EntityListeners:

Java
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class Auditable {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;

    public LocalDateTime getCreatedAt() { return createdAt; }
    public LocalDateTime getUpdatedAt() { return updatedAt; }
}
Java
@Entity
public class Book extends Auditable {

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

    private String title;
    private String author;

    // ...
}

@EntityListeners(AuditingEntityListener.class) hooks into JPA's lifecycle callbacks (@PrePersist, @PreUpdate) to populate createdAt/updatedAt automatically on every insert/update, with no code in Book itself or any service that saves one.

Tracking who: @CreatedBy/@LastModifiedBy

The same mechanism can capture who made a change, given an AuditorAware bean that tells Spring how to determine the "current" user for any given operation:

Java
@Bean
public AuditorAware<String> auditorProvider() {
    return () -> Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
        .map(Authentication::getName);
}
Java
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class Auditable {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;

    @CreatedBy
    @Column(updatable = false)
    private String createdBy;

    @LastModifiedBy
    private String updatedBy;
}

Reading the currently-authenticated principal straight out of SecurityContextHolder ties this cleanly into Spring Security (see that track) — the same authentication that authorized the request is what gets recorded as having made the change.

Soft deletes: @SQLDelete and @SQLRestriction

A hard DELETE destroys data permanently — often undesirable for records with audit, compliance, or "undo" requirements. A soft delete instead flags a row as deleted and filters it out of ordinary queries, while leaving the actual row intact in the database:

Java
@Entity
@SQLDelete(sql = "UPDATE books SET deleted = true WHERE id = ?")
@SQLRestriction("deleted = false")
public class Book {

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

    private String title;
    private String author;

    private boolean deleted = false;
}

@SQLDelete overrides what SQL Hibernate actually issues when repository.delete(book) is called — an UPDATE instead of a real DELETE. @SQLRestriction transparently appends AND deleted = false to every query Hibernate generates against this entity (via JpaRepository, derived queries, JPQL — all of it), so soft-deleted rows are invisible everywhere without a single WHERE deleted = false written by hand anywhere in application code.

Java
bookRepository.delete(book);                 // issues an UPDATE, not a DELETE -- row stays in the table
bookRepository.findAll();                     // never returns rows where deleted = true
bookRepository.findById(book.getId());        // returns empty once soft-deleted, same as if it were gone

@SQLRestriction is the current (Hibernate 6.3+) annotation for this; older code (and Hibernate 5.x/early 6.x) uses the now-deprecated @Where(clause = "deleted = false"), which works identically but is no longer the recommended spelling.

Common mistakes

  • Forgetting @EnableJpaAuditing — without it, @CreatedDate/@LastModifiedDate fields are silently never populated, with no error to point at the missing piece.
  • Applying @SQLRestriction but still writing a manual deleteById-based hard-delete path elsewhere in the codebase that bypasses the entity's overridden @SQLDelete behavior (e.g. a native/bulk query), leaving soft-delete semantics inconsistently enforced.
  • Not indexing the deleted column on a large, frequently-queried soft-deleted table — every query now implicitly filters on it, so it deserves the same indexing consideration as any other frequent WHERE clause.
  • Assuming a unique constraint (e.g. on email) still behaves sensibly after soft deletes accumulate — a soft-deleted row still occupies its unique value, which can block a legitimate new row from reusing it unless the constraint is deliberately scoped to exclude deleted rows.