Back to Blog
spring-datajpahibernatespring-bootjavadatabase

Spring Data JPA: 12 Tips That Every Developer Should Know (2026)

Beyond findById and save, Spring Data JPA has powerful features that most developers never discover. Auditing, custom queries, projections, specifications — here's what's worth knowing.

J

JOptimize Team

May 30, 2026· 9 min read

Spring Data JPA's findById, save, and findAll are just the surface. The framework has a rich set of features that solve real problems — automatic auditing, soft deletes, query by example, streaming large datasets — that most developers reinvent from scratch instead of using the built-in capabilities. Here are the 12 features worth knowing.


1. Automatic Auditing with @CreatedDate and @LastModifiedDate

Every entity should track who created it, when, and who last modified it. Spring Data handles this automatically:

@Configuration @EnableJpaAuditing public class JpaConfig {} @MappedSuperclass @EntityListeners(AuditingEntityListener.class) public abstract class AuditableEntity { @CreatedDate @Column(updatable = false) private Instant createdAt; @LastModifiedDate private Instant updatedAt; @CreatedBy @Column(updatable = false) private String createdBy; @LastModifiedBy private String lastModifiedBy; } @Entity public class Order extends AuditableEntity { // createdAt, updatedAt, createdBy, lastModifiedBy are automatic @Id @GeneratedValue private Long id; private OrderStatus status; } // Tell Spring Data who the current user is @Component public class SecurityAuditorAware implements AuditorAware<String> { @Override public Optional<String> getCurrentAuditor() { return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication()) .map(Authentication::getName); } }

2. Soft Deletes with @SQLDelete and @Where

Instead of physically deleting rows, mark them as deleted — easier data recovery, better audit trails:

@Entity @SQLDelete(sql = "UPDATE orders SET deleted = true WHERE id = ?") @Where(clause = "deleted = false") // Automatically excludes soft-deleted rows public class Order { @Id @GeneratedValue private Long id; private boolean deleted = false; // All findBy*, findAll, etc. automatically exclude deleted = true } // To access deleted records (admin use case): @Query(value = "SELECT * FROM orders WHERE deleted = true", nativeQuery = true) List<Order> findAllDeleted();

3. Query by Example

For dynamic search with optional filters, Query by Example avoids building Specifications manually:

public List<Order> search(String status, Long customerId) { Order probe = new Order(); probe.setStatus(status != null ? OrderStatus.valueOf(status) : null); probe.setCustomerId(customerId); ExampleMatcher matcher = ExampleMatcher.matching() .withIgnoreNullValues() // Skip null fields .withIgnoreCase() // Case-insensitive string matching .withStringMatcher(ExampleMatcher.StringMatcher.CONTAINING); // LIKE return orderRepo.findAll(Example.of(probe, matcher)); // Generates: WHERE status = ? AND customer_id = ? (only non-null fields) }

4. Streaming Large Datasets Without OOM

For processing all rows of a large table without loading them all into memory:

// Returns a Stream<T> backed by a scrolling cursor — constant memory usage @Query("SELECT o FROM Order o WHERE o.status = :status") Stream<Order> streamByStatus(@Param("status") OrderStatus status); @Transactional(readOnly = true) public void exportOrders(OrderStatus status, OutputStream out) { try (Stream<Order> orders = orderRepo.streamByStatus(status)) { orders.forEach(order -> writeToStream(order, out)); // Memory: only one batch of records at a time, not all N records } // Stream auto-closes the cursor }

5. Projections for Slim Queries

Fetch only the fields you need:

// Interface projection — Spring Data generates SELECT of only these fields public interface OrderSummary { Long getId(); OrderStatus getStatus(); @Value("#{target.customer.name}") // SpEL for computed fields String getCustomerName(); } // DTO projection — even faster (no proxy overhead) @Query(""" SELECT new com.example.dto.OrderSummaryDto( o.id, o.status, c.name, o.total ) FROM Order o JOIN o.customer c WHERE o.customerId = :customerId """) List<OrderSummaryDto> findSummariesByCustomerId(@Param("customerId") Long customerId);

6. Custom Repository Implementations

For complex queries that don't fit Spring Data's abstractions:

// Define a fragment interface public interface OrderRepositoryCustom { Page<Order> complexSearch(OrderSearchCriteria criteria, Pageable pageable); } // Implement it (name convention: repository interface name + "Impl") @RequiredArgsConstructor public class OrderRepositoryImpl implements OrderRepositoryCustom { private final EntityManager em; @Override public Page<Order> complexSearch(OrderSearchCriteria criteria, Pageable pageable) { CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery<Order> query = cb.createQuery(Order.class); Root<Order> root = query.from(Order.class); List<Predicate> predicates = new ArrayList<>(); if (criteria.getStatus() != null) predicates.add(cb.equal(root.get("status"), criteria.getStatus())); if (criteria.getMinTotal() != null) predicates.add(cb.ge(root.get("total"), criteria.getMinTotal())); query.where(predicates.toArray(new Predicate[0])); // Apply sorting, pagination... return new PageImpl<>(em.createQuery(query).getResultList()); } } // Spring Data automatically combines both public interface OrderRepository extends JpaRepository<Order, Long>, OrderRepositoryCustom {}

7. @EntityGraph for Controlling Fetch Strategy Per Query

Instead of JOIN FETCH in every query, define named graphs:

@Entity @NamedEntityGraph( name = "Order.withCustomerAndItems", attributeNodes = { @NamedAttributeNode("customer"), @NamedAttributeNode(value = "items", subgraph = "items-subgraph") }, subgraphs = @NamedSubgraph( name = "items-subgraph", attributeNodes = @NamedAttributeNode("product") ) ) public class Order { ... } // Use the named graph in the repository @EntityGraph("Order.withCustomerAndItems") Optional<Order> findById(Long id); // Fetches customer + items + products in one query @EntityGraph(attributePaths = {"customer"}) // Ad-hoc graph List<Order> findByStatus(OrderStatus status);

8. Pessimistic Locking for Concurrent Updates

For inventory or balance updates where two concurrent transactions must not interfere:

@Lock(LockModeType.PESSIMISTIC_WRITE) // SELECT ... FOR UPDATE @Query("SELECT p FROM Product p WHERE p.id = :id") Optional<Product> findByIdForUpdate(@Param("id") Long id); @Transactional public void reserveStock(Long productId, int quantity) { Product product = productRepo.findByIdForUpdate(productId).orElseThrow(); // Row is locked — concurrent transactions wait here if (product.getStock() < quantity) throw new InsufficientStockException(); product.setStock(product.getStock() - quantity); productRepo.save(product); // Lock released on commit }

9. Batch Insert with saveAll()

# application.yml — enable Hibernate batch inserts spring: jpa: properties: hibernate: jdbc: batch_size: 50 order_inserts: true order_updates: true generate_statistics: true # Log batch stats in dev
// saveAll() respects batch_size — inserts 50 rows per DB round trip public void importProducts(List<ProductDto> dtos) { List<Product> products = dtos.stream() .map(ProductMapper::toEntity) .toList(); productRepo.saveAll(products); // 1000 products = 20 batches of 50 }

Important: batch inserts don't work with GenerationType.IDENTITY (auto-increment). Use SEQUENCE instead:

@Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "product_seq") @SequenceGenerator(name = "product_seq", sequenceName = "product_id_seq", allocationSize = 50) // Pre-allocate 50 IDs — reduces DB calls private Long id;

10. JPA Event Callbacks

Hooks that run before/after entity lifecycle events:

@Entity public class Order { @PrePersist public void onPrePersist() { this.orderNumber = generateOrderNumber(); // Set before insert this.createdAt = Instant.now(); } @PreUpdate public void onPreUpdate() { this.version++; // Increment version on every update } @PostLoad public void onPostLoad() { // Decrypt sensitive fields after loading from DB this.taxId = decrypt(this.encryptedTaxId); } }

11. Limit Derived Query Methods

Spring Data generates queries from method names, but they have limits:

// Works for simple cases: List<Order> findByStatusAndCustomerId(OrderStatus status, Long customerId); Optional<Order> findFirstByCustomerIdOrderByCreatedAtDesc(Long customerId); // Gets unreadable quickly — use @Query instead: List<Order> findByStatusAndCustomerIdAndCreatedAtBetweenAndTotalGreaterThan(...); // ↑ This should be: @Query("SELECT o FROM Order o WHERE o.status = :s AND o.customerId = :c " + "AND o.createdAt BETWEEN :from AND :to AND o.total > :min") List<Order> searchOrders(@Param("s") ..., @Param("c") ..., ...);

12. Repository Composition with JpaSpecificationExecutor

public interface OrderRepository extends JpaRepository<Order, Long>, JpaSpecificationExecutor<Order> {} // Build reusable specifications: public class OrderSpecs { public static Specification<Order> hasStatus(OrderStatus s) { return (root, q, cb) -> s == null ? null : cb.equal(root.get("status"), s); } public static Specification<Order> totalAbove(BigDecimal min) { return (root, q, cb) -> min == null ? null : cb.ge(root.get("total"), min); } } // Compose dynamically — null specs are ignored: Page<Order> results = orderRepo.findAll( where(hasStatus(status)).and(totalAbove(minTotal)), PageRequest.of(0, 20) );

Summary

Spring Data JPA's most valuable features beyond the basics: automatic auditing with @EnableJpaAuditing, soft deletes with @SQLDelete + @Where, streaming large datasets without OOM, interface projections for slim queries, custom repository implementations for complex queries, @EntityGraph for fetch control, pessimistic locking for concurrent updates, batch inserts with sequence generators, and Specifications for composable dynamic queries.


JOptimize: Find What Spring Data JPA Hides From You

Spring Data's convenient methods can hide N+1 patterns, missing indexes, and over-fetching. JOptimize makes them visible.

Use Spring Data JPA well. Avoid its traps.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.