Spring Data JPA
|
This section documents Spring Boot 4.1.x and Spring Framework 7.0.x on the Java 17/21+ baseline, as described by the official Spring Boot reference documentation and each sub-project’s own site (Spring Data, Spring for Apache Kafka, Micrometer, Project Reactor, springdoc-openapi, Spring gRPC, and the others listed in this section’s Bibliography) — which are the references these pages are written and verified against. This content was generated with the assistance of AI and should be verified against those official docs before being relied on in production. Spring Boot and its ecosystem continue to evolve; the examples here target the current 4.1.x / 7.0.x releases. This section’s bibliography lists the reference material consulted while preparing these pages. |
Spring Data JPA sits on top of JPA (Hibernate, by default in Spring Boot) to turn entity classes and repository interfaces into working persistence code, while Spring Framework’s JDBC support remains available for the cases an ORM does not fit well.
This page assumes familiarity with the shared repository abstraction (CrudRepository, PagingAndSortingRepository)
covered in Spring Data Overview; it focuses on the
JPA/relational specifics: entity mapping, JpaRepository, query derivation, @Query, dynamic queries,
projections, transactions, auditing, and plain JDBC as an escape hatch.
Entities and JPA mapping
A JPA entity is a plain class annotated with @Entity and an identifier annotated with @Id. Column and
relationship annotations control how fields map to table columns and foreign keys:
@Entity
@Table(name = "customers")
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "full_name", nullable = false, length = 120)
private String fullName;
@Column(unique = true)
private String email;
@Enumerated(EnumType.STRING)
private CustomerStatus status;
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Order> orders = new ArrayList<>();
// getters and setters omitted
}
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id", nullable = false)
private Customer customer;
@Column(name = "placed_at")
private Instant placedAt;
private BigDecimal total;
}
@ManyToOne defaults to eager fetching — override it with FetchType.LAZY as shown above, since eager
associations are a common source of unintended N+1 queries. @OneToMany/@ManyToMany already default to lazy.
cascade propagates persist/remove operations to associated entities, and orphanRemoval deletes child rows
that are no longer referenced. See
Spring Data JPA — Persisting
Entities and Object Mapping
Fundamentals for the full mapping model, and the underlying Jakarta Persistence specification for annotation
details.
Entity inheritance
JPA maps a Java class hierarchy onto relational tables using one of three strategies, selected with
@Inheritance on the root entity. They trade off query simplicity against normalization and column nullability
differently enough that the choice matters:
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "payment_type")
public abstract class Payment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private BigDecimal amount;
// getters and setters omitted
}
@Entity
@DiscriminatorValue("CARD")
public class CardPayment extends Payment {
private String last4Digits;
}
@Entity
@DiscriminatorValue("BANK_TRANSFER")
public class BankTransferPayment extends Payment {
private String iban;
}
-
SINGLE_TABLE(the JPA default, and what the example above uses) — every subclass is stored in one table, with a@DiscriminatorColumn(payment_typeabove) recording which subclass each row represents, and every subclass-specific column (last4_digits,iban) made nullable since only rows of the matching type populate it. Fastest reads (no joins, ever) and the simplest queries, at the cost of a wide, sparsely-populated table as more subclasses are added. -
JOINED— the root gets its own table (payment, withidandamount), and each subclass gets a table of just its own columns (card_payment, keyed by the sameidas a foreign key back topayment). Fully normalized — no nullable columns, no wasted space — but reading a subclass instance requires a join, and reading a polymorphic collection of the base type requires a join per concrete subclass present. -
TABLE_PER_CLASS— each concrete subclass gets a complete table with all inherited columns repeated (card_paymenthas its ownid/amount/last4_digits;bank_transfer_paymenthas its ownid/amount/iban). No joins and no discriminator column, but polymorphic queries against the base type require aUNIONacross every subclass table, and identifier generation can’t safely use a simple per-tableIDENTITYcolumn (a shared sequence is typically needed instead). Least commonly used of the three.
Querying the base type transparently returns whichever concrete subclass each row actually is:
public interface PaymentRepository extends JpaRepository<Payment, Long> {
List<Payment> findByAmountGreaterThan(BigDecimal threshold);
}
// each element is really a CardPayment or BankTransferPayment instance
List<Payment> payments = paymentRepository.findByAmountGreaterThan(BigDecimal.valueOf(100));
for (Payment payment : payments) {
if (payment instanceof CardPayment cardPayment) {
// ...
}
}
When subclasses only need to share mapped fields without being polymorphic query targets in their own right
(no shared table, no discriminator, no querying the supertype), @MappedSuperclass is a lighter alternative to
@Inheritance — it is not itself an entity and has no table, but its fields are inherited into each subclass’s
own table:
@MappedSuperclass
public abstract class BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@CreatedDate
private Instant createdAt;
}
@Entity
public class Customer extends BaseEntity {
// gets its own table with id and created_at columns, inherited from BaseEntity --
// there is no "base_entity" table and no polymorphic query across unrelated entities
private String fullName;
}
See the
Jakarta Persistence @Inheritance specification for the full trade-off discussion, including how each strategy
interacts with @GeneratedValue identifier generation.
JpaRepository and derived query methods
JpaRepository<T, ID> extends PagingAndSortingRepository with JPA-specific operations (flush, batched
saveAll, getReferenceById):
public interface CustomerRepository extends JpaRepository<Customer, Long> {
// derived queries: Spring Data parses the method name into a JPQL query
Optional<Customer> findByEmail(String email);
List<Customer> findByStatusAndFullNameContainingIgnoreCase(CustomerStatus status, String namePart);
List<Customer> findTop10ByOrderByFullNameAsc();
long countByStatus(CustomerStatus status);
boolean existsByEmail(String email);
void deleteByStatus(CustomerStatus status);
}
Derived methods follow the findBy/countBy/existsBy/deleteBy prefixes combined with property names and
keywords (And, Or, Containing, Between, GreaterThan, OrderBy, IgnoreCase, Top/First). A Page
or Slice return type accepts a Pageable and adds pagination automatically:
Page<Customer> findByStatus(CustomerStatus status, Pageable pageable);
// usage
Pageable pageable = PageRequest.of(0, 20, Sort.by("fullName").ascending());
Page<Customer> page = customerRepository.findByStatus(CustomerStatus.ACTIVE, pageable);
Spring Data JPA has supported keyset scrolling natively since 3.1: a derived method returning Window<T> and
taking a ScrollPosition runs an offset-free, indexed WHERE-based query instead of JPA’s usual OFFSET-backed
Pageable query, avoiding the scan-and-discard cost Page/Pageable pays on a deep page (see
Keyset scrolling with
Window<T>):
Window<Customer> findFirst20ByStatus(CustomerStatus status, Sort sort, ScrollPosition position);
// usage
Window<Customer> window = customerRepository.findFirst20ByStatus(
CustomerStatus.ACTIVE, Sort.by("id"), ScrollPosition.keyset());
@Query, dynamic queries, and projections
JPQL and native SQL with @Query
When a derived method name would become unwieldy, or the query needs a join, aggregation, or a native SQL
feature, annotate the method with @Query:
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("select o from Order o where o.customer.id = :customerId and o.total >= :minTotal")
List<Order> findLargeOrdersForCustomer(@Param("customerId") Long customerId,
@Param("minTotal") BigDecimal minTotal);
@Modifying
@Query("update Order o set o.total = o.total * :factor where o.placedAt < :cutoff")
int applyLegacyDiscount(@Param("factor") BigDecimal factor, @Param("cutoff") Instant cutoff);
// nativeQuery = true switches from JPQL to vendor SQL
@Query(value = """
select c.id, c.full_name, sum(o.total) as lifetime_value
from customers c join orders o on o.customer_id = c.id
group by c.id, c.full_name
having sum(o.total) > :threshold
""", nativeQuery = true)
List<Object[]> findHighValueCustomers(@Param("threshold") BigDecimal threshold);
}
@Modifying is required for UPDATE/DELETE queries executed through @Query; without it Spring Data expects
a SELECT. Native queries lose JPQL’s portability and entity-graph awareness in exchange for full SQL syntax,
including vendor-specific functions.
Specifications and Querydsl for dynamic queries
Derived methods and static @Query values do not compose well when filters are optional or combined at
runtime. Specification<T> builds predicates programmatically against the JPA Criteria API:
public interface CustomerRepository extends JpaRepository<Customer, Long>,
JpaSpecificationExecutor<Customer> {
}
public final class CustomerSpecifications {
public static Specification<Customer> hasStatus(CustomerStatus status) {
return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status);
}
public static Specification<Customer> nameContains(String fragment) {
return (root, query, cb) -> fragment == null ? null
: cb.like(cb.lower(root.get("fullName")), "%" + fragment.toLowerCase() + "%");
}
}
// composing at call time, skipping null (inactive) filters
Specification<Customer> spec = Specification.where(CustomerSpecifications.hasStatus(status))
.and(CustomerSpecifications.nameContains(nameFilter));
List<Customer> results = customerRepository.findAll(spec);
Querydsl offers a similar capability with a generated, type-safe query DSL (QCustomer) instead of the
Criteria API’s string-based property paths, via QuerydslPredicateExecutor<T>; it requires the
querydsl-apt/jpa annotation processor to generate the Q* metamodel classes at build time.
Projections: interface, DTO, and dynamic
Returning full entities is wasteful when only a few columns are needed. Spring Data supports three projection styles:
// interface (closed) projection -- Spring Data generates a proxy backed by the query result
public interface CustomerSummary {
Long getId();
String getFullName();
}
public interface CustomerRepository extends JpaRepository<Customer, Long> {
List<CustomerSummary> findByStatus(CustomerStatus status);
// DTO projection -- a constructor expression, instantiated directly by JPQL
@Query("select new com.example.dto.CustomerDto(c.id, c.fullName, c.email) from Customer c where c.status = :status")
List<CustomerDto> findDtosByStatus(@Param("status") CustomerStatus status);
// dynamic projection -- the caller picks the projection type per call
<T> List<T> findByEmail(String email, Class<T> projectionType);
}
// dynamic projection usage
List<CustomerSummary> summaries = customerRepository.findByEmail(email, CustomerSummary.class);
Interface projections are the simplest and support open projections (@Value("#\{target.fullName}")); DTO
projections require an explicit constructor and package-qualified class name in the JPQL but avoid the proxy
overhead; dynamic projections let one repository method serve several call sites with different shapes. See
Spring Data JPA — Projections
for the full projections chapter.
Geospatial queries
Plain JPA/Hibernate has no built-in spatial type or query-derivation keyword the way Spring Data MongoDB or
Neo4j do. Spatial support instead comes from Hibernate Spatial (org.hibernate:hibernate-spatial) layered on
top of JPA, mapping JTS (org.locationtech.jts.geom.Point/Geometry) types against a spatially-enabled
database — PostGIS on PostgreSQL being the common case.
Mapping a JTS Point with Hibernate Spatial
Add org.hibernate:hibernate-spatial to the classpath and map a JTS Point field directly on the entity:
@Entity
@Table(name = "places")
public class Place {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Column(columnDefinition = "geometry(Point,4326)")
private Point location; // org.locationtech.jts.geom.Point
}
The 4326 SRID (WGS-84, matching GPS coordinates) is a PostGIS convention rather than a Hibernate Spatial
requirement — another spatially-enabled database/dialect may use a different SRID or column type. See
Hibernate Spatial and
the
Hibernate User Guide’s spatial chapter.
Querying spatial columns with native and JPQL @Query
Derived keywords such as Near/Within are not part of Spring Data JPA’s supported keyword list the way they
are for MongoDB or Neo4j, so spatial queries go through @Query instead — native SQL where JPQL doesn’t expose
the dialect function, or JPQL directly where the provider does:
public interface PlaceRepository extends JpaRepository<Place, Long> {
@Query(value = """
select * from places
where ST_DWithin(location::geography, ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography, :radiusMeters)
""", nativeQuery = true)
List<Place> findWithinRadius(@Param("lon") double lon, @Param("lat") double lat,
@Param("radiusMeters") double radiusMeters);
@Query(value = """
select p.*, ST_Distance(p.location::geography, ST_SetSRID(ST_MakePoint(:lon, :lat), 4326)::geography) as distance
from places p
order by distance
limit :limit
""", nativeQuery = true)
List<Place> findNearest(@Param("lon") double lon, @Param("lat") double lat, @Param("limit") int limit);
}
nativeQuery = true is required here because PostGIS functions (ST_DWithin, ST_MakePoint, ST_Distance)
are not part of JPQL’s function set. The ::geography casts matter: location is mapped as geometry(Point,4326)
above, and for a geometry column PostGIS interprets ST_DWithin/ST_Distance distances in the units of the
SRS — for SRID 4326 (WGS-84) that is decimal degrees, not meters. Casting both sides to geography switches
PostGIS to great-circle calculations in meters, so radiusMeters and the computed distance mean what their
names say; omitting the cast would silently return an unbounded (degree-radius) result instead of a meters-based
one. See the PostGIS spatial function reference.
Transactions, optimistic locking, and auditing
@Transactional and propagation
Spring Data repository methods are transactional by default (read-only for query methods), but multi-step
service logic usually needs its own @Transactional boundary:
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final InventoryService inventoryService;
public OrderService(OrderRepository orderRepository, InventoryService inventoryService) {
this.orderRepository = orderRepository;
this.inventoryService = inventoryService;
}
@Transactional
public Order placeOrder(Order order) {
inventoryService.reserveStock(order); // participates in the same transaction (REQUIRED, the default)
return orderRepository.save(order);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordAuditEntry(Long orderId, String message) {
// runs in its own transaction, committed independently of the caller's outcome
}
@Transactional(readOnly = true)
public List<Order> listOrders(Long customerId) {
return orderRepository.findByCustomerId(customerId);
}
}
Propagation.REQUIRED (the default) joins an existing transaction or starts one; REQUIRES_NEW suspends the
caller’s transaction and starts an independent one, useful for audit logging that must persist even if the
caller later rolls back; MANDATORY, NESTED, SUPPORTS, NOT_SUPPORTED, and NEVER cover the remaining
cases. readOnly = true is a hint the JPA provider and driver can use to skip dirty checking and flush
overhead.
The same annotation also carries an isolation attribute — @Transactional(isolation = Isolation.REPEATABLE_READ) — which overrides the database session’s default level
for the duration of that one boundary. DataSourceTransactionManager/JpaTransactionManager apply it by calling
Connection.setTransactionIsolation(…) when the transaction starts and restoring the previous value when the
connection is released back to the pool; the default, Isolation.DEFAULT, sets nothing and leaves the database’s
own default in force. Two caveats: some JpaTransactionManager setups need the datasource (and its connection
pool) configured to permit changing the level, and not every engine supports every level. See
Transaction Isolation & Locking for the levels
and their cost, and SQL Reference: Transaction Control for the underlying
ANSI model.
One thing this annotation cannot do is span a reactive return type. @Transactional here is the blocking
JPA variant: the proxy commits when the method returns, and a method returning a Mono or Flux returns an
unsubscribed publisher, so the transaction opens and commits before any data access has run at all. JPA and
reactive return types must therefore not be combined. Reactive transactions need a ReactiveTransactionManager
over R2DBC or reactive MongoDB, where the transaction rides the Reactor Context rather than the thread — see Reactive transactions and thread affinity.
@Version and optimistic locking
Two concurrent transactions loading, modifying, and saving the same row can silently overwrite each other’s
change — the classic lost update. @Version prevents this without taking a database lock for the duration of
either transaction:
@Entity
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private BigDecimal balance;
@Version
private Long version;
// getters and setters omitted
}
Every UPDATE Hibernate issues for a @Version-annotated entity includes the current version in its WHERE
clause and increments it in the SET clause — roughly
update account set balance = ?, version = version + 1 where id = ? and version = ?. If another transaction
already committed a change in between, zero rows match that WHERE clause, Hibernate detects the mismatch, and
the save fails with OptimisticLockException — surfaced through Spring Data as
ObjectOptimisticLockingFailureException, a subtype of the same DataAccessException hierarchy every
@Repository bean already translates into (see
Core Annotations):
@Service
public class AccountService {
private final AccountRepository accounts;
public AccountService(AccountRepository accounts) {
this.accounts = accounts;
}
@Transactional
public void withdraw(Long accountId, BigDecimal amount) {
Account account = accounts.findById(accountId).orElseThrow();
account.setBalance(account.getBalance().subtract(amount));
try {
accounts.save(account); // increments and checks @Version under the hood
} catch (ObjectOptimisticLockingFailureException ex) {
// someone else updated this account first -- reload and retry, or surface a 409 Conflict
throw new ConcurrentModificationException("Account " + accountId + " was modified concurrently", ex);
}
}
}
A @Version field must be a short/int/long (or their wrapper types) or a
java.sql.Timestamp/Instant; JPA manages its value entirely — application code should never set it directly.
Pessimistic locking
Optimistic locking holds no lock while the user "thinks", so it scales far better for the common case of
low-contention updates — at the cost of the caller handling the occasional conflict. The alternative,
pessimistic locking, takes a real database row lock on read and holds it for the transaction’s duration, so a
competing writer waits instead of failing. In Spring Data JPA that is the @Lock annotation on a repository
query method:
public interface AccountRepository extends JpaRepository<Account, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
Optional<Account> findWithLockById(Long id);
}
@Transactional
public void withdrawExclusively(Long accountId, BigDecimal amount) {
Account account = accounts.findWithLockById(accountId).orElseThrow(); // row locked until commit
account.setBalance(account.getBalance().subtract(amount));
}
PESSIMISTIC_WRITE emits SELECT … FOR UPDATE (or the dialect’s equivalent) and holds the exclusive row lock
until the surrounding transaction commits, so the method must run inside a @Transactional boundary and must be
short; the jakarta.persistence.lock.timeout query hint caps the wait, after which Spring raises
PessimisticLockingFailureException. See
Transaction Isolation & Locking for the other
lock modes, deadlock ordering, and when to prefer this over @Version.
Auditing with @CreatedDate / @LastModifiedBy
Spring Data JPA can populate creation/modification metadata automatically:
@SpringBootApplication
@EnableJpaAuditing(auditorAwareRef = "auditorProvider")
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
@Bean
public AuditorAware<String> auditorProvider() {
return () -> Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.map(Authentication::getName);
}
}
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@CreatedDate
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
@CreatedBy
private String createdBy;
@LastModifiedBy
private String updatedBy;
}
@EnableJpaAuditing activates the auditing infrastructure application-wide; @EntityListeners(AuditingEntityListener.class)
opts a specific entity into it. @CreatedDate/@LastModifiedDate need no AuditorAware bean, but
@CreatedBy/@LastModifiedBy do — the bean supplies "who" (typically read from the current
SecurityContext), while the listener supplies "when".
Custom queries without JPA
Not every data-access need fits a repository and an ORM well. Bulk updates over millions of rows, complex
reporting queries with vendor-specific window functions or hints, and one-off scripts benefit from talking to
the database directly instead of paying for entity hydration, the persistence context, and dirty checking.
Spring Framework provides two layers for this: the newer JdbcClient and the classic JdbcTemplate family.
JdbcClient (Spring Framework 6.1+)
JdbcClient is a fluent facade introduced in Spring Framework 6.1 that unifies positional and named
parameters behind one API:
@Repository
public class ReportingRepository {
private final JdbcClient jdbcClient;
public ReportingRepository(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
public List<CustomerSummary> findActiveSummaries() {
return jdbcClient.sql("select id, full_name from customers where status = :status")
.param("status", "ACTIVE")
.query(CustomerSummary.class)
.list();
}
public Optional<BigDecimal> findLifetimeValue(long customerId) {
return jdbcClient.sql("select sum(total) from orders where customer_id = ?")
.param(customerId)
.query(BigDecimal.class)
.optional();
}
public int deactivateStaleCustomers(Instant cutoff) {
return jdbcClient.sql("update customers set status = 'INACTIVE' where last_login < :cutoff")
.param("cutoff", cutoff)
.update();
}
}
Spring Boot autoconfigures a JdbcClient bean whenever a DataSource and spring-boot-starter-jdbc are on
the classpath, so it can be injected directly like any other bean.
JdbcTemplate, NamedParameterJdbcTemplate, and RowMapper
JdbcTemplate predates JdbcClient and remains the lower-level, widely used API; NamedParameterJdbcTemplate
wraps it to allow named placeholders instead of positional ? markers:
@Repository
public class LegacyReportingRepository {
private final NamedParameterJdbcTemplate namedJdbcTemplate;
private final JdbcTemplate jdbcTemplate;
public LegacyReportingRepository(NamedParameterJdbcTemplate namedJdbcTemplate) {
this.namedJdbcTemplate = namedJdbcTemplate;
this.jdbcTemplate = namedJdbcTemplate.getJdbcTemplate();
}
private static final RowMapper<CustomerSummary> SUMMARY_MAPPER =
(rs, rowNum) -> new CustomerSummary(rs.getLong("id"), rs.getString("full_name"));
public List<CustomerSummary> findByStatus(String status) {
Map<String, Object> params = Map.of("status", status);
return namedJdbcTemplate.query(
"select id, full_name from customers where status = :status", params, SUMMARY_MAPPER);
}
public int[] batchInsertAuditEvents(List<AuditEvent> events) {
String sql = "insert into audit_events (order_id, message, recorded_at) values (?, ?, ?)";
return jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
AuditEvent event = events.get(i);
ps.setLong(1, event.orderId());
ps.setString(2, event.message());
ps.setTimestamp(3, Timestamp.from(event.recordedAt()));
}
@Override
public int getBatchSize() {
return events.size();
}
});
}
}
RowMapper<T> converts one ResultSet row into a domain object — it is the manual equivalent of what
JdbcClient’s `query(Class) does automatically for simple types. batchUpdate sends the whole set of
statements to the driver in one round trip, which matters when inserting or updating thousands of rows; running
the same statement one row at a time through JpaRepository.save in a loop is dramatically slower and can
exhaust the persistence context’s first-level cache.
Reach for JdbcClient/JdbcTemplate instead of JPA when: the operation is a bulk insert/update/delete
that does not need entity lifecycle callbacks; the query is a complex reporting query (multi-level
aggregation, window functions, CTEs) that is easier to express and tune as raw SQL than as JPQL or Criteria; or
the SQL is intentionally vendor-specific (a database’s own hints, MERGE statement, or JSON/array
functions) and portability across JPA providers is not a goal. See
Spring Framework — Using the
JDBC Core Classes.
Summary
-
Map entities with
@Entity/@Id/@Column/relationship annotations, keeping@ManyToOne/@OneToOneassociations lazy to avoid accidental eager loading. -
Model a class hierarchy with
@Inheritance(SINGLE_TABLEby default, orJOINED/TABLE_PER_CLASSwhen normalization matters more than read speed), or reach for the lighter@MappedSuperclasswhen subclasses only need to share fields, not be polymorphic query targets. -
Start with
JpaRepositoryand derived query methods; move to@Query(JPQL or native SQL) when the query outgrows a method name. -
Use
Specification/JpaSpecificationExecutor(or Querydsl) for filters that are combined dynamically at runtime, and interface/DTO/dynamic projections to avoid fetching whole entities unnecessarily. -
Wrap multi-step writes in
@Transactional, choosing propagation deliberately (REQUIRES_NEWfor independent audit writes); add@Versionto prevent lost updates under concurrent writes, and handleObjectOptimisticLockingFailureExceptionwhere conflicts are expected; enable auditing fields with@EnableJpaAuditingplus anAuditorAwarebean. -
For bulk operations, heavy reporting queries, or vendor-specific SQL, drop down to
JdbcClientorJdbcTemplate/NamedParameterJdbcTemplatewithRowMapperandbatchUpdaterather than forcing the problem through JPA.