Using Java Records In Real Codebase
In recent years, Java has introduced an impressive number of features. The development of Java has historically been relatively slow; it took about 2.5 years to go from Java 7 to Java 8, then 3.5 years to reach Java 9. But since Java 11, there has been a new Java release every 6 months, and these days LTS1 versions are released every two years.
Some features attract a lot of eyeballs. One feature that deserves more
attention is the addition of record classes in Java 16.
Record classes, or records for short, are classes which are transparent
holders for shallowly immutable data2. Records can be seen as a regular
Java class where all fields are final
and accessible via getters, and where
the equals
, hashCode
, and toString
methods have been
properly implemented.
Informally, a record is a radical class; a class that is only meant to be used as a value carrier. Records represent real “things” such as cars, users, and accounts; not logic containers such as controllers, services, or configurations.
The reason to get excited about records two years after their release is that they are now well integrated into the ecosystem but still underused.
Record syntax
Record syntax is extremely short. Here is an example of a record representing a point on a Cartesian coordinate system.
record Point(int x, int y) { }
Below is an equivalent class to the above record.
final class Point {
private final int x;
private final int y;
Point(int x, int y) {
this.x = x;
this.y = y;
}
int x() { return x; }
int y() { return y; }
public boolean equals(Object o) {
if (!(o instanceof Point)) return false;
Point other = (Point) o;
return other.x == x && other.y == y;
}
public int hashCode() {
return Objects.hash(x, y);
}
public String toString() {
return String.format("Point[x=%d, y=%d]", x, y);
}
}
Visibly here, a record can save about 15 lines of code.
It automates the implementation of equals
and hashCode
function
which are error-prone.
Technicalities
All records are final and, as enums, they cannot extend another class, but they can implement interfaces and have methods of their own.
interface ComparableInSize<T>{
int compareTo(T o);
}
record Point(int x, int y) implements ComparableInSize<Point> {
public double distanceFromOrigin(){
return Math.sqrt(x*x + y*y);
}
@Override
public int compareTo(Point o) {
return Double.compare(distanceFromOrigin(), o.distanceFromOrigin());
}
}
Example of use - Typed ids
Records are great for specifying the data that go through a web server. A
good utilization of records is to build typed IDs. To lower the probability
of an error, it is best to avoid using generic types for IDs such as int
or
String
. It is better to use very specific types such as UserId
or
StoreId
. Doing so reduces the chances of swapping the IDs when calling a
function and makes the code much easier to read.
Before records, dedicated ID classes could be considered overkill by some, but since records are available, no IDs should be left untyped. See how easy it is to implement proper ID classes
record UserId(int id){}
record AccountId(int id){}
Below is an example of an API implemented in two different ways. The first one uses untyped IDs, the second one typed IDs. Each seasoned programmer would prefer the latter.
void addAmountToAccount(int userId, int accountId, int amount);
void addAmountToAccount(UserId userId, AccountId accountId, int amount);
3rd party Integrations
Spring Boot
Records can be used transparently in Spring Boot. They are a perfect fit for controllers’ data transfer objects (DTOs).
@RestController
@RequestMapping("/")
public class MainController {
record CreateUserRequest(String name, int age){ }
@RequestMapping("/create-person")
@PostMapping
public String createPerson(@RequestBody CreateUserRequest req){
return String.format("Create person %s (age %s)", req.name(), req.age());
}
}
Theoretically, records can be used for other Spring components such as controllers and services. As Spring components do not hold values but logic, practically it would not make much sense to records for them.
Jackson Integration
The underlying reason why we can easily use records in Spring boot controllers’ DTOs is because Jackson works out-of-the-box with records. Jackson is a very popular serialization library in Java that is used by Spring Boot to interact with JSON.
Serialization and deserialization of records work as expected with Jackson. Below is a test demonstrating the functionality.
class JacksonTest {
record CreateUserRequest(String name, int age) {
}
@Test
void testJsonSerialization() throws JsonProcessingException {
ObjectMapper objectMapper = new ObjectMapper();
CreateUserRequest req = new CreateUserRequest("didier", 56);
String serializedRequest = objectMapper.writeValueAsString(req);
assertEquals("{\"name\":\"didier\",\"age\":56}", serializedRequest);
CreateUserRequest ret = objectMapper.readValue(serializedRequest,
CreateUserRequest.class);
assertEquals(req, ret);
}
}
Gson Integration
Gson is a JSON serializer maintained by google. Once more, records are well integrated with gson.
class GsonTest {
record CreateUserRequest(String name, int age) {
}
@Test
void testJsonSerialization() {
Gson gson = new Gson();
CreateUserRequest req = new CreateUserRequest("didier", 56);
String serializedRequest = gson.toJson(req);
assertEquals("{\"name\":\"didier\",\"age\":56}", serializedRequest);
CreateUserRequest ret = gson.fromJson(serializedRequest,
CreateUserRequest.class);
assertEquals(req, ret);
}
}
Lombok
Lombok is a formidable library that allows code generation via annotations. Lombok’s support for record is not perfect yet3 4 5 6 but still pretty decent. Obviously, the need to generate code is not as high on record as in regular classes. Implementing getters, equal, and hashcode methods is a common Lombok use case that becomes unnecessary with records because they are already implemented.
Importantly to many, the @NonNull
is available to mark record field as
not nullable. If a null
value is passed to a field annotated with
@NonNull
, a NullPointerException
will be thrown.
Testing library
No code is ready for production before it is tested. Records do not require different testing methodologies than Java beans.
Hamcrest has an open ticket or two about records 7 8. Sincerely, these are minor issues and should not cause any problems to anyone.
Assertj does not have any open record-related bugs. In fact, they plan to improve record support in version 49
Conclusion
As more code bases are moving to Java 17 and Java 21, it would be nice to see more usage of record classes. Perhaps some people will discover unexpected ways to use records.
Library Version Used
This blog post has used the below library versions.
Library | Version |
---|---|
org.projectlombok:lombok | 1.18.28 |
org.hamcrest:hamcrest-all | 1.3 |
org.assertj:assertj-core | 3.24.2 |
com.google.code.gson:gson | 2.10.1 |
org.springframework.boot:spring-boot-starter-web | 3.1.3 |
graalvm-ce-17 | 17.0.7 |
LTS stands for “Long-term support”. For more information about what does an LTS version does, see https://blogs.oracle.com/Javamagazine/post/Java-long-term-support-lts . ↩︎