Transforming Data Transformation in Java with Local Records

Data transformation is a common task for Java developers. When moving data from one location to a new one, and this could be from one datastore to another datastore, such as in a ETL batch process, or retrieving data and sending it to the presentation layer, some amount of data transformation is often required.

While not difficult, performing even simple transforms in Java often means writing a lot of code. Developers will need to create one or more classes with all the associated member fields, accessor methods, and implementing hashCode(), equals(), toString(), and so on. In this article we are going to look at how with the introduction of Records in Java 16 data transformation can be a lot easier.

Java Records

Records, after two rounds of being a preview feature in Java 14 and 15, is becoming a permanent feature in Java 16. Records are a new feature designed to address concerns relating to the definition of data carrier classes in Java including; the proper implementation of hashCode() and equals(), handling of immutable data, and the serialization, de-serialization, and initialization of data carrier classes. For this article we will be focusing how Records aims to make the definition of a data carrier class more concise.

First, let’s understand the issues with defining a data carrier class in Java before Java 16. Before Records, creating a data carrier class would commonly look something like this:

public class Person {

	private long id;
	private String firstName;
	private String lastName;

	public Person() {
	}

	public Person(long id, String firstName, String lastName) {
		this.id = id;
		this.firstName = firstName;
		this.lastName = lastName;
	}

	public long getId() {
		return id;
	}

	public String getFirstName() {
		return firstName;
	}

	public String getLastName() {
		return lastName;
	}

	public void setId(long id) {
		this.id = id;
	}

	public void setFirstName(String firstName) {
		this.firstName = firstName;
	}

	public void setLastName(String lastName) {
		this.lastName = lastName;
	}

	@Override
	public int hashCode() {
		final int prime = 31;
		int result = 1;
		result = prime * result + ((firstName == null) ? 0 : firstName.hashCode());
		result = prime * result + (int) (id ^ (id >>> 32));
		result = prime * result + ((lastName == null) ? 0 : lastName.hashCode());
		return result;
	}

	@Override
	public boolean equals(Object obj) {
		if (this == obj)
			return true;
		if (obj == null)
			return false;
		if (getClass() != obj.getClass())
			return false;
		Person other = (Person) obj;
		if (firstName == null) {
			if (other.firstName != null)
				return false;
		} else if (!firstName.equals(other.firstName))
			return false;
		if (id != other.id)
			return false;
		if (lastName == null) {
			if (other.lastName != null)
				return false;
		} else if (!lastName.equals(other.lastName))
			return false;
		return true;
	}

	@Override
	public String toString() {
		return "Person [id=" + id + ", firstName=" + firstName + ", lastName=" + lastName + "]";
	}

}

Despite the only business meaningful part of the code being the three member fields; id, firstName, lastName, it would take nearly 80 lines to define the class so it could be properly used in a Java application (which would often requires being compatible with popular frameworks). With Records, the above can instead be declared in a single line:

public record Person(long id, String firstName, String lastName){}

Note: Records are shallowly immutable, which is a bit different from how many data carriers are currently defined in Java projects, like the initial Person example, which typically Getters and Setters to be compatible with frameworks. Frameworks are being updated to be compatible with Java 16, and thus Records, and you can view their status here: https://wiki.openjdk.java.net/display/quality/Quality+Outreach.

Then when the above code is run through the Java compiler the below is produced:

public final class com.bk.example.Person extends java.lang.Record {
  private final long id;
  private final java.lang.String firstName;
  private final java.lang.String lastName;
  public com.bk.example.Person(long, java.lang.String, java.lang.String);
  public final java.lang.String toString();
  public final int hashCode();
  public final boolean equals(java.lang.Object);
  public long id();
  public java.lang.String firstName();
  public java.lang.String lastName();
}

The benefits of being able to define a data carrier class in a line or two of code, what would previously had taken dozens of lines of code are pretty straightforward. Though there are a number of other benefits Records provide, which if you would want to learn more about, I would highly recommend these two episodes of the podcast Inside Java; “Record Classes” with Gavin Bierman and “Records Serialization” with Julia Boes and Chris Hegarty.

Note: If you are wondering why the compiled code doesn’t have “Getters” and “Setters”, nomenclature that comes from the JavaBeans standard, be sure to read this article that covers some of the design considerations around Records.

Local Records

Like a normal Java class, a Java Record can also be defined with in the body of a method. However, whereas defining a data carrier class within a method body was often impractical due to impacts on the readability of code, Records impose no such liability. Let’s see how Records can be leveraged to make light data transformation easier.

In this simple code example I am printing to console a list of Persons I am retrieving from a repository:

public class PersonService {

	private PersonRepo repo = new PersonRepo();

	public void printPersons() {
		repo.getAllPersons().stream().forEach(p -> System.out.println(p.toString()));
	}
}

The above code when executed would print out the following:

Person[id=1, firstName=Tony, lastName=Stark]
Person[id=2, firstName=Bruce, lastName=Banner]
Person[id=3, firstName=Sam, lastName=Wilson]
Person[id=4, firstName=Monica, lastName=Rambeau]
Person[id=5, firstName=Wanda, lastName=Maximoff]

When presenting information to users it’s often not desirable to include meta information like id. Users typically wouldn’t be interested in such information and I might want to keep such information private for reasons from security to having more flexibility when changing metadata values.

With Records I can easily define a new data carrier class in the body of printPersons() that can handle the light transformation of stripping out the id field like seen here:

public void printPersons() {
	record PersonView(String firstName, String lastName) {}
	repo.getAllPersons().stream().map(p -> new PersonView(p.firstName(), p.lastName())).forEach(pv -> System.out.println(pv.toString()));
}

Running the above code will produce the following output:

PersonView[firstName=Tony, lastName=Stark]
PersonView[firstName=Bruce, lastName=Banner]
PersonView[firstName=Sam, lastName=Wilson]
PersonView[firstName=Monica, lastName=Rambeau]
PersonView[firstName=Wanda, lastName=Maximoff]

This is good, but sometimes during data transformations there might be some additional behavior we need to have happen, beyond the simple moving around or stripping out of fields. Luckily the default behavior of a Records class can also be easily overwritten. If for example I wanted to provide even cleaner output, I can simply override the default toString() method with my own, like in this example:

public void printPersons() {
	record PersonView(String firstName, String lastName) {
		@Override
		public String toString(){
			return firstName + " " + lastName;
		}
	}
	repo.getAllPersons().stream().map(p -> new PersonView(p.firstName(), p.lastName())).forEach(pv -> System.out.println(pv.toString()));
}

Which prints this to console:

Tony Stark
Bruce Banner
Sam Wilson
Monica Rambeau
Wanda Maximoff

Records allow for plenty of flexibility, equals(), hashcode(), and the accessor methods of a Record class can all be overwritten if needed. Custom behavior can also be added to the constructor, such as checking for null values, though the Java compiler will ensure all member fields of a Record are being assigned a value, and if not, add an assignment operation automatically. Custom methods can also be added if needed as well. For example a toJson() method could be defined for formatting the contents of a Record as a JSON message.

Conclusion

Java 16 is scheduled to go GA March 16th and with it fully bring Records to the Java ecosystem. In this article we saw how Records will provide some significant ergonomic benefits to Java developers when performing the common task of transforming data.

If you would like to check out Java Records for yourself, along with the other new features coming in Java 16, you can download the Java 16 JDK here: https://jdk.java.net/16/.

You can find the example code used in for this article along with instructions on how to run it on my github profile: https://github.com/wkorando/transformation-with-java-local-records

Leave a comment