In this series of articles, I'm sharing my view on refactoring a large legacy codebase that employed many poor practices. To address these issues and develop better Spring Data JPA repositories, I wrote this guide to promote good practices in developing among my former colleagues. The guide is updated and completely rewritten to utilize the latest features of Spring Data JPA.
Some of the examples may seem obvious, but they're not. It's only from your experienced perspective. They're real examples from the production codebase.
Keep in mind that this series of articles explains the latest version of Spring Data JPA, so there may be nuances that I'll highlight differently.
Table of contents
- 1 Designing Spring Data JPA repositories
- 2 Working with queries in repositories
- 3 Spring Data JPA projections
- 4 Using repository methods effectively
- 5 Stored procedures in repositories
- 6 Spring Data Jpa Repositories Cheetsheet
- At the end
1 Designing Spring Data JPA repositories
Spring Data JPA provides several repository interfaces with predefined methods for fetching data. I'll mention only the interesting
- The
`Repository<T, ID>`interface, the father of Spring Data interfaces, is a marker interface for discovery. It has no methods. When using it, you define only what you want. - The
CrudRepositoryinterface, adds basic CRUD methods for faster development, and its twinListCrudRepositorydoes the same, but returnsListinstead ofIterable. - The
PagingAndSortingRepository- adds pagination and sorting only, and it has a twin that returnsList. Guess how it's called, wait a minute, you're right! - The
JpaRepositoryis my favorite, it contains all of the previous interfaces that returnList. Most of the time, I'm using only this interface.
When should you use a Repository and JpaRepository, or something in between? I believe that if you need a strict API for other developers, you can extend from the repository and implement only the necessary operations, rather than granting access to the entire CRUD operations, which could compromise your logic. Use JpaRepository in case you don't have access limitations, and you want faster development.
As an example of API limitations, you may sometimes need to work with logic stored in the database. There are numerous stored procedures, nuances in logic, and more. As a developer, you should be cautious when working with table entities, as this could lead to unpredictable behavior. So, in this case, you're only designing JPA entities and implementing only an empty interface with only specified query methods. With this approach, you're highlighting to other developers that they should implement methods that you need, rather than manipulating raw entities.
Here is actually one more interesting thing that comes from Spring Data JPA repositories. Methods you inherit from CrudRepository/JpaRepository are transactional by default: reads run with @Transactional(readOnly = true), writes with a regular @Transactional.
You usually don’t need Spring Framework @Repository annotation (don't mistake it with JPA's interface) on the interface - discovery is automatic. For reusable bases, annotate the base interface with @NoRepositoryBean.
Extending one of these interfaces informs Spring Data JPA that it should generate an implementation for your interface. For example:
public interface CompanyRepository extends JpaRepository<Company, Long> {
// custom methods will be added here
}
2 Working with queries in repositories
There are two primary methods for querying data using Spring Data JPA repositories. Actually, it's more, but let's focus on the more popular (IMO).
- Derive the query from the method name. Spring parses the method name and generates the appropriate JPQL. This speeds up development and is intuitive for simple conditions.
-
Write the query explicitly with the
@Queryannotation. This approach is more flexible, allowing you to use JPQL or native SQL. In the latest versions of Spring Data, you can use@NativeQueryan annotation instead of passingnativeQuery = true.
For data-modifying queries (UPDATE/DELETE), add @Modifying, and make sure there is a transactional boundary - either annotate the repository method or class with @Transactional. Another way is to call it from a @Transactional service.
Example methods using both approaches:
// Derived query
List<Employee> findByDepartmentIdAndActiveTrue(Long departmentId);
// Explicit JPQL query
@Query("SELECT e FROM Employee e WHERE e.department.id = :deptId AND e.active = true")
List<Employee> findActiveEmployees(@Param("deptId") Long departmentId);
// Native SQL query
@Modifying
@Transactional
@NativeQuery(value = "UPDATE employee SET active = false WHERE id = :id")
void deactivateEmployee(@Param("id") Long id);
In the example above, the first two methods are select queries. The last one is an update (deactivation), serving a different purpose than the selects.
The first approach shortens the time required to develop queries and is intuitive. The second example provides additional capabilities when creating methods for working with the database, allowing you to write queries using both JPQL and native SQL.
Inherited data-modifying methods are marked @Transactional by default, as mentioned before. For custom modifying queries, annotate with @Modifying and ensure a transactional boundary is present (on the method or class, or at the service layer).
3 Spring Data JPA projections
Using raw entities for users from the database may be impractical or unsafe. It may be acceptable to retrieve the full entity and work within the application, but it's better to adapt your queries to return only the necessary information.
To address this, you should utilize Spring Data JPA projections, which enable the definition of how data from the database will be presented. In the examples described above, the Spring Data JPA projection returns only the selected attributes needed by the caller.
Spring Data JPA provides the following types of projections:
- Projections defined via interfaces are also known as interface-based projections
- Projections to DTO objects. Read the guide about developing DTOs in a series of articles about Spring Data JPA.
- Dynamic projections.
Interface-based projection allows you to create read-only projections for safely presenting data from the database. This approach is typically used when there is no need to manipulate the created object, and it is required only for displaying data. Note that accessing nested properties can result in joins and additional queries, so projections are not always faster than fetching entities. Always check the generated SQL to ensure optimal performance.
For example, an interface-based Spring Data JPA projection:
public interface EmployeeView {
String getFirstName();
String getLastName();
BigDecimal getSalary();
}
List<EmployeeView> findBySalaryGreaterThan(BigDecimal amount);
DTO-based projection enables projecting onto Java classes, allowing you to work with concrete DTO objects rather than interfaces. For derived query methods, Spring can map results to a DTO via its constructor, while for @Query JPQL requires the use of a constructor expression. Class-based projections require a single all-args constructor; if there are multiple constructors, annotate the intended one with @PersistenceCreator.
public class EmployeeDto {
private final String firstName;
private final String lastName;
private final BigDecimal salary;
public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
public BigDecimal getSalary() { return salary; }
public EmployeeDto(String firstName, String lastName, BigDecimal salary) {
this.firstName = firstName;
this.lastName = lastName;
this.salary = salary;
}
}
@Query("SELECT new com.example.EmployeeDto(e.firstName, e.lastName, e.salary) FROM Employee e WHERE e.salary > :amount")
List<EmployeeDto> findHighEarningEmployees(@Param("amount") BigDecimal amount);
You can use dynamic projections with repositories to expose a generic method, allowing the caller to choose the projection type at runtime. The Class parameter selects the projection type. If you need to pass a Class into the query itself, use a different parameter so it is not consumed as the projection selector.
When using DTO classes with dynamic projections, ensure the query supplies the constructor arguments (for example, via a JPQL constructor expression); otherwise, the call will fail at runtime.
List findBySalaryGreaterThan(BigDecimal amount, Class type);
// Usage:
repo.findBySalaryGreaterThan(new BigDecimal("1000"), EmployeeView.class); // interface projection
repo.findBySalaryGreaterThan(new BigDecimal("1000"), EmployeeDto.class); // DTO class (with suitable query)
4 Using repository methods effectively
Repository CRUD methods run in a transaction by default (readOnly = true for reads, regular for writes) as it was mentioned before. The next thing about transaction is to avoiding opening transactions manually at call sites.
When performing operations on multiple entities, prefer batch methods such as saveAll() instead of calling save() in a loop. Grouping actions into a single query reduces the number of database round-trips.
Prefer batch-oriented writes, but note that saveAll() it does not issue a single SQL statement by itself. To actually reduce round-trips, enable JDBC batching (for example, spring.jpa.properties.hibernate.jdbc.batch_size=50 and often hibernate.order_inserts=true/hibernate.order_updates=true). Avoid GenerationType.IDENTITY if you need insert batching, and for very large batches, call flush()/clear() periodically.
Whenever possible, combine logic into a single query rather than performing multiple queries in Java. In some cases, it is more efficient to offload part of an algorithm to the database using SQL.
For large result sets, use pagination. Page<T> returns content plus totals and triggers a count query (for custom @Query supply a countQuery), Slice<T> returns content and whether there is a next slice without a count query, and a List<T> with a Pageable parameter applies limit/offset but gives no metadata.
// 1) Derived query with Page and sorting
interface UserRepository extends JpaRepository<User, Long> {
Page<User> findByActive(boolean active, Pageable pageable);
}
// Usage:
Pageable pageable = PageRequest.of(0, 20, Sort.by("createdAt").descending());
Page<User> page = userRepository.findByActive(true, pageable);
List<User> users = page.getContent();
long total = page.getTotalElements();
boolean last = page.isLast();
// 2) Derived query with Slice for infinite scroll (no count query)
interface UserRepository extends JpaRepository<User, Long> {
Slice<User> findByActive(boolean active, Pageable pageable);
}
5 Stored procedures in repositories
While developing a database-oriented app, you could use Spring Data JPA to call stored procedures defined in your database. There are different ways to do it.
The first method is to use NamedStoredProcedureQuery:
- Declare it on an entity with
@NamedStoredProcedureQuery, specifying:-
name– the identifier used by JPA, -
procedureName– the actual name of the procedure in the database, -
parameters– an array of@StoredProcedureParameterobjects defining each parameter’s mode (IN/OUT), name and Java type.
-
- Add a method to your repository annotated with
@Procedureand referencing the declared name.
For multiple outs parameters, Spring Data JPA can return a Map<String,Object> when the call is backed by a @NamedStoredProcedureQuery. For a single out, you can return that value directly. There’s also outputParameterName on @Procedure for targeting a specific out param.
Example declaration on an entity:
@NamedStoredProcedureQuery(
name = "Employee.raiseSalary",
procedureName = "raise_employee_salary",
parameters = {
@StoredProcedureParameter(mode = ParameterMode.IN, name = "in_employee_id", type = Long.class),
@StoredProcedureParameter(mode = ParameterMode.IN, name = "in_increase", type = BigDecimal.class),
@StoredProcedureParameter(mode = ParameterMode.OUT, name = "out_new_salary", type = BigDecimal.class)
}
)
@Entity
public class Employee { … }
Repository method:
@Procedure(name = "Employee.raiseSalary")
BigDecimal raiseSalary(@Param("in_employee_id") Long id,
@Param("in_increase") BigDecimal increase);
The second method is to call a procedure without defining JPA metadata by using @Procedure(procedureName = "…") directly on the repository method, or even call it via @Query(value = "CALL proc(:arg…)", nativeQuery = true).
Actually, there is one more method, but it's less canonical is to use entity manager to call stored procedures, this article will not cover this practice as it will be in the next article and last article in this series.
6 Spring Data Jpa Repositories Cheetsheet
To briefly sum up this design guide, you could use the following cheetsheet.
6.1 What Spring Data JPA repositories to choose?
Interfaces to extend
-
Repository<T, ID>— marker only; you define every method yourself. -
CrudRepository<T, ID>— basic CRUD; collections returnIterable. -
ListCrudRepository<T, ID>— likeCrudRepository, but collections returnList. -
PagingAndSortingRepository<T, ID>— adds paging & sorting. -
ListPagingAndSortingRepository<T, ID>— list-returning twin. -
JpaRepository<T, ID>— all of the above + JPA niceties (flush, batch deletes, etc.). Default choice in most apps.
When to pick what
- Need a strict, minimal API? Extend
Repository(or a slim base) and expose only allowed methods. - Need speed of development? Extend
JpaRepository.
Discovery & base config
-
@Repositoryis not required on repository interfaces; Spring detects them by type. - For reusable base interfaces, annotate with
@NoRepositoryBean. - Default implementation is backed by
SimpleJpaRepository.
Transactions (defaults)
- Defaults apply to inherited CRUD methods: reads use
@Transactional(readOnly = true), writes use regular@Transactional. -
Your own query methods (derived names or
@Query) are not transactional by default; annotate them or invoke from a transactional service.
6.2 How to query data with Spring Data JPA?
Two core approaches
- Derived queries (by method name) for simple conditions.
-
Explicit queries with
@Query(JPQL) or native queries via either@Query(..., nativeQuery = true)or@NativeQuery(modern shortcut; supports extras likesqlResultSetMapping).
Modifying queries
- Add
@Modifyingand ensure a transactional boundary (@Transactionalon method/class or call from a transactional service).
Paging with custom queries
- With
Page<T>and complex JPQL/native queries, supply an explicitcountQuery(orcountProjection) to avoid brittle auto-counts.
6.3 Best ways to use Spring Data JPA Projections
Types
- Interface-based projections — read-only views for safe data presentation.
-
DTO/class-based projections — map to a class with a single all-args constructor (use
@PersistenceCreatorif multiple constructors exist). -
Dynamic projections — expose a generic method and let callers pass
Class<T>to choose the projection type at runtime.
Notes
- Accessing nested properties in projections can trigger joins. Projections aren’t automatically faster than entities. Inspect SQL and returned columns and measure queries performance.
- When using DTOs with dynamic projections, ensure the query provides constructor args (e.g., via a JPQL constructor expression).
6.4 Short notes how to use queries effectively
Batching & round-trips
- Prefer
saveAll(...)over repeatedsave(...). - Avoid
GenerationType.IDENTITYif you need insert batching. Prefer sequences/pooled optimizers. - For very large batches, periodically call
flush()/clear().
Let the DB work
- Push set-based logic into single queries instead of multi-step Java loops where possible.
Paging options
-
Page<T>— content + totals (triggers count query). -
Slice<T>— content + “has next” (no count query, good for infinite scroll). -
List<T>with aPageableparameter — applies limit/offset, no metadata.
6.5 Calling stored procedures from Spring Data JPA
Approaches
-
Named stored procedure: declare on an entity with
@NamedStoredProcedureQuery, then call via repository method annotated with@Procedure(name = "..."). -
Direct call (no entity metadata): use
@Procedure(procedureName = "...")on the repository method, or call using@Query(value = "CALL ...", nativeQuery = true).
Outputs
- Multiple
OUTparams (with a named stored procedure) can be returned asMap<String,Object>. - Single
OUTcan be returned directly, or target a specific one usingoutputParameterNameon@Procedure.
At the end
I hope you find this article helpful. The continuation of the series articles will be published soon, so connect with me on LinkedIn to stay informed about new articles. If you missed the previous article, read my “Spring Data JPA Best Practices: Entity Design Guide"
Bye!
Top comments (0)