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);
}| Approach | Best when | Cost |
|---|---|---|
| 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
| Strategy | Tables | Good | Bad |
|---|---|---|---|
| SINGLE_TABLE (default) | One table with a type column | Fast queries, simple | Many nullable columns |
| JOINED | One table per class, joined by id | Normalized, no nulls | Joins slow down queries |
| TABLE_PER_CLASS | One full table per concrete class | No joins for one type | Polymorphic queries use UNION, avoid |
| @MappedSuperclass | No table for the parent, only shared fields | Simple reuse of id and audit fields | Parent 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.