Java Records – A Beginner’s Guide

Imagine having a tool that can automatically detect if you are using JPA and Hibernate properly. Hypersistence Optimizer is that tool!

Introduction

In this article, I’m going to present to you the Records feature that was introduced in Java 14, and which aims to simplify the way we create a POJO (Plain Old Java Objects), DTO, or Value Object.

Domain Model

Let’s assume we have the following PostInfo and AuditInfo POJO classes:

Java Records PostInfo and AuditInfo

Both classes define several properties and provide specific implementations for the equals, hashCode, and toString Java Object methods.

The AuditInfo class is implemented like this:

public class AuditInfo {

    private final LocalDateTime createdOn;

    private final String createdBy;

    private final LocalDateTime updatedOn;

    private final String updatedBy;

    public AuditInfo(
            LocalDateTime createdOn,
            String createdBy,
            LocalDateTime updatedOn,
            String updatedBy) {
        this.createdOn = createdOn;
        this.createdBy = createdBy;
        this.updatedOn = updatedOn;
        this.updatedBy = updatedBy;
    }

    public LocalDateTime getCreatedOn() {
        return createdOn;
    }

    public String getCreatedBy() {
        return createdBy;
    }

    public LocalDateTime getUpdatedOn() {
        return updatedOn;
    }

    public String getUpdatedBy() {
        return updatedBy;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof AuditInfo)) return false;

        AuditInfo auditInfo = (AuditInfo) o;
        
        return createdOn.equals(auditInfo.createdOn) &&
               createdBy.equals(auditInfo.createdBy) &&
               Objects.equals(updatedOn, auditInfo.updatedOn) &&
               Objects.equals(updatedBy, auditInfo.updatedBy);
    }

    @Override
    public int hashCode() {
        return Objects.hash(
            createdOn, 
            createdBy, 
            updatedOn, 
            updatedBy
        );
    }

    @Override
    public String toString() {
        return String.format("""
           AuditInfo {
                createdOn : '%s',
                createdBy : '%s',
                updatedOn : '%s',
                updatedBy : '%s'
            }
            """,
            createdOn,
            createdBy,
            updatedOn,
            updatedBy
        );
    }
}

And the PostInfo class looks as follows:

public class PostInfo {

    private final Long id;

    private final String title;

    private final AuditInfo auditInfo;

    public PostInfo(
            Long id,
            String title,
            AuditInfo auditInfo) {
        this.id = id;
        this.title = title;
        this.auditInfo = auditInfo;
    }

    public Long getId() {
        return id;
    }

    public String getTitle() {
        return title;
    }

    public AuditInfo getAuditInfo() {
        return auditInfo;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof PostInfo)) return false;
        PostInfo postInfo = (PostInfo) o;
        return id.equals(postInfo.id) &&
               title.equals(postInfo.title) &&
               auditInfo.equals(postInfo.auditInfo);
    }

    @Override
    public int hashCode() {
        return Objects.hash(
            id, 
            title, 
            auditInfo
        );
    }

    @Override
    public String toString() {
        return String.format("""
            PostInfo {
                id : '%s',
                title : '%s',
                auditInfo : {
                    createdOn : '%s',
                    createdBy : '%s',
                    updatedOn : '%s',
                    updatedBy : '%s'
                }
            }
            """,
            id,
            title,
            auditInfo.createdOn,
            auditInfo.createdBy,
            auditInfo.updatedOn,
            auditInfo.updatedBy
        );
    }
}

Frankly, that’s a lot of code for such a simple data object.

Java Records

Java 14 introduces a new way of defining such data objects, as Records, that take the burden of defining the fields, getters, equals, hashCode, and toString method implementations.

So, let’s see how the AuditInfo and PostInfo classes look when we define them as Records, instead of Plain Old Java Objects:

public record AuditInfo(
    LocalDateTime createdOn,
    String createdBy,
    LocalDateTime updatedOn,
    String updatedBy
) {}

public record PostInfo(
    Long id,
    String title,
    AuditInfo auditInfo
) {}

That’s it!

Behind the scenes, Java Records are defined as any other Java class. In our case, the decompiled classes look as follows:

public final class PostInfo 
        extends java.lang.Record {
    private final java.lang.Long id;
    private final java.lang.String title;
    private final AuditInfo auditInfo;

    public PostInfo(
            java.lang.Long id, 
            java.lang.String title, 
            AuditInfo auditInfo) { 
        /* compiled code */ 
    }

    public java.lang.String toString() { /* compiled code */ }

    public final int hashCode() { /* compiled code */ }

    public final boolean equals(java.lang.Object o) { /* compiled code */ }

    public java.lang.Long id() { /* compiled code */ }

    public java.lang.String title() { /* compiled code */ }

    public AuditInfo auditInfo() { /* compiled code */ }
}

public final class AuditInfo 
        extends java.lang.Record {
    private final java.time.LocalDateTime createdOn;
    private final java.lang.String createdBy;
    private final java.time.LocalDateTime updatedOn;
    private final java.lang.String updatedBy;

    public AuditInfo(
            java.time.LocalDateTime createdOn, 
            java.lang.String createdBy, 
            java.time.LocalDateTime updatedOn, 
            java.lang.String updatedBy) { 
        /* compiled code */ 
    }

    public java.lang.String toString() { /* compiled code */ }

    public final int hashCode() { /* compiled code */ }

    public final boolean equals(java.lang.Object o) { /* compiled code */ }

    public java.time.LocalDateTime createdOn() { /* compiled code */ }

    public java.lang.String createdBy() { /* compiled code */ }

    public java.time.LocalDateTime updatedOn() { /* compiled code */ }

    public java.lang.String updatedBy() { /* compiled code */ }
}

The generated class is final and extends the Record base class that was introduced by Java 14.

Since Java Records define a single constructor that takes the same arguments we used when defining the Record type, this is how we can create a PostInfo with an AuditInfo object:

PostInfo postInfo = new PostInfo(
    1L,
    "High-Performance Java Persistence",
    new AuditInfo(
        LocalDateTime.of(2016, 11, 2, 12, 0, 0),
        "Vlad Mihalcea",
        LocalDateTime.now(),
        "Vlad Mihalcea"
    )
);

Note that, unlike the POJO specification, Java Records getters don’t follow the Java Bean standard, and the method names match the encapsulated field names:

assertEquals(
    1L, postInfo.id().longValue()
);

assertEquals(
    "High-Performance Java Persistence", postInfo.title()
);

assertEquals(
    LocalDateTime.of(2016, 11, 2, 12, 0, 0), postInfo.auditInfo().createdOn()
);

assertEquals(
    "Vlad Mihalcea", postInfo.auditInfo().createdBy()
);

We can see that a toString method is also generated, and the implementation is based on the Record properties. So, when calling the toString methods of the AuditInfo and PostInfo Records:

LOGGER.info("Audit info:\n{}", postInfo.auditInfo());
LOGGER.info("Post info:\n{}", postInfo);

We get the following log entries:

Audit info:
AuditInfo[createdOn=2016-11-02T12:00, createdBy=Vlad Mihalcea, updatedOn=2020-04-14T12:29:29.534875700, updatedBy=Vlad Mihalcea]

Post info:
PostInfo[id=1, title=High-Performance Java Persistence, auditInfo=AuditInfo[createdOn=2016-11-02T12:00, createdBy=Vlad Mihalcea, updatedOn=2020-04-14T12:29:29.534875700, updatedBy=Vlad Mihalcea]]

Customizing Java Records

Even if the generated classes are final, we can still override the default methods. For instance, let’s say we want to provide a custom toString implementation that matches the one we defined previously in our POJO classes.

To override the toString method, we just have to provide the new method definition when declaring Java Records:

public record AuditInfo(
    LocalDateTime createdOn,
    String createdBy,
    LocalDateTime updatedOn,
    String updatedBy
) {
    @Override
    public String toString() {
        return String.format("""
            AuditInfo {
                createdOn : '%s',
                createdBy : '%s',
                updatedOn : '%s',
                updatedBy : '%s'
            }
            """,
            createdOn,
            createdBy,
            updatedOn,
            updatedBy
        );
    }
}

public record PostInfo(
    Long id,
    String title,
    AuditInfo auditInfo
) {
    @Override
    public String toString() {
        return String.format("""
            PostInfo {
                id : '%s',
                title : '%s',
                auditInfo : {
                    createdOn : '%s',
                    createdBy : '%s',
                    updatedOn : '%s',
                    updatedBy : '%s'
                }
            }
            """,
            id,
            title,
            auditInfo.createdOn,
            auditInfo.createdBy,
            auditInfo.updatedOn,
            auditInfo.updatedBy
        );
    }
}

Now, when the Logger framework calls the toString method, this is what we get in the application log:

Audit info:
AuditInfo {
    createdOn : '2016-11-02T12:00',
    createdBy : 'Vlad Mihalcea',
    updatedOn : '2020-04-14T12:45:09.569632400',
    updatedBy : 'Vlad Mihalcea'
}

Post info:
PostInfo {
    id : '1',
    title : 'High-Performance Java Persistence',
    auditInfo : {
        createdOn : '2016-11-02T12:00',
        createdBy : 'Vlad Mihalcea',
        updatedOn : '2020-04-14T12:45:09.569632400',
        updatedBy : 'Vlad Mihalcea'
    }
}

Cool, right?

Online Workshops

If you enjoyed this article, I bet you are going to love my upcoming Online Workshops!

Conclusion

The new Java Records feature is very handy, as it simplifies the way we build value objects. Just like Multiline String Text Blocks, this is a preview language feature in Java 14.

So, if you want to try it out, not that you need to use the enable-preview to both the Java compiler and the JVM when running the program.

Transactions and Concurrency Control eBook

7 Comments on “Java Records – A Beginner’s Guide

  1. Nice article and I love the feature, but I’m a little annoyed that there isn’t a mutable version of Records. I understand the value of favoring immutable objects in design, but it just seems like they could have supported a lot of additional use cases by supporting a mutable/immutable modifier on the definition. Anyone have thoughts on that or able to point out why my thinking is flawed? Thanks.

      • and I think that is a great use case. I just wish it was equally applicable to all boilerplate reduction.

  2. Hi Vlad, do you envisage any use of JDK 14 records by Hibernate or other ORMs? For example returning read only entities from the database? Or for inserting entities into an append only database? Thanks.

    • I’ll write an article next week on this topic, so stay tuned for more news.

  3. Great resume! But…

    “Note that, unlike the POJO specification, Java Records getters don’t follow the Java Bean standard, and the method names match the encapsulated field names”

    Do you know if there’s a good reason for that?

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.