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_type above) 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, with id and amount), and each subclass gets a table of just its own columns (card_payment, keyed by the same id as a foreign key back to payment). 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_payment has its own id/amount/last4_digits; bank_transfer_payment has its own id/amount/ iban). No joins and no discriminator column, but polymorphic queries against the base type require a UNION across every subclass table, and identifier generation can’t safely use a simple per-table IDENTITY column (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/@OneToOne associations lazy to avoid accidental eager loading.

  • Model a class hierarchy with @Inheritance (SINGLE_TABLE by default, or JOINED/TABLE_PER_CLASS when normalization matters more than read speed), or reach for the lighter @MappedSuperclass when subclasses only need to share fields, not be polymorphic query targets.

  • Start with JpaRepository and 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_NEW for independent audit writes); add @Version to prevent lost updates under concurrent writes, and handle ObjectOptimisticLockingFailureException where conflicts are expected; enable auditing fields with @EnableJpaAuditing plus an AuditorAware bean.

  • For bulk operations, heavy reporting queries, or vendor-specific SQL, drop down to JdbcClient or JdbcTemplate/NamedParameterJdbcTemplate with RowMapper and batchUpdate rather than forcing the problem through JPA.