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:
@Configuration
@EnableJpaAuditing
public class JpaAuditingConfig {
}
Add a shared base class entities can extend, annotated @EntityListeners:
@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; }
}
@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:
@Bean
public AuditorAware<String> auditorProvider() {
return () -> Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.map(Authentication::getName);
}
@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:
@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.
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/@LastModifiedDatefields are silently never populated, with no error to point at the missing piece. - Applying
@SQLRestrictionbut still writing a manualdeleteById-based hard-delete path elsewhere in the codebase that bypasses the entity's overridden@SQLDeletebehavior (e.g. a native/bulk query), leaving soft-delete semantics inconsistently enforced. - Not indexing the
deletedcolumn 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 frequentWHEREclause. - 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.