Course topics

By WebNest Studio

Spring Boot Tutorial

Auditing, Optimistic Locking and Soft Deletes

Production data needs history and protection. Support staff ask "who changed this price and when?". Two admins edit the same product at the same time and one silently overwrites the other. A user deletes a record by mistake and wants it back. These are everyday requirements, and Spring Data JPA and Hibernate have built-in answers for each.

This lesson covers automatic audit fields with Spring Data auditing, preventing lost updates with optimistic locking (@Version), soft deletes with Hibernate's @SoftDelete, and full change history with Hibernate Envers.

Spring Data JPA Auditing

Annotate fields with @CreatedDate, @LastModifiedDate, @CreatedBy and @LastModifiedBy, add @EntityListeners(AuditingEntityListener.class) to the entity (or a shared @MappedSuperclass), and enable it with @EnableJpaAuditing. Provide an AuditorAware bean that returns the current user — typically from the Spring Security context — and the fields are filled automatically on insert and update.

Optimistic Locking with @Version

Add a @Version field (a number or timestamp). Hibernate includes it in every UPDATE's WHERE clause and increments it. If another transaction changed the row in the meantime, zero rows match and Hibernate throws OptimisticLockException (Spring translates it to ObjectOptimisticLockingFailureException). Return 409 Conflict to the client, who can reload and retry. This prevents lost updates without holding database locks.

For REST APIs, send the version to the client (for example as an ETag) and require it on updates, so conflicts are detected even across separate HTTP requests.

Pessimistic Locking

When conflicts are frequent and retries are expensive — for example decrementing limited ticket stock — use @Lock(LockModeType.PESSIMISTIC_WRITE) on a repository method to issue SELECT ... FOR UPDATE. Other transactions wait until yours commits. Keep such transactions very short.

Soft Deletes

A soft delete marks a row as deleted instead of removing it. Hibernate's @SoftDelete annotation turns repository.delete(entity) into an UPDATE that sets a deleted flag, and automatically filters deleted rows out of every query. Remember that unique constraints still see soft-deleted rows, and that data-protection laws may still require a real deletion of personal data.

Full History with Hibernate Envers

Auditing fields only record the last change. Hibernate Envers (@Audited) records every version of an entity in _AUD tables with a revision number. Spring Data's RevisionRepository lets you query "what did this product look like last Tuesday?".

Examples

A reusable audited base class

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

    @CreatedDate
    @Column(nullable = false, updatable = false)
    private Instant createdAt;

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

    @LastModifiedDate
    private Instant updatedAt;

    @LastModifiedBy
    private String updatedBy;

    // getters
}

@Configuration
@EnableJpaAuditing
public class AuditingConfig {

    @Bean
    AuditorAware<String> auditorAware() {
        return () -> Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
            .filter(Authentication::isAuthenticated)
            .map(Authentication::getName)
            .or(() -> Optional.of("system"));
    }
}

@Entity
public class Product extends AuditedEntity {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private BigDecimal price;
}
Output
insert into product (created_at, created_by, updated_at, updated_by, name, price) values
  ('2026-09-27T09:12:00Z', 'admin@webnest.in', '2026-09-27T09:12:00Z', 'admin@webnest.in', 'Hoodie', 1299.00)
-- later, after another admin edits the price:
update product set price=1199.00, updated_at='2026-09-27T11:40:03Z', updated_by='ravi@webnest.in' where id=5

Optimistic locking and a 409 Conflict response

Java
@Entity
public class Product extends AuditedEntity {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private BigDecimal price;

    @Version
    private long version;
}

public record UpdatePriceRequest(BigDecimal price, long version) {}

@PutMapping("/api/products/{id}/price")
@Transactional
public ProductDto updatePrice(@PathVariable Long id, @RequestBody UpdatePriceRequest req) {
    Product p = products.findById(id).orElseThrow();
    if (p.getVersion() != req.version()) {
        throw new ObjectOptimisticLockingFailureException(Product.class, id);
    }
    p.setPrice(req.price());
    return ProductDto.from(p);
}

@RestControllerAdvice
class ConflictHandler {
    @ExceptionHandler(ObjectOptimisticLockingFailureException.class)
    ProblemDetail conflict() {
        ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.CONFLICT);
        pd.setTitle("Edit conflict");
        pd.setDetail("This product was changed by someone else. Reload and try again.");
        return pd;
    }
}
Output
update product set price=?, version=4, ... where id=5 and version=3

Admin A: PUT /api/products/5/price {"price":1199,"version":3} -> 200 (version now 4)
Admin B: PUT /api/products/5/price {"price":999, "version":3} -> 409 {"title":"Edit conflict","detail":"This product was changed by someone else. Reload and try again."}

Pessimistic lock for limited stock

Java
public interface TicketRepository extends JpaRepository<EventTicket, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select t from EventTicket t where t.id = :id")
    Optional<EventTicket> findForUpdate(Long id);
}

@Transactional
public void reserve(Long ticketId) {
    EventTicket t = tickets.findForUpdate(ticketId).orElseThrow();
    if (t.getRemaining() == 0) throw new SoldOutException();
    t.setRemaining(t.getRemaining() - 1);
}
Output
select ... from event_ticket e1_0 where e1_0.id=? for no key update
(concurrent reservations wait in line; the count can never go below zero)

Soft delete with @SoftDelete and history with Envers

Java
@Entity
@SoftDelete                       // adds a boolean "deleted" column and filters it automatically
public class Review {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String text;
    private int rating;
}

reviewRepository.deleteById(12L);
reviewRepository.findAll();       // review 12 no longer returned

// Envers: <dependency><groupId>org.hibernate.orm</groupId><artifactId>hibernate-envers</artifactId></dependency>
@Entity
@Audited
public class CoursePrice {
    @Id private Long courseId;
    private BigDecimal amount;
}

public interface CoursePriceRepository
        extends JpaRepository<CoursePrice, Long>, RevisionRepository<CoursePrice, Long, Integer> {}

@EnableJpaRepositories(repositoryFactoryBeanClass = EnversRevisionRepositoryFactoryBean.class)
@Configuration
class EnversConfig {}

coursePriceRepository.findRevisions(7L).forEach(r ->
    System.out.println("rev " + r.getRequiredRevisionNumber() + ": " + r.getEntity().getAmount()));
Output
update review set deleted=true where id=? and deleted=false
select ... from review r1_0 where r1_0.deleted=false

rev 1: 2999.00
rev 4: 2499.00
rev 9: 1999.00

Common Mistakes

  • Forgetting @EnableJpaAuditing or @EntityListeners, so audit fields stay null.
  • Not exposing the @Version value to clients, so conflicting edits from two browser tabs still overwrite each other.
  • Catching OptimisticLockException and retrying blindly, overwriting the other user's change anyway.
  • Using pessimistic locks in long transactions, causing waiting threads and deadlocks.
  • Assuming soft-deleted personal data satisfies a user's legal right to deletion.

Key Points to Remember

  • @CreatedDate/@LastModifiedDate/@CreatedBy/@LastModifiedBy plus AuditorAware record who changed what and when.
  • @Version enables optimistic locking; map conflicts to HTTP 409.
  • Use PESSIMISTIC_WRITE locks sparingly for high-contention counters.
  • Hibernate @SoftDelete marks rows deleted and hides them from queries automatically.
  • Hibernate Envers keeps a full revision history queryable via RevisionRepository.

Practice the examples

Change an input, predict the result, then compare it with the output. Explain why the result changes.

Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.