Home / Spring Boot / Data and Transactions / Advanced JPA: Specifications, Auditing, Locking and Mapping

Advanced JPA: Specifications, Auditing, Locking and Mapping

These are the features that separate demo projects from real systems: dynamic filters, automatic created and updated timestamps, protection against lost updates, soft delete and richer mappings.

1. Dynamic search with Specifications

When users can combine optional filters (name, category, price range), writing many repository methods does not scale. A Specification builds the WHERE condition step by step.

public interface ProductRepository extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> { }

public class ProductSpecs {

    public static Specification<Product> nameContains(String text) {
        return (root, query, cb) -> text == null ? null
                : cb.like(cb.lower(root.get("name")), "%" + text.toLowerCase() + "%");
    }
    public static Specification<Product> category(String category) {
        return (root, query, cb) -> category == null ? null : cb.equal(root.get("category"), category);
    }
    public static Specification<Product> priceBetween(BigDecimal min, BigDecimal max) {
        return (root, query, cb) -> {
            if (min == null && max == null) return null;
            if (min == null) return cb.lessThanOrEqualTo(root.get("price"), max);
            if (max == null) return cb.greaterThanOrEqualTo(root.get("price"), min);
            return cb.between(root.get("price"), min, max);
        };
    }
}

@Service
public class ProductSearchService {
    private final ProductRepository repository;
    public ProductSearchService(ProductRepository repository) { this.repository = repository; }

    public Page<Product> search(String name, String category, BigDecimal min, BigDecimal max, Pageable pageable) {
        Specification<Product> spec = Specification
                .where(ProductSpecs.nameContains(name))
                .and(ProductSpecs.category(category))
                .and(ProductSpecs.priceBetween(min, max));
        return repository.findAll(spec, pageable);
    }
}

A specification that returns null is ignored. Only the filters the user sent are added to the SQL.

2. Auditing: who created it and when

@Configuration
@EnableJpaAuditing
public class AuditConfig {
    @Bean
    public AuditorAware<String> auditorProvider() {
        return () -> Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
                .map(Authentication::getName).or(() -> Optional.of("system"));
    }
}

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class Auditable {
    @CreatedDate       @Column(updatable = false) private Instant createdAt;
    @LastModifiedDate  private Instant updatedAt;
    @CreatedBy         @Column(updatable = false) private String createdBy;
    @LastModifiedBy    private String updatedBy;
    // getters
}

@Entity
public class Invoice extends Auditable {
    @Id @GeneratedValue private Long id;
    private BigDecimal amount;
}

3. Optimistic locking: prevent lost updates

Two users edit product 5 (version = 3) at the same time:
 User A reads (v3)                 User B reads (v3)
 User A saves:  update ... set price=?, version=4 where id=5 and version=3   ► 1 row updated  OK
 User B saves:  update ... set price=?, version=4 where id=5 and version=3   ► 0 rows updated
                                                          ► ObjectOptimisticLockingFailureException
@Entity
public class Product {
    @Id @GeneratedValue private Long id;
    @Version private Long version;         // Hibernate manages it
    private BigDecimal price;
}

@RestControllerAdvice
class LockingHandler {
    @ExceptionHandler(ObjectOptimisticLockingFailureException.class)
    ProblemDetail conflict() {
        return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "The record was changed by someone else. Reload and try again.");
    }
}

4. Pessimistic locking: lock the row now

public interface AccountRepository extends JpaRepository<Account, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)                // select ... for update
    @Query("select a from Account a where a.id = :id")
    Optional<Account> findByIdForUpdate(@Param("id") Long id);
}
ApproachBest whenCost
Optimistic (@Version)Conflicts are rare (most web apps)Retry or error on conflict, no database locks held
Pessimistic (FOR UPDATE)Conflicts are frequent (stock, balance)Blocks other transactions, risk of deadlocks. Keep transactions short

5. Soft delete

@Entity
@SQLDelete(sql = "update customer set deleted = true where id = ?")
@SQLRestriction("deleted = false")          // Hibernate 6.3+ (older versions: @Where(clause = "deleted = false"))
public class Customer {
    @Id @GeneratedValue private Long id;
    private String name;
    private boolean deleted = false;
}
// repository.delete(customer) now runs an UPDATE. All queries automatically ignore deleted rows.

6. Embedded objects and enums

@Embeddable
public class Address {
    private String street;
    private String city;
    @Column(name = "zip_code") private String zipCode;
}

@Entity
public class Person {
    @Id @GeneratedValue private Long id;
    @Embedded private Address address;                    // columns street, city, zip_code in the person table

    @Enumerated(EnumType.STRING)                          // never use ORDINAL: reordering the enum breaks data
    private Status status;
}

7. Inheritance mapping

StrategyTablesGoodBad
SINGLE_TABLE (default)One table with a type columnFast queries, simpleMany nullable columns
JOINEDOne table per class, joined by idNormalized, no nullsJoins slow down queries
TABLE_PER_CLASSOne full table per concrete classNo joins for one typePolymorphic queries use UNION, avoid
@MappedSuperclassNo table for the parent, only shared fieldsSimple reuse of id and audit fieldsParent cannot be queried

8. Projection with constructor expression

public record ProductStat(String category, long count, BigDecimal avgPrice) { }

@Query("select new com.example.ProductStat(p.category, count(p), avg(p.price)) from Product p group by p.category")
List<ProductStat> stats();

Common mistake: Using EnumType.ORDINAL (the default) for enums. Adding or reordering a constant silently changes the meaning of old rows. Always use EnumType.STRING or a converter.

Interview questions

Optimistic or pessimistic locking?

Start with optimistic locking with @Version. Use pessimistic locks only for hot rows where conflicts are common and retries are not acceptable.

How do I add pagination to a Specification query?

Pass a Pageable to findAll(spec, pageable). Spring Data adds the count query automatically.

Why does soft delete break unique constraints?

A deleted row still occupies the unique value. Use a partial unique index (where deleted = false) or include a deleted timestamp in the unique key.