Java Records in JPA/Spring Data: Challenges and Usecases

Java records cannot be used as the replacement for JPA entities because they restrict the byte code manipulation, which is generally required for persistence frameworks to function.

Traditionally, JPA is designed to work with regular Java classes, typically referred to as entities, that map to database tables. The JPA entities can have mutable states and additional behavior which Java record does not allow.

This tutorial explores all such limitations, and subsequently, delves into the other possible usages of Java records in a JPA/spring data application.

1. Challenges of using Java Records as JPA Entities?

In Java, records provide a concise way to declare classes for storing data, mainly used for immutable data objects. While records can be very useful for creating simple data classes, there are some challenges and considerations when using these as JPA entities:

  • Entities must be mutable: Java records are inherently immutable. JPA, on the other hand, often requires entities to be mutable to track changes and update the database accordingly.
  • Entities require the default no-arg constructor: The records do not have a default constructor as they automatically generate a constructor with all the record components as parameters. JPA requires entities to have a default no-arg constructor for proper instantiation during the retrieval process.
  • Records present challenges in using annotations: JPA entities sometimes require JPA annotations or configurations for specific use cases, such as defining the primary key or specifying relationships. These annotations are typically placed on classes, fields, or methods, but records combine all three into a concise syntax. This can make it unclear where to put annotations for specific use cases.
  • Versioning and Lifecycle Events usually change the state of entities: JPA provides lifecycle callbacks like @PrePersist, @PostPersist, @PreUpdate etc. allowing developers to define custom logic to be executed before specific entity state transitions. Java records are immutable, by default, meaning their state cannot be changed after instantiation. This will prevent the lifecycle transition needed in the entities.

2. How can we utilize records in JPA?

Java records were introduced as a preview feature in Java 14 [JEP-359] and finalized in Java 16 [JEP-395]. The convenience and security offered by records within Java applications present several advantageous possibilities when coupled with JPA.

For example, records are great as DTO (Data Transfer Object) if we don’t want to change the information once it has been fetched from the database. Records provide better performance than entities and allow us to decouple our domain model from our API.

There are multiple ways that allow fetching data as Java records in a JPA application, and we will explore all such methods in the coming sections.

3. Demo Project Setup

Now, let’s create a simple Spring Boot application that uses Spring Data JPA. We are using Employee entity to connect with the database and EmployeeRecord of record type as a DTO object.

We will look at different ways of projecting the information into EmployeeRecord using Spring Data and JPA APIs such as @Query annotation, CriteriaQuery, etc.

3.1. Maven

To interact with a database we need to add Spring Data JPA dependency to our project. For the purpose of this demonstration, we will use an in-memory H2 database.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

3.2. Domain Model (JPA Entity / Java Record)

The following entity class Employee interacts with the TBL_EMPLOYEE table in the database. We will need this entity to store and manipulate the data in the database.

@Entity
@Table(name = "employee")
public class Employee {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private Long salary;
    
    // constructors, getters, setters
}

The EmployeeRecord is a Java record type and it acts as the immutable data carrier for information of an employee.

public record EmployeeRecord(Long id, String name, Long salary) {}

4. Using Java Records with Spring Data Repositories

In this section, we will look at how to use records with Spring Data Repositories and @Query annotation.

4.1. Automatic Mapping from Entity to Record (when both have the same fields)

Spring Data allows us to utilize records as return types from the repository methods. By doing so, the entity is automatically mapped to the record, provided that both share precisely identical fields.

public interface EmployeeRepository extends CrudRepository<Employee, Long> {

    List<EmployeeRecord> findEmployeeByName(String Name);
}

In this case, the EmployeeRecord has the same fields as the Employee entity. As a result, Spring Data JPA seamlessly maps the entity to the record, leading to the retrieval of a list of records instead of entities when the findEmployeeByName() method is invoked.

@Autowired
private EmployeeRepository employeeRepository;

@Test
void findEmployeeByName() {

    Employee employee = new Employee();
    employee.setId(1L);
    employee.setName("John Doe");
    employee.setSalary(140000L);

    employeeRepository.save(employee);

    List<EmployeeRecord> fetchedEmployee = employeeRepository.findEmployeeByName("John Doe");

    assertNotNull(fetchedEmployee.get(0));
    assertEquals("John Doe", fetchedEmployee.get(0).name());
}

4.2. Custom Repository Implementation (when both DO NOT have the same fields)

When automatic mapping is not feasible, we have the option to implement custom repositories to define their own mappings. This approach allows greater flexibility and control over the data retrieval process.

To begin, we create a class called PartialEmployeeRecord that serves as the return type for methods within the repository:

public record PartialEmployeeRecord(Long id, String name) {}

