Key Takeaways
- Records are classes that act as transparent carriers for immutable data and can be thought of as nominal tuples.
- Records can help you write more predictable code, reduce complexity, and improve the overall quality of your Java applications.
- Records can be applied with Domain-Driven Design (DDD) principles to write immutable classes, and create more robust and maintainable code.
- The Jakarta Persistence specification does not support immutability for relational databases, but immutability can be accomplished with NoSQL databases.
- You can take advantage of immutable classes in situations such as concurrency cases, CQRS, event-driven architecture, and much more.
If you are already familiar with the Java release cadence and the latest LTS version, Java 17, you can explore the Java Record feature that allows immutable classes.
But the question remains: How can this new feature be used in my project code? How do I take advantage of it to make a clean and better design? This tutorial will provide some examples going beyond the classic data transfer objects (DTOs).
What and Why Java Records?
First thing first: what is a Java Record? You can think of Records as classes that act as transparent carriers for immutable data. Records were introduced as a preview feature in Java 14 (JEP 359).
After a second preview was released in Java 15 (JEP 384), the final version was released with Java 16 (JEP 395). Records can also be thought of as nominal tuples.
As I previously mentioned, you can create immutable classes with less code. Consider a Person
class containing three fields - name, birthday and city where this person was born - with the condition that we cannot change the data.
Therefore, let's create an immutable class. We'll follow the same Java Bean pattern and define the domain as final
along with its respective fields:
public final class Person {
private final String name;
private final LocalDate birthday;
private final String city;
public Person(String name, LocalDate birthday, String city) {
this.name = name;
this.birthday = birthday;
this.city = city;
}
public String name() {
return name;
}
public LocalDate birthday() {
return birthday;
}
public String city() {
return city;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
OldPerson person = (OldPerson) o;
return Objects.equals(name, person.name)
&& Objects.equals(birthday, person.birthday)
&& Objects.equals(city, person.city);
}
@Override
public int hashCode() {
return Objects.hash(name, birthday, city);
}
@Override
public String toString() {
return "OldPerson{" +
"name='" + name + '\'' +
", birthday=" + birthday +
", city='" + city + '\'' +
'}';
}
}
In the above example, we've created the class with final fields and getter methods, but please note that we didn’t exactly follow the Java Bean standard by preceding the methods with get.
Now, let’s follow the same path to create an immutable class: define the class as final, the fields, and then the constructor. Once it is repeatable, can we reduce this boilerplate? The answer is yes, thanks to the Record construct:
public record Person(String name, LocalDate birthday, String city) {
}
As you can see, we can reduce a couple of lines with a single line. We replaced the class
keyword to instead use the record
keyword, and let the magic of simplicity happen.
It is essential to highlight that the record
keyword is a class. Thus, it allows several Java classes to have capabilities, such as methods and implementation. Having said that, let's move to the next session to see how to use the Record construct.
Data Transfer Objects (DTOs)
This is the first, and also broadly the most popular use case on the internet. Thus, we won't need to focus on this too much here, but it is worth mentioning that it is a good example of a Record, but not a unique case.
It does not matter if you use Spring, MicroProfile or Jakarta EE. Currently, we have several samples cases that I'll list below:
Value Objects or Immutable Types
In Domain-Driven Design (DDD), Value Objects represent a concept from a problem domain or context. Those classes are immutable, such as a Money
or Email
type. So, once both Value Objects as records are firm, you can use them.
In our first example, we'll create an email where it only needs validation:
public record Email (String value) {
}
As with any Value Object, you can add methods and behavior, but the result should be a different instance. Imagine we'll create a Money
type, and we want to create an add
operation. Thus, we'll add the method to check if those are the same currency and then create a new instance as a result:
public record Money(Currency currency, BigDecimal value) {
Money add(Money money) {
Objects.requireNonNull(money, "Money is required");
if (currency.equals(money.currency)) {
BigDecimal result = this.value.add(money.value);
return new Money(currency, result);
}
throw new IllegalStateException("You cannot sum money with different currencies");
}
}
The Money
Record was just an example, mainly because developers can use the well-known library, Joda-Money. The point is when you need to create a Value Object or an immutable type, you can use a Record that can fit perfectly on it.
Immutable Entities
But wait? Did you say immutable entities? Is that possible? It is unusual, but it happens, such as when the entity holds a historic transitional point.
Can an entity be immutable? If you check definition of an entity in Eric Evans’ book, Domain-Driven Design: Tackling Complexity in the Heart of Software:
An entity is anything that has continuity through a life cycle and distinctions independent of attributes essential to the application's user.
The entity is not about being mutable or not, but it is related to the domain; thus, we can have immutable entities, but again, it is unusual. There is a discussion related to this question on Stackoverflow.
Let's create an entity named Book
, where this entity has an ID
, title
and release
year. What happens if you want to edit a book entity? We don't. Instead, we need to create a new edition. Therefore, we'll also add the edition field.
public record Book(String id, String title, Year release, int edition) {}
This is OK, but we also need validation. Otherwise, this book will have inconsistent data on it. It does not make sense to have null values on the id, title and release as a negative edition. With a Record, we can use the compact constructor and place validations on it:
public Book {
Objects.requireNonNull(id, "id is required");
Objects.requireNonNull(title, "title is required");
Objects.requireNonNull(release, "release is required");
if (edition < 1) {
throw new IllegalArgumentException("Edition cannot be negative");
}
}
We can override equals()
, hashCode()
and toString()
methods if we wish. Indeed, let's override the equals()
and hashCode()
contracts to operate on the id
field:
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Book book = (Book) o;
return Objects.equals(id, book.id);
}
@Override
public int hashCode() {
return Objects.hashCode(id);
}
To make it easier to create this class or when you have more complex objects, you can either create a method factory or define builders. The code below shows the builder creation on the Book
Record method:
Book book = Book.builder().id("id").title("Effective Java").release(Year.of(2001)).builder();
At the end of our immutable entity with a Record, we'll also include the change method, where we need to change the book to a new edition. In the next step, we'll see the creation of the second edition of the well-known book by Joshua Bloch, Effective Java
. Thus, we cannot change the fact that there was once a first edition of this book; this is the historical part of our library business.
Book first = Book.builder().id("id").title("Effective Java").release(Year.of(2001)).builder();
Book second = first.newEdition("id-2", Year.of(2009));
Currently, the Jakarta Persistence specification cannot support immutability for compatibility reasons, but we can explore it on NoSQL APIs such as Eclipse JNoSQL and Spring Data MongoDB.
We covered many of those topics. Therefore, let's move on to another design pattern to represent the form of our code design.
State Implementation
There are circumstances where we need to implement a flow or a state inside the code. The State Design Pattern explores an e-commerce context where we have an order where we need to maintain the chronological flow of an order. Naturally, we want to know when it was requested, delivered, and finally received from the user.
The first step is to create an interface. For simplicity, we'll use a String to represent products, but you know we'll need an entire object for it:
public interface Order {
Order next();
List<String> products();
}
With this interface ready for use, let's create an implementation that follows its flows and returns the products. We want to avoid any change to the products. Thus, we'll override the products()
methods from the Record to produce a read-only list.
public record Ordered(List<String> products) implements Order {
public Ordered {
Objects.requireNonNull(products, "products is required");
}
@Override
public Order next() {
return new Delivered(products);
}
@Override
public List<String> products() {
return Collections.unmodifiableList(products);
}
}
public record Delivered(List<String> products) implements Order {
public Delivered {
Objects.requireNonNull(products, "products is required");
}
@Override
public Order next() {
return new Received(products);
}
@Override
public List<String> products() {
return Collections.unmodifiableList(products);
}
}
public record Received(List<String> products) implements Order {
public Received {
Objects.requireNonNull(products, "products is required");
}
@Override
public Order next() {
throw new IllegalStateException("We finished our journey here");
}
@Override
public List<String> products() {
return Collections.unmodifiableList(products);
}
}
Now that we have state implemented, let's change the Order
interface. First, we'll create a static method to start an order. Then, to ensure that we won't have a new intruder state, we'll block the new order state implementation and only allow the ones we have. Therefore, we'll use the sealed interface feature.
public sealed interface Order permits Ordered, Delivered, Received {
static Order newOrder(List<String> products) {
return new Ordered(products);
}
Order next();
List<String> products();
}
We made it! Now we'll test the code with a list of products. As you can see, we have our flow exploring the capabilities of records.
List<String> products = List.of("Banana");
Order order = Order.newOrder(products);
Order delivered = order.next();
Order received = delivered.next();
Assertions.assertThrows(IllegalStateException.class, () -> received.next());
The state, with an immutable class, allows you to think about transactional moments, such as an entity, or generate an event on an Event-Driven architecture.
Conclusion
That is it! In this article, we discussed the power of a Java Record. It is essential to mention that it is a Java class with several benefits such as creating methods, validating on the constructor, overriding the getter, hashCode()
, toString()
methods, etc.
The Record feature can go beyond a DTO. In this article, we explored a few, such as Value Object, immutable entity, and State.
Imagine where you can take advantage of immutable classes in situations such as concurrency cases, CQRS, event-driven architecture, and much more. The record feature can make your code design go to infinity and beyond! I hope you enjoyed this article and see you at a social media distance.