4 ways to query using Spring Data
The ability to efficiently access and manipulate data is crucial for building robust, scalable, and maintainable applications. Traditionally, interacting with databases and data repositories required developers to write intricate and boilerplate code, often diverting their attention from core business logic. However, with the advent of modern frameworks and libraries, developers can now focus on what truly matters — creating innovative solutions to real-world problems. One such powerful tool that has revolutionised data access in the Java ecosystem is Spring Data.
In this article, we will explore how Spring Data enables developers to tackle various data access scenarios elegantly. We will delve into common use cases, each presenting a unique data access challenge, and demonstrate how Spring Data provides intuitive solutions to overcome them.
1. You have to fetch entities by a custom condition
Let’s begin with a straightforward scenario: fetching an entity from your database based on a condition. For this case, I recommend opting for the simplest approach by using query methods. Not only is this method highly readable, but it also effectively accomplishes its task. To further enhance your development experience, I strongly suggest using an IDE that provides Spring Data support, as it offers additional (type-)safety measures and code completion. This becomes particularly beneficial when adhering to conventions in your code.
interface PersonRepository : JpaRepository<Person, UUID> {
fun findDistinctByAgeAndNameIgnoreCase(age: Int, name: String): List<Person>
}
Here’s a more detailed explanation of the method:
findDistinctBy
: This is a keyword prefix that indicates that we want to find distinct records that match the given criteria.AgeAndName
: These are the properties (fields) of thePerson
entity that we want to filter by. The method will findPerson
entities that have both the specifiedage
andname
.IgnoreCase
: This is a keyword suffix that indicates that thename
comparison should be case-insensitive. It means that the method will consider names with different casing as equal (e.g., "John" and "john" will be treated as the same).
2. You have to fetch multiple related entities at once
I assume that you have employed FetchType.LAZY
on every entity relationship. If not, I strongly advise you to investigate why this practice is considered a best practice. However, there are certain scenarios where you need to retrieve a hierarchy or a graph of entities, and using lazy loading for entity relationships can result in performance issues, such as the notorious N+1 problem
.
Switching to FetchType.EAGER
might seem like a solution, but it introduces another problem: JPA/Hibernate will load these collections regardless of whether they are necessary for the current use case, leading to potential inefficiencies.
To address this challenge, I recommend utilising the power of @NamedEntityGraph
, which allows you to modify the fetch plan at runtime. This way, you can optimize the retrieval of related entities based on the specific requirements of each use case. By strategically defining entity graphs, you can strike a balance between fetching only the data needed and avoiding unnecessary data loading, ultimately enhancing the performance and efficiency of your application.
@Entity
@NamedEntityGraph(name = "Author.books",
attributeNodes = @NamedAttributeNode("books")
)
data class Author(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
val name: String,
@OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
val books: List<Book> = emptyList()
)
And in your repository you can provide the entity graph that you wanted:
@Repository
interface AuthorRepository : JpaRepository<Author, Long> {
@EntityGraph(value = "Author.books", type = EntityGraphType.LOAD)
fun findByName(name: String): Author?
}
Or you can use an AD-HOC graph definition:
@Repository
interface AuthorRepository : JpaRepository<Author, Long> {
@EntityGraph(attributePaths = ["books"])
fun findByName(name: String): Author?
}
In this case, the query will fetch the Author
with a collection of books. Everything is executed at once and the books are fetched eagerly. Another nice feature is that it allows you to declare subgraphs if you want go deep through your entity hierarchy/graph structure.
3. You have to aggregate data
Whether you need to calculate statistics, generate reports, or provide analytics, efficient data aggregation is essential for making informed decisions. Instead of fetching entities you want to combine and to aggregate using existing functions some data into an object:
interface SaleRepository : JpaRepository<Sale, Long> {
@Query("SELECT NEW com.example.dto.RevenuePerCategoryDTO(s.product.category, SUM(s.price)) FROM Sale s GROUP BY s.product.category")
fun getTotalRevenuePerCategory(): List<RevenuePerCategoryDTO>
}
In this case, I recommend to use JPQL
to access built-in functions like sum
or other aggregation functions. JPQL
allows you create instances of your DTO directly from the query results (Also known as Projection technique). The thing I don’t like using JPQL
is lack of full type safety since it uses string-based queries and if it is too big or complex, it becomes a little bit unmaintainable. Again, make sure that your IDE can give you a little bit of help in this directions using built-in linters features or 3rd party plugins.
4. You need just a few properties from your entity
In enterprise projects, it is not uncommon for entities to grow significantly in size, accumulating numerous properties over time. In my own experience, I have encountered projects where entities swelled to encompass almost 50–70 properties. While this may not be an ideal situation, it’s a reality we sometimes face; no software project is entirely flawless.
Now, consider the scenario where you need to query this massive entity to display just two or three specific properties on the UI. It’s akin to searching for a needle in a haystack! The inefficiency and performance overhead in retrieving a bulk of unnecessary data can be quite significant.
To address this issue, I highly recommend employing the technique I mentioned earlier: Projection. By using Projection, you can selectively retrieve only the required properties from the entity, rather than fetching the entire entity itself. This approach results in more focused and optimized data retrieval, leading to improved UI performance and reduced database load:
@Entity
class Person {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
var id: UUID? = null
@Column
var firstName: String? = null
@Column
var lastName: String? = null
@Column
var age: Int? = null
@OneToMany
var addresses: List<Address>? = null
// ... and 100 properties more
}
interface PersonRepository : JpaRepository<Person, UUID> {
@Query("SELECT new com.example.dto.PersonAgeDto(p.firstName, p.lastName, p.age) FROM Person p")
fun findPeopleNameAndAge(): List<Person>
}
This example is similar with the example above when I discussed about aggregation and I mentioned that complex JPQL
are hard to maintain:
Take the example where you want to retrieve a root entity with a few properties along with its projected child associations on multiple levels. How do you solve that?
To achieve this, I strongly recommend utilising an additional helper library called Blaze Persistence. This powerful library complements your Spring Data repositories with a rich and type-safe API, seamlessly integrating with Spring and offering a bunch of features to enhance your queries. However, the module that specifically captures our interest in this case is the Entity View module.
This module empowers you to create custom, lightweight views of your entities, tailored to match the specific requirements of your use cases. These views, known as “entity views,” serve as a projection mechanism, enabling you to extract only the necessary data from your entities while retaining the type-safety and strong-typing benefits.
@EntityView(Person::class.java)
interface PersonView {
@IdMapping()
fun getId(): UUID?
fun getFirstName(): String?
fun getLastName(): String?
fun getAge(): Int?
@Mapping("addresses.country")
fun getAddresses(): List<String>
}
interface PersonViewRepository : EntityViewRepository<PersonView, UUID> {
fun findAll(): List<PersonView>
}
The problem at hand is addressed through a more elegant solution compared to the existing built-in alternatives like ResultTransformer
or JPA Criteria API
. This solution revolves around creating an “Entity View,” which shares a similar concept with Table View but within the JPA context. By exposing the Entity View through an EntityViewRepository
, you can follow a pattern analogous to JpaRepository
.
Conclusion
Selecting the optimal data retrieval method from your repository technology is a crucial task, as it directly impacts the performance and maintainability of your application. Beyond just the developer experience, factors like maintainability and simplicity should also be taken into consideration. My firm recommendation is to always use the right tool for the right job.
Throughout this article, I have presented four common use cases that Spring Data can efficiently address with its diverse array of features. It is important to note that these solutions can even be combined to tackle more complex scenarios beyond the presented use cases. Always check the documentation to find if there better alternatives than the solution you know.
While Spring Data and JPA are indeed powerful technologies, they do come with their own set of limitations. Therefore, don’t hesitate to explore and experiment with additional libraries like Blaze Persistence, which can seamlessly supplement your requirements and enhance your data access capabilities.
Furthermore, I cannot stress enough the importance of paying close attention to the raw SQL generated by JPA/Hibernate or any other database abstraction tools. Understanding the underlying queries can reveal potential performance bottlenecks and aid in fine-tuning your application’s data access layer.
In conclusion, choosing the right data retrieval methods can help you make the most of Spring Data and JPA. This approach will lead to strong, easy-to-maintain, and high-performance applications. Embrace these practices to ensure an elegant and efficient data access experience.