It’s essential to note that the PartialEmployeeRecord class may not have the same fields as the Employee entity; in this case, it only includes the id and name fields.

Next, we create a custom repository interface, which will use the PartialEmployeeRecord record type as follows:

public interface CustomEmployeeRepository {

    List<PartialEmployeeRecord> findAllEmployees();
}

For the repository’s implementation, we use the JdbcTemplate to interact with the database:

@Repository
public class CustomEmployeeRepositoryImpl implements CustomEmployeeRepository {

	private final JdbcTemplate jdbcTemplate;

	public CustomEmployeeRepositoryImpl(JdbcTemplate jdbcTemplate) {
		this.jdbcTemplate = jdbcTemplate;
	}

	public List<PartialEmployeeRecord> findAllEmployees() {

		return jdbcTemplate.query(
			"SELECT id, name FROM employee",
				(rs, rowNum) -> new PartialEmployeeRecord(
					rs.getLong("id"),
					rs.getString("name")	
				)
			);
	}
}

In the above code, the jdbcTemplate.query() method is used to execute the SQL query and map populates the query results into instances of PartialEmployeeRecord.

@Autowired
private CustomEmployeeRepository customEmployeeRepository;

@Test
void findAllEmployees() {

    //Create user

    List<CustomEmployeeRecord> fetchedEmployee = customEmployeeRepository.findAllEmployees();

    assertNotNull(fetchedEmployee.get(0));
    assertEquals("John Doe", fetchedEmployee.get(0).name());
}

4.3. Returning @Query Results as Records using JPQL

The @Query annotation allows us to define SQL to execute for a Spring Data repository method. @Query annotation is part of JPQL (Java Persistence Query Language) defined in the JPA specification. It is developed based on SQL syntax.

In Spring Data JPA repositories, the use of records can be leveraged through the @Query annotation as follows:

public interface EmployeeRepository extends CrudRepository<Employee, Long> {

    @Query("SELECT new com.example.dto.EmployeeRecord(e.id, e.name, e.salary) FROM Employee e WHERE e.id = :id")
    EmployeeRecord findEmployeeById(@Param("id") Long id);
}

In the following example, we define a custom JPQL query using the @Query annotation, where we select specific fields from the Employee entity to create an EmployeeRecord object.

The EmployeeRecord type represents a custom data transfer object (DTO) with the desired fields: id, name, and salary.

@Autowired
private EmployeeRepository employeeRepository;

@Test
void findEmployeeById() {

    //Create user

    EmployeeRecord fetchedEmployee = employeeRepository.findEmployeeById(1L);

    assertEquals("John Doe", fetchedEmployee.name());
}

5. Using Java Records with JPA

In this section, we will interact with the database and look at how to use records with JPA features such as CriteriaQuery, TupleTransformer and SqlResultSetMapping.

5.1. Using Records with CriteriaQuery

JPA CriteriaQuery enables us to write queries without doing raw SQL as well as gives us some object-oriented control over the queries. The Criteria API allows us to build up a criteria query object programmatically, where we can apply different kinds of filtration rules and logical conditions.

To use records with CriteriaBuilder we need to utilize the construct() method to define a constructor call in our CriteriaQuery. The first method parameter is a reference to the class Hibernate shall instantiate, and all other parameters will be used as constructor parameters.

public List<EmployeeRecord> findAllEmployeeWithSalaryGreater(int salary) {

	CriteriaBuilder cb = entityManager.getCriteriaBuilder();
	CriteriaQuery<EmployeeRecord> query = cb.createQuery(EmployeeRecord.class);
	Root<Employee> root = query.from(Employee.class);

	query.select(cb.construct(EmployeeRecord.class,
		root.get("id"), root.get("name"), root.get("salary")))
		.where(cb.gt(root.get("salary"), salary));
		
	return entityManager.createQuery(query).getResultList();
}

The above code demonstrates how to use CriteriaBuilder to create a CriteriaQuery that returns an EmployeeRecord.

Let’s delve into the steps involved in the code:

  • We create a CriteriaQuery using the CriteriaBuilder.createQuery() method, passing the class of the record we desire to retrieve as the parameter.
  • Next, we create a Root using the CriteriaQuery.from() method, where we specify the entity class as the parameter. This helps us indicate the table we want to query in the database.
  • Next, We use the CriteriaQuery.select() method to define a select clause. To convert the query results into a record, we utilize the CriteriaBuilder.construct() method, which takes the class of the record and the entity fields we want to pass to the record constructor as parameters.
  • Next, we use CriteriaQuery.select(…).where() method to specify our filter condition.
  • Finally, we use the EntityManager.createQuery() method to create a TypedQuery from CriteriaQuery. Subsequently, by calling the TypedQuery.getResultList() method, we retrieve the results of the query.
