avatarBrian Enochson

Summary

This article introduces Java records and their usage, focusing on their fundamentals, internals, compact constructors, and builder pattern implementation.

Abstract

Java records are a recent addition to the language, available since Java 14. They are useful for creating small immutable objects and act as transparent carriers for immutable data. The article compares the traditional way of creating immutable POJOs, using Lombok, and the new way using Java records. It also discusses the internals of Java records, compact constructors for validation, and the builder pattern implementation for records with many fields.

Bullet points

  • Java records are a recent addition to the language, available since Java 14.
  • Records are useful for creating small immutable objects and act as transparent carriers for immutable data.
  • Records do not support inheritance and cannot be extended or inherit other classes.
  • Records work well as DTO or carrier objects used for transfer of data between architectural layers or as response objects from API endpoints.
  • The article compares the traditional way of creating immutable POJOs, using Lombok, and the new way using Java records.
  • Java records can be provided with validation by using compact constructors.
  • The builder pattern can be implemented for records with many fields using straight code or by annotating the record class with Lombok's @Builder annotation.
  • Source code can be found in the author's Github repository.

Java Records — This is How We Do It Now

Use of Java records detailed.

Introduction

This article will introduce Java records and their usage. Records are a relatively recent addition to the language, being available since Java 14. We will look first at some basics and then go deeper into some implementation details such as how to add validation logic and how to implement the builder pattern with your Record classes.

Records — The Fundamentals

Java Records are very useful for creating small immutable objects. Records are classes that act as transparent carriers for immutable data. As a result, we cannot stop a record from exposing its member fields. Java records do not support inheritance. Therefore, they cannot be extended or inherit other classes.

They work well as DTO or carrier object which are used for transfer of data between architectural layers or as response objects from API endpoints.

Source code can be found in my Github here.

Old Way

First we will see how this was traditionally accomplished. This is an example of a class that we have all generated many times in the past. While programmers seldom (or never) code these by hand, but rather use the generation functionality of an IDE, this is still verbose. There is a lot of boilerplate code. The popularity of Lombok, which we will see below, came about due to this issue.

import java.util.Objects;

public class ChargeSessionV1 {

    private String id;
    private int watts;
    private String make;
    private String model;
    private String vin;

    public ChargeSessionV1(String id, int watts, String make, String model, String vin) {
        this.id = id;
        this.watts = watts;
        this.make = make;
        this.model = model;
        this.vin = vin;
    }

    public String getId() {
        return id;
    }
    
    public int getWatts() {
        return watts;
    }

    public String getMake() {
        return make;
    }
    
    public String getModel() {
        return model;
    }
    
    public String getVin() {
        return vin;
    }

    @Override
    public String toString() {
        return "ChargeSessionV1{" +
            "id=" + id + 
            ", watts=" + watts +
            ", make=" + make + 
            ", model=" + model + 
            ", vin=" + vin + 
            '}';
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        ChargeSessionV1 that = (ChargeSessionV1) o;
        return Objects.equals(id, that.id) && Objects.equals(make, that.make) && Objects.equals(model, that.model);
    }

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

This was the standard way to build an immutable POJO within Java. The verbose nature of the code, even if we generated it, attributed to Java’s reputation as a bloated language and this was the same from class to class.

Lombok Way

From the above bloated POJO class many of us started using Lombok. Lombok worked great and allowed reduction in boilerplate code. But, it required use of extra annotations and an added dependency to the project. To use Lombok you will need something like the following in your project file.

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.30</version>
            <scope>provided</scope>
        </dependency>

With this being added you can annotate your classes as follows to get the same code as in the Pojo above.

@Value
public class ChargeSessionV2 {

    private String id;
    private int watts;
    private String make;
    private String model;
    private String vin;
}

Definitely an improvement and there is a major reduction in code bulk.

Would be nice to have something like the above, but a native part of the language itself. That is Java Records.

The New Way

How could we do the above, but using Java records? This code is a replacement for the above two alternatives.

public record ChargeSession(String id,int watts, String make, String model, String vin) {}

Creating a record is easy as declaring that it will be a record, providing a name and declaring the fields. That’s it.

So to use the these three variants of standard Pojo, Lombok and Java Record we would initialize them exactly the same.

var chargeSessionRecordPojo = new ChargeSessionV1("11111", 420, "Tesla", "Model S", "KNADE221296399151");
var chargeSessionRecordLombok = new ChargeSessionV2("11111", 420, "Tesla", "Model S", "KNADE221296399151");
var chargeSessionRecord = new ChargeSession("11111", 420, "Tesla", "Model S", "KNADE221296399151");

System.out.println("Pojo: " + chargeSessionRecordPojo);
System.out.println("Lombok: " + chargeSessionRecordLombok);
System.out.println("Record: " + chargeSessionRecord);

This prints the following code, again exactly the same.

Pojo: ChargeSessionV1{id=11111, watts=420, make=Tesla, model=Model S, vin=KNADE221296399151}
Lombok: ChargeSessionV2(id=11111, watts=420, make=Tesla, model=Model S, vin=KNADE221296399151)
Record: ChargeSession[id=11111, watts=420, make=Tesla, model=Model S, vin=KNADE221296399151]

We were able to achieve with one line of standard Java using a Java record what can be done with many lines of a standard Pojo and with annotations and extra dependencies with Lombok.

Internals

Let’s compare what is actually generated for each variant.

brianenochson@Brians-MacBook-Pro javarecord % javap ChargeSession.class
Compiled from "ChargeSession.java"
public final class com.brianeno.javarecord.ChargeSession extends java.lang.Record {
  public com.brianeno.javarecord.ChargeSession(java.lang.String, int, java.lang.String, java.lang.String, java.lang.String);
  public final java.lang.String toString();
  public final int hashCode();
  public final boolean equals(java.lang.Object);
  public java.lang.String id();
  public int watts();
  public java.lang.String make();
  public java.lang.String model();
  public java.lang.String vin();
}

The code generated from our record alternative is remarkably lean. We see there is a hashCode, equals and toString and the “getters” are generated without the “get” prefix. We also see it is a final class and that it extended java.lang.Record. (The java.lang.Record class cannot be directly extended in your code, trying will result in a compiler error).

brianenochson@Brians-MacBook-Pro javarecord % javap ChargeSessionV1.class
Compiled from "ChargeSessionV1.java"
public class com.brianeno.javarecord.ChargeSessionV1 {
  public com.brianeno.javarecord.ChargeSessionV1(java.lang.String, int, java.lang.String, java.lang.String, java.lang.String);
  public java.lang.String getId();
  public int getWatts();
  public java.lang.String getMake();
  public java.lang.String getModel();
  public java.lang.String getVin();
  public java.lang.String toString();
  public boolean equals(java.lang.Object);
  public int hashCode();
}

The pojo class looks exactly like our record class. But as we saw we had a lot more lines of source code for the same result. What about the class created using Lombok annotations?

brianenochson@Brians-MacBook-Pro javarecord % javap ChargeSessionV2.class
Compiled from "ChargeSessionV2.java"
public final class com.brianeno.javarecord.ChargeSessionV2 {
  public java.lang.String getId();
  public int getWatts();
  public java.lang.String getMake();
  public java.lang.String getModel();
  public java.lang.String getVin();
  public boolean equals(java.lang.Object);
  public int hashCode();
  public java.lang.String toString();
  public com.brianeno.javarecord.ChargeSessionV2(java.lang.String, int, java.lang.String, java.lang.String, java.lang.String);
}

With the Lombok variant this we see almost exactly like the POJO and record alternatives. It is declared final and provides a hashCode, toString and equals.

Compact Constructors

Records can be provided with validation by using something called a compact constructor. This would look as follows.

public record ChargeSessionWithValidation(String id, int watts, String make, String model, String vin) {
    public ChargeSessionWithValidation {
        if (id == null) {
            throw new IllegalArgumentException("ID Cannot be null");
        }
        if (watts == 0) {
            throw new IllegalArgumentException("Watts cannot be zero");
        }
        // etc.
    }
}

The formal parameters of a compact constructor of a record class are implicitly declared. This allows to implement validation or other initialization logic. The constructor body of a record is run when the Record is created.

Builder

One of the early quotes often seen was that Records do not support a builder pattern, so are not advantageous to use for classes with a lot of fields. This is not true as we will see. We have a couple of options here.

The inclusion of a builder pattern can be done with straight code first of all. This has the advantage there is not extra required dependency, but the disadvantage it adds to the code line quantity.

Let’s look at our ChargingSession example with builder pattern in code.

package com.brianeno.javarecord;

public record ChargeSessionWithNestedBuilder(String id, int watts, String make, String model, String vin) {

    public static final class Builder {
        String id;
        int watts;
        String make;
        String model;
        String vin;

        public Builder(String id, int watts) {
            this.id = id;
            this.watts = watts;
        }

        public Builder make(String make) {
            this.make = make;
            return this;
        }

        public Builder model(String model) {
            this.model = model;
            return this;
        }

        public Builder vin(String vin) {
            this.vin = vin;
            return this;
        }

        public ChargeSessionWithNestedBuilder build() {
            return new ChargeSessionWithNestedBuilder(id, watts, make, model, vin);
        }
    }
}

The builder class is defined as an internal static class with fields mirroring the record itself. The disadvantage of course is if the record is modified, the build class must be changed also. If you go this route, this can then be initialized as follows.

var nestedBuilder = new ChargeSessionWithNestedBuilder.Builder("22222", 380)
     .make("Ford")
     .model("F150")
     .vin("5XYKT4A62CG191848")
     .build();

Which for a record with many fields is quite nice and prevents mistakes of out of order fields. For example, it would be easy with standard initialization to accidentally switch the make and model fields as both strings.

There is another shortened way to declare a record with builder. Often when using records in our code, we will still have lombok included as a dependency. This might be the case in a SpringBoot application. If so you can declare a record class “builder enabled” by annotating it as follows.

@Builder
public record ChargeSessionWithLombokBuilder(String id, int watts, String make, String model, String vin) {
}

The class can then be initialized with the following code.

var lombokBuilder = ChargeSessionWithLombokBuilder.builder()
            .id("33333")
            .watts(405)
            .make("Kia")
            .model("EV9")
            .vin("5XYKT3A69DG353356")
            .build();

Again, this is a great way to use records, but limits the amount of code needed.

Summary

Thank you for coming along for a tour of Java Records. Understanding their capabilities has the potential to help you as you develop your applications. We looked at the fundamentals of records, some internal information on what is actually generated. Next we looked at how to add validation to a record class and finally we explored a couple of alternatives to adding the builder pattern to records with many fields.

Source code can be found in my Github here.

Enjoy the journey.

🔔 If you enjoyed this, subscribe to my future articles, follow me if you like or view already published articles here. 🚀

➕Join the Medium Membership Program to support my work and connect with other writers.

📝 Have questions or suggestions or any ideas for topics? Leave a comment or message me through Medium.

Thank you for your support! 🌟

Java
Software Development
Programming
Java Records
Basic Knowledge
Recommended from ReadMedium