Spring Boot
Spring Data JPA
Learn Spring Data JPA: repositories, derived queries, @Query, pagination, projections, specifications and auditing, with pitfalls and the SQL each one generates.
Repository Hierarchy
Spring Data JPA provides a hierarchy of repository interfaces, and each level adds functionality. You never write the implementation: at startup Spring creates a proxy backed by SimpleJpaRepository. Choose the narrowest interface that covers your needs. CrudRepository is enough for basic CRUD, PagingAndSortingRepository adds pagination, and JpaRepository adds flushing and batch deletes.
When to pick which: use JpaRepository for most Spring Boot services because it returns List instead of Iterable and gives you saveAndFlush. Choose CrudRepository or ListCrudRepository only when you deliberately want to hide paging or batch methods from callers. Pitfall: a repository that extends JpaRepository exposes deleteAll() and findAll() to every caller, so on large tables wrap it in a service rather than injecting it everywhere.
Java
// ── Repository hierarchy (each extends the one above): ───────────────
//
// Repository<T, ID> (marker interface — no methods)
// └── CrudRepository<T, ID> (save, findById, findAll, delete, count)
// └── PagingAndSortingRepository (findAll(Pageable), findAll(Sort))
// └── JpaRepository<T, ID> (flush, saveAllAndFlush, deleteAllInBatch)
// ── JpaRepository — the standard choice for Spring Boot applications: ─
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// JpaRepository<User, Long>:
// User = the entity type
// Long = the primary key type
// Inherited methods (no implementation needed):
// save(User) → INSERT or UPDATE
// saveAll(Iterable<User>) → batch INSERT or UPDATE
// findById(Long) → Optional<User>
// findAll() → List<User>
// findAll(Pageable) → Page<User>
// findAll(Sort) → List<User>
// findAllById(Iterable<Long>) → List<User>
// existsById(Long) → boolean
// count() → long
// deleteById(Long)
// delete(User)
// deleteAll()
// deleteAllById(Iterable<Long>)
// flush() → flush pending changes to DB
// saveAndFlush(User) → save + immediate flush
}
// ── CrudRepository — when pagination is not needed: ───────────────────
@Repository
public interface TagRepository extends CrudRepository<Tag, Long> { }
// ── ListCrudRepository (Spring Data 3+) — returns List instead of Iterable:
@Repository
public interface CategoryRepository extends ListCrudRepository<Category, Long> { }Derived Query Methods
Spring Data parses the method name at startup and builds a query from it: the part after find...By is split on keywords such as And, Or, Between, Containing and OrderBy, and each property name is resolved against the entity. No @Query is needed for simple lookups.
Pitfalls: (1) a misspelled property fails the application at startup with "No property found for type", which is good because you find it immediately. (2) Long names like findByNameAndActiveTrueAndRoleInOrderByCreatedAtDesc are hard to read, so switch to @Query or Specifications after about three conditions. (3) Containing, StartingWith and EndingWith add the % wildcards for you, and values are bound as parameters, so there is no SQL injection risk. (4) A leading-wildcard LIKE cannot use a normal index, so findByNameContaining can be slow on big tables. (5) Return Optional for lookups that may find nothing, and a collection when several rows are valid.
For example, findByEmailAndRole(email, role) generates: select u from User u where u.email = ?1 and u.role = ?2.
Java
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// ── Find by single field: ─────────────────────────────────────────
Optional<User> findByEmail(String email);
List<User> findByRole(User.Role role);
List<User> findByName(String name);
// ── Find by multiple fields (AND): ────────────────────────────────
Optional<User> findByEmailAndRole(String email, User.Role role);
List<User> findByNameAndActiveTrue(String name);
// ── Find by multiple fields (OR): ─────────────────────────────────
List<User> findByNameOrEmail(String name, String email);
// ── Comparison operators: ─────────────────────────────────────────
List<User> findByAgeGreaterThan(int age);
List<User> findByAgeGreaterThanEqual(int age);
List<User> findByAgeLessThan(int age);
List<User> findByAgeBetween(int min, int max);
List<User> findByCreatedAtAfter(LocalDateTime date);
List<User> findByCreatedAtBefore(LocalDateTime date);
// ── String matching: ──────────────────────────────────────────────
List<User> findByNameContaining(String fragment); // LIKE %fragment%
List<User> findByNameStartingWith(String prefix); // LIKE prefix%
List<User> findByNameEndingWith(String suffix); // LIKE %suffix
List<User> findByNameContainingIgnoreCase(String fragment); // case-insensitive
// ── Null checks: ──────────────────────────────────────────────────
List<User> findByBioIsNull();
List<User> findByBioIsNotNull();
// ── Boolean flags: ────────────────────────────────────────────────
List<User> findByActiveTrue();
List<User> findByActiveFalse();
// ── In / Not In: ──────────────────────────────────────────────────
List<User> findByRoleIn(Collection<User.Role> roles);
List<User> findByIdNotIn(Collection<Long> ids);
// ── Sorting built into the method name: ───────────────────────────
List<User> findByRoleOrderByNameAsc(User.Role role);
List<User> findByActiveOrderByCreatedAtDesc(boolean active);
// ── Existence and count: ──────────────────────────────────────────
boolean existsByEmail(String email);
long countByRole(User.Role role);
long countByActiveTrue();
// ── Delete: ───────────────────────────────────────────────────────
// Derived deletes load the entities first, then delete one by one.
void deleteByEmail(String email);
long deleteByActiveFalse(); // returns count of deleted rows
// ── Top / First — limit results: ──────────────────────────────────
Optional<User> findFirstByOrderByCreatedAtDesc(); // most recent
List<User> findTop5ByRoleOrderByCreatedAtDesc(User.Role role);
List<User> findFirst10ByActiveTrue();
// ── With Pageable — any finder can accept Pageable: ───────────────
Page<User> findByRole(User.Role role, Pageable pageable);
List<User> findByActiveTrue(Sort sort);
}@Query — Custom JPQL and Native SQL
Use @Query when a derived name becomes unreadable or you need joins, aggregates or database-specific SQL. JPQL works on entity and field names and stays portable across databases. Native SQL works on table and column names and lets you use vendor features, but it ties you to one database.
Pitfalls: (1) Always use named parameters with @Param and never concatenate strings. (2) For Page<T> results with a join or native query, provide a countQuery, otherwise the count query Spring derives can be wrong or slow. (3) The "(:param IS NULL OR u.col = :param)" pattern is convenient, but on PostgreSQL it can fail with errors such as "could not determine data type of parameter" when the parameter is null, especially with enums and dates. If you see that, switch to Specifications (shown below). (4) @Modifying queries bypass the persistence context, so entities already loaded in the same transaction become stale. Use clearAutomatically = true so they are refreshed on the next read, and flushAutomatically = true so pending changes are written before the bulk statement runs.
Java
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// ── JPQL query — operates on entity fields, not column names: ─────
@Query("SELECT u FROM User u WHERE u.email = :email")
Optional<User> findByEmailJpql(@Param("email") String email);
// ── JPQL with JOIN FETCH — prevents N+1 (see next section): ──────
@Query("SELECT u FROM User u LEFT JOIN FETCH u.orders WHERE u.id = :id")
Optional<User> findByIdWithOrders(@Param("id") Long id);
// ── JPQL with optional filters: ──────────────────────────────────
// Works on H2/MySQL. On PostgreSQL a null enum/date parameter can fail
// with "could not determine data type" — prefer Specifications there.
@Query("""
SELECT u FROM User u
WHERE (:role IS NULL OR u.role = :role)
AND (:active IS NULL OR u.active = :active)
AND (:search IS NULL OR LOWER(u.name) LIKE LOWER(CONCAT('%', :search, '%')))
""")
Page<User> search(
@Param("role") User.Role role,
@Param("active") Boolean active,
@Param("search") String search,
Pageable pageable);
// ── @Query with Pageable — provide countQuery for Page<T>: ────────
@Query(value = "SELECT u FROM User u WHERE u.role = :role",
countQuery = "SELECT COUNT(u) FROM User u WHERE u.role = :role")
Page<User> findByRolePaged(@Param("role") User.Role role, Pageable pageable);
// ── Native SQL — operates on actual table/column names: ───────────
@Query(value = "SELECT * FROM users WHERE email = :email",
nativeQuery = true)
Optional<User> findByEmailNative(@Param("email") String email);
// ── Native SQL with pagination: ───────────────────────────────────
@Query(value = "SELECT * FROM users WHERE role = :role ORDER BY created_at DESC",
countQuery = "SELECT COUNT(*) FROM users WHERE role = :role",
nativeQuery = true)
Page<User> findByRoleNative(@Param("role") String role, Pageable pageable);
// ── Modifying query — UPDATE or DELETE: ───────────────────────────
// clearAutomatically: evicts stale entities from the persistence context
// flushAutomatically: writes pending changes before the bulk statement
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
@Query("UPDATE User u SET u.active = false WHERE u.lastLoginAt < :cutoff")
int deactivateInactiveUsers(@Param("cutoff") LocalDateTime cutoff);
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
@Query("DELETE FROM User u WHERE u.active = false AND u.createdAt < :cutoff")
int deleteOldInactiveUsers(@Param("cutoff") LocalDateTime cutoff);
// ── Projections — select specific fields only: ────────────────────
@Query("SELECT u.id AS id, u.name AS name, u.email AS email FROM User u")
List<UserSummary> findAllSummaries();
}
// ── Projection interface — maps query results to fields: ───────────────
public interface UserSummary {
Long getId();
String getName();
String getEmail();
}The N+1 Query Problem and How to Fix It
The N+1 problem is the most common performance bug in Spring Data JPA. You load N users with one query, then touch a lazy collection such as user.getOrders() inside a loop, and Hibernate fires one extra query per user. 100 users means 101 queries.
You can see it by enabling SQL logging with spring.jpa.show-sql=true or, better, logging.level.org.hibernate.SQL=DEBUG. If the log shows the same select ... from orders where user_id=? repeated, you have the problem.
There are three fixes. JOIN FETCH in @Query loads the association in the same query, but combining it with pagination makes Hibernate paginate in memory for collections, so avoid it with Pageable. @EntityGraph does the same without writing JPQL and works with derived methods. For paginated lists, use a batch size (spring.jpa.properties.hibernate.default_batch_fetch_size=50) so the lazy collections load in groups of 50 instead of one at a time. Keep associations LAZY by default and fetch eagerly only per use case.
Java
// ── The problem: 1 query for users + 1 query per user for orders ──────
List<User> users = userRepository.findAll(); // select * from users
for (User u : users) {
System.out.println(u.getOrders().size()); // select * from orders where user_id=? (xN)
}
// ── Fix 1: JOIN FETCH (single query, avoid with Pageable on collections) ─
@Query("SELECT DISTINCT u FROM User u LEFT JOIN FETCH u.orders")
List<User> findAllWithOrders();
// select distinct u.*, o.* from users u left join orders o on o.user_id = u.id
// ── Fix 2: @EntityGraph on a derived method — no JPQL needed: ─────────
@EntityGraph(attributePaths = {"orders"})
List<User> findByRole(User.Role role);
@EntityGraph(attributePaths = {"orders", "profile"})
Optional<User> findWithDetailsById(Long id);
// ── Fix 3: batch fetching — best when paginating (application.properties): ─
// spring.jpa.properties.hibernate.default_batch_fetch_size=50
// Orders for 50 users load with one "where user_id in (?, ?, ... )" query.
// ── Where the entity is mapped, keep collections lazy: ───────────────
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Order> orders = new ArrayList<>();Pagination and Sorting
Pageable and Sort are first-class parameters. Pass a Pageable to a repository method that returns Page<T> and you get the content plus total element count and page count. Spring MVC resolves Pageable automatically from the page, size and sort request parameters, and @PageableDefault sets fallbacks.
Pitfalls: (1) Page<T> runs an extra COUNT query on every call. If you only need "is there a next page", return Slice<T>, which skips the count and is faster on big tables. (2) Page numbers are zero-based by default. (3) Cap the page size, otherwise a client can request size=100000. (4) Sort properties are entity field names, not column names, and unvalidated sort input from the client can throw errors, so whitelist allowed sort fields for public APIs. (5) Since Spring Data 3.3, serializing a Page directly to JSON logs a warning because the PageImpl structure is not a stable contract. Wrap it with PagedModel, or return your own response DTO.
Java
// ── Service — accept Pageable from the controller: ────────────────────
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserService {
private final UserRepository userRepository;
public Page<UserResponse> findAll(Pageable pageable) {
return userRepository.findAll(pageable).map(UserResponse::from);
}
// ── Build Pageable programmatically: ──────────────────────────────
public Page<UserResponse> findAll(int page, int size, String sortBy) {
Pageable pageable = PageRequest.of(
page, // zero-based page index
size, // page size
Sort.by(Sort.Direction.DESC, sortBy) // sort
);
return userRepository.findAll(pageable).map(UserResponse::from);
}
// ── Multi-column sort: ────────────────────────────────────────────
public Page<UserResponse> findAllMultiSort() {
Sort sort = Sort.by(
Sort.Order.asc("role"),
Sort.Order.desc("createdAt"),
Sort.Order.asc("name")
);
return userRepository.findAll(PageRequest.of(0, 20, sort))
.map(UserResponse::from);
}
// ── Sort without pagination: ──────────────────────────────────────
public List<UserResponse> findAllSorted() {
return userRepository.findAll(Sort.by("name"))
.stream().map(UserResponse::from).toList();
}
}
// ── Slice — no COUNT query, only knows if a next page exists: ─────────
Slice<User> findByActiveTrue(Pageable pageable);
// ── Controller — Pageable from HTTP query parameters: ─────────────────
@GetMapping
public PagedModel<UserResponse> findAll(
@PageableDefault(size = 20, sort = "createdAt",
direction = Sort.Direction.DESC)
Pageable pageable) {
return new PagedModel<>(userService.findAll(pageable));
}
// GET /users → page 0, size 20, sort createdAt DESC
// GET /users?page=2&size=10 → page 2, size 10
// GET /users?sort=name,asc → sort by name ascending
// GET /users?sort=role,asc&sort=name,asc → multi-column sort
// ── Page<T> structure (what the repository returns): ──────────────────
// {
// "content": [...],
// "totalElements": 148,
// "totalPages": 8,
// "first": true,
// "last": false,
// "size": 20,
// "number": 0,
// "numberOfElements": 20,
// "empty": false
// }Pagination Configuration (application.yml)
Set global limits so every endpoint is protected without repeating annotations. max-page-size stops clients from requesting huge pages, default-page-size applies when size is missing, and one-indexed-parameters lets APIs use page=1 as the first page. Leave it false unless your front end expects 1-based pages, because mixing the two causes off-by-one bugs where page=1 silently returns the second page.
yaml
spring:
data:
web:
pageable:
max-page-size: 100 # requests above this are clamped to 100
default-page-size: 20 # used when no size parameter is sent
one-indexed-parameters: false # keep 0-based (default)Projections — Selecting Specific Fields
Loading full entities when you only need two or three fields wastes memory and produces wide SELECT statements. Projections map results to a smaller shape. Interface projections are simplest, record or class DTO projections are the fastest because they are plain objects, and dynamic projections let the caller choose the shape.
Pitfalls: (1) A closed interface projection (only getters matching entity fields) lets Spring select just those columns. An open projection that uses @Value with SpEL, like getDisplayName below, forces Spring to load the whole entity, so you lose the optimization. (2) JPQL constructor expressions need the fully qualified class name, which breaks silently on package renames. (3) Projections are read-only: you cannot save them back.
Java
// ── Interface projection — Spring generates an implementation: ────────
public interface UserSummary {
Long getId();
String getName();
String getEmail();
// Open projection with @Value + SpEL: convenient, but loads the full entity.
@Value("#{target.name + ' <' + target.email + '>'}")
String getDisplayName();
}
// ── Class projection (DTO) — constructor expression in JPQL: ──────────
public record UserDto(Long id, String name, String email) { }
// ── Repository — return projections directly: ─────────────────────────
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// Interface projection — Spring generates SELECT id, name, email:
List<UserSummary> findByRole(User.Role role);
// Generic projection — caller specifies the projection type:
<T> List<T> findByRole(User.Role role, Class<T> type);
// DTO projection via @Query JPQL constructor expression:
@Query("SELECT new com.example.dto.UserDto(u.id, u.name, u.email) " +
"FROM User u WHERE u.role = :role")
List<UserDto> findDtosByRole(@Param("role") User.Role role);
// Interface projection with Pageable:
Page<UserSummary> findByActiveTrue(Pageable pageable);
}
// ── Usage — caller picks the projection: ──────────────────────────────
List<UserSummary> summaries = userRepository.findByRole(User.Role.ADMIN);
List<UserDto> dtos = userRepository.findByRole(
User.Role.ADMIN, UserDto.class);
// ── Dynamic projection — same method, different return shapes: ─────────
<T> Page<T> findByActiveTrue(Pageable pageable, Class<T> type);
// Call with full entity:
Page<User> full = repo.findByActiveTrue(pageable, User.class);
// Call with summary projection:
Page<UserSummary> summaries = repo.findByActiveTrue(pageable, UserSummary.class);Specifications — Dynamic Queries
When filters are optional and vary per request, Specifications are cleaner and safer than string-built JPQL or the ":param IS NULL OR ..." trick. Each Specification is a small reusable predicate, and you combine them with and() and or(). A predicate that returns null is ignored, which is exactly what you want for an optional filter.
Notes: (1) The repository must extend JpaSpecificationExecutor. (2) In recent Spring Data JPA versions (3.5+) Specification.where(...) is deprecated, so use Specification.allOf(...) as shown, or Specification.unrestricted() as a starting point. On older versions, Specification.where(...).and(...) still works. (3) Field names inside root.get("...") are plain strings and are only checked at runtime. If you want compile-time safety, use the JPA metamodel generator (User_.role). (4) Use a countQuery-safe approach with Pageable: Spring runs the count automatically for specifications.
Java
// ── Enable Specifications — extend JpaSpecificationExecutor: ──────────
@Repository
public interface UserRepository extends JpaRepository<User, Long>,
JpaSpecificationExecutor<User> { }
// ── Define reusable Specification predicates: ─────────────────────────
public class UserSpecifications {
public static Specification<User> hasRole(User.Role role) {
return (root, query, cb) ->
role == null ? null : cb.equal(root.get("role"), role);
}
public static Specification<User> isActive(Boolean active) {
return (root, query, cb) ->
active == null ? null : cb.equal(root.get("active"), active);
}
public static Specification<User> nameContains(String search) {
return (root, query, cb) ->
search == null || search.isBlank() ? null :
cb.like(cb.lower(root.get("name")),
"%" + search.toLowerCase() + "%");
}
public static Specification<User> createdAfter(LocalDateTime date) {
return (root, query, cb) ->
date == null ? null : cb.greaterThan(root.get("createdAt"), date);
}
}
// ── Service — compose specifications at runtime: ──────────────────────
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserService {
private final UserRepository userRepository;
public Page<UserResponse> search(UserSearchRequest request, Pageable pageable) {
// Spring Data JPA 3.5+: allOf() replaces the deprecated where().
// On older versions: Specification.where(a).and(b).and(c)
Specification<User> spec = Specification.allOf(
UserSpecifications.hasRole(request.role()),
UserSpecifications.isActive(request.active()),
UserSpecifications.nameContains(request.search()),
UserSpecifications.createdAfter(request.createdAfter())
);
return userRepository.findAll(spec, pageable).map(UserResponse::from);
}
}
// ── Request DTO: ───────────────────────────────────────────────────────
public record UserSearchRequest(
User.Role role, // null = all roles
Boolean active, // null = both active and inactive
String search, // null = no name filter
LocalDateTime createdAfter // null = no date filter
) { }Auditing with Spring Data
Auditing fills createdAt, updatedAt, createdBy and updatedBy automatically on save, so you do not need @PrePersist and @PreUpdate callbacks on every entity. You need three things: @EnableJpaAuditing, an AuditorAware bean that says who the current user is, and @EntityListeners(AuditingEntityListener.class) on a shared base class.
Pitfalls: (1) Forgetting @EnableJpaAuditing is the usual reason the fields stay null. (2) If the AuditorAware returns Optional.empty() (for example in a scheduled job with no logged-in user), createdBy stays null, so return a fallback like "system". (3) Anonymous requests give an authentication object named "anonymousUser", so check isAuthenticated if you do not want that stored. (4) @LastModifiedDate is only updated when the entity actually changes, and bulk @Modifying queries bypass auditing entirely.
Java
// ── 1. Enable auditing on the main application class: ────────────────
@SpringBootApplication
@EnableJpaAuditing(auditorAwareRef = "auditorProvider")
public class MyApplication { }
// ── 2. Provide the current auditor (who is making the change): ────────
@Bean
public AuditorAware<String> auditorProvider() {
return () -> Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.filter(Authentication::isAuthenticated)
.map(Authentication::getName)
.or(() -> Optional.of("system")); // fallback for jobs / anonymous
}
// ── 3. Define an auditable base class: ────────────────────────────────
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class Auditable {
@CreatedDate
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(nullable = false)
private LocalDateTime updatedAt;
@CreatedBy
@Column(updatable = false, length = 100)
private String createdBy;
@LastModifiedBy
@Column(length = 100)
private String updatedBy;
// getters
}
// ── 4. Extend entities from the base class: ───────────────────────────
@Entity
@Table(name = "products")
public class Product extends Auditable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
private BigDecimal price;
// Spring Data automatically sets createdAt, updatedAt,
// createdBy, updatedBy on save — no manual lifecycle callbacks needed.
}Custom Repository Implementations
When derived methods, @Query and Specifications are not enough (complex Criteria API logic, bulk operations, Hibernate-specific APIs) you add your own code to a repository through a fragment. You declare a fragment interface, write a class named after the fragment with an Impl suffix, and extend the fragment from your main repository. Spring Data detects the implementation by that naming convention and merges it in, so callers still use a single injection point.
Pitfalls: (1) The class name must be exactly the fragment interface name plus Impl, for example UserRepositoryCustomImpl, or Spring will not pick it up and the method fails at startup with a "No property found" error. (2) Inject EntityManager, not a Session, unless you need Hibernate-specific features. (3) Mark write methods @Transactional because the fragment is not covered by the repository's default transactions the way inherited methods are. (4) Always validate or cap filter.getLimit() before using it in setMaxResults.
Java
// ── 1. Fragment interface — declares the custom methods: ─────────────
public interface UserRepositoryCustom {
List<User> searchWithComplexCriteria(UserFilter filter);
int bulkUpdateStatus(List<Long> ids, String status);
}
// ── 2. Implementation — Impl suffix is required by convention: ─────────
@RequiredArgsConstructor
public class UserRepositoryCustomImpl implements UserRepositoryCustom {
private final EntityManager em;
@Override
public List<User> searchWithComplexCriteria(UserFilter filter) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<User> query = cb.createQuery(User.class);
Root<User> root = query.from(User.class);
List<Predicate> predicates = new ArrayList<>();
if (filter.getName() != null) {
predicates.add(cb.like(cb.lower(root.get("name")),
"%" + filter.getName().toLowerCase() + "%"));
}
if (filter.getRole() != null) {
predicates.add(cb.equal(root.get("role"), filter.getRole()));
}
if (filter.getMinAge() != null) {
predicates.add(cb.ge(root.get("age"), filter.getMinAge()));
}
query.where(predicates.toArray(new Predicate[0]));
query.orderBy(cb.desc(root.get("createdAt")));
return em.createQuery(query)
.setMaxResults(Math.min(filter.getLimit(), 100)) // cap the limit
.getResultList();
}
@Override
@Transactional
public int bulkUpdateStatus(List<Long> ids, String status) {
return em.createQuery(
"UPDATE User u SET u.status = :status WHERE u.id IN :ids")
.setParameter("status", status)
.setParameter("ids", ids)
.executeUpdate();
}
}
// ── 3. Main repository — extends both JpaRepository and the fragment: ─
@Repository
public interface UserRepository extends JpaRepository<User, Long>,
UserRepositoryCustom {
// All JpaRepository methods + searchWithComplexCriteria + bulkUpdateStatus
// available through a single injection point.
}