@Autowired
private EmployeeService employeeService;

@Test
void findAllEmployeeWithSalaryGreater() {

	// create user

	List<EmployeeRecord> fetchedEmployee = employeeService.findAllEmployeeWithSalaryGreater(40000);

	assertNotNull(fetchedEmployee.get(0));
	assertEquals("John Doe", fetchedEmployee.get(0).name());
	assertEquals(40000L, fetchedEmployee.get(0).salary());
}

5.2. Mapping Entity to Record using TupleTransformer or ResultTransformer

TupleTransformer or ResultTransformer provides a powerful and flexible way to map the result of the JPQL, Criteria, and native SQL query to a specific object structure. This can be an entity or DTO objects, java.util.List or java.util.Map representations of each record, or a custom data structure.

For example, we can use a TupleTransformer to return a record instead of an entity. The below example fetches all employee details using TupleTransformer:

public List<EmployeeRecord> findAllEmployeeUsingTupleTransformer() {

	return entityManager.createQuery(" select e.id, e.name, e.salary from Employee e")
		.unwrap(org.hibernate.query.Query.class)
		.setTupleTransformer((objects, strings) -> {
			int i = 0;
			return new EmployeeRecord(
				(Long) objects[i++],
				(String) objects[i++],
				(Long) objects[i++]);
		})
		.getResultList();
}

Let’s understand the above method:

  • The method starts by creating a query using the entityManager.createQuery() method. The query retrieves specific attributes (id, name, and salary) of the Employee entity.
  • The unwrap() is used to get access to the underlying Hibernate Query object. This allows us to set a custom tuple transformer for the query.
  • The setTupleTransformer() sets a custom tuple transformer for the query. A tuple transformer is used to transform the raw result set from the database into the desired Java object. In this case, the transformer takes two arguments: objects (an array of values representing the selected attributes) and strings (an array of attribute names).
  • The lambda function returns a new EmployeeRecord object using the values from the objects array.
  • Finally, the getResultList() method is called on the query to execute it and obtain the list of EmployeeRecord objects as the result.

5.3. Mapping Entities to Records using @SqlResultSetMapping

The @SqlResultSetMapping specifies the mapping of the result of a native SQL query into Java objects. It requires only one property, name. However, without one of the member types, nothing will be mapped. The member types are ColumnResult, ConstructorResult, and EntityResult.

The following example utilizes a mapping, where results are mapped into EmployeeRecord accordingly using @SqlResultSetMapping:

@SqlResultSetMapping(
        name = "EmployeeRecordMapping",
        classes = @ConstructorResult(
                targetClass = EmployeeRecord.class,
                columns = {
                        @ColumnResult(name = "id", type = Long.class),
                        @ColumnResult(name = "name", type = String.class),
                        @ColumnResult(name = "salary", type = Integer.class)
                }
        )
)
@Entity
@Table(name = "employee")
public class Employee {

    //...
}

Let’s break down how this mapping works:

  • The name attribute of the @SqlResultSetMapping annotation specifies the unique identifier for this mapping.
  • The @ConstructorResult annotation indicates that the results will be mapped using the constructor of the EmployeeRecord.
  • The targetClass attribute within @ConstructorResult specifies the target class into which the results will be mapped, here, it’s EmployeeRecord.
  • The @ColumnResult annotations, define the column names and their corresponding data types. These column values will be used to instantiate the EmployeeRecord objects.

With the mapping established, we can now apply it in our native query to obtain results as records:

public List<EmployeeRecord> findAllEmployeeUsingMapping() {
    Query query = entityManager.createNativeQuery("SELECT * FROM employee", "EmployeeRecordMapping");
    return query.getResultList();
}

By invoking the findAllEmployeeUsingMapping() method, we create a native query that fetches all employee data from the database. The results are then automatically converted into EmployeeRecord objects.

@Autowired
private EmployeeService employeeService;

@Test
void findAllEmployeeUsingMapping() {

	List<EmployeeRecord> fetchedEmployee = employeeService.findAllEmployeeUsingMapping();

	assertNotNull(fetchedEmployee.get(0));
	assertEquals("John Doe", fetchedEmployee.get(0).name());
}

6. Conclusion

In this article, we explored the limitations and usecases of Java records as JPA entities within Spring Data JPA and JPA applications. We learned to use records with Spring Data JPA repositories through automatic mapping, custom queries, and custom repository implementations.

Additionally, we also learned how to use records in CriteriaBuilder, TupleTransformer, and SqlResultSetMapping.

Happy Learning !!

Source Code on Github

Comments

Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.

Our Blogs

REST API Tutorial

Dark Mode

Dark Mode