Build Java records with builders

When writing Java record withers what and when I discovered that the Java beans with builders resulting from Build beans better with builders could have their boiler plate code size reduced to half, if they were turned into records instead of beans.

This is the story of switching my Java beans to records and how that worked with jackson to build JSON objects from records and parse JSON objects into records.

Using records with builders is not the final solution (still too much boilerplate to get nice creation syntax), but reducing the boilerplate code size sounded like a good reason to do it, while waiting for Java record “withers” to appear.

What I wanted to know, was:

  1. Would it work with JSON and Jackson in the same ways Java beans do?
  2. What about my transient properties and jackson?

The short answers, are:

  1. Yes, records works pretty much like you would expect when translating to JSON and back
  2. Transient properties in records work exactly with jackson, like they did in beans, but you must start the method name with “get” followed by the camelcased property name (i.e. keep the beans convention)

For a longer explanation, please read on.

If you have a bean with a builder, like so:

public class Person {
    private String username;
    private String firstName;
    private String lastName;

    private Person() {
        // No-arg constructor required by jackson
    }

    public String getUsername() {
        return username;
    }

    public String getFirstName() {
        return firstName;
    }

    public String getLastName() {
        return lastName;
    }

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

    public static Builder with() {
        return new Builder();
    }

    public static Builder with(Person person) {
        var builder = new Builder();
        builder.username = person.username;
        builder.firstName = person.firstName;
        builder.lastName = person.lastName;
        return builder;
    }

    public static class Builder {
        private String username;
        private String firstName;
        private String lastName;

        private Builder() {}

        public Person build() {
            var person = new Person();
            person.username = this.username;
            person.firstName = this.firstName;
            person.lastName = this.lastName;
            return person;
        }

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

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

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

It can be replaced by a record with a builder, like so:

public record Person(String username, String firstName, String lastName) {

    public static Builder with() {
        return new Builder();
    }

    public static Builder with(Person person) {
        var builder = new Builder();
        builder.username = person.username;
        builder.firstName = person.firstName;
        builder.lastName = person.lastName;
        return builder;
    }

    public static class Builder {
        private String username;
        private String firstName;
        private String lastName;

        private Builder() {}

        public Person build() {
            return new Person(username, firstName, lastName);
        }

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

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

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

As you see, half the code of the bean (all of the getters and the toString) is gone from the second example. Only the builder is left.

The code that is gone was boiler plate code autogenerated by an IDE and wasn’t touched after that. But it still add code lines to the code count, contains code that potentially needs to be read or skimmed through… and needs tests to not bring the code coverage percentage down low enough to have sonarqube start complaining.

So, good riddance.

Usage of the bean, is:

var john = Person.with().username("jod").firstName("John").lastName("Doe").build();
System.out.println(john);

var jack = Person.with(john).firstName("Jack").build();
System.out.println(jack);

System.out.println(john.getFirstName());
System.out.println(jack.getFirstName());

Usage of the record, is:

var john = Person.with().username("jod").firstName("John").lastName("Doe").build();
System.out.println(john);

var jack = Person.with(john).firstName("Jack").build();
System.out.println(jack);

System.out.println(john.firstName());
System.out.println(jack.firstName());

So the code looks almost exactly the same, except that the “get” prefix is gone from the accessor methods.

Using jackson to do the translation, the JSON version, would look the same for bean or record. I.e. the front end code won’t see any difference:

{
    "username" = "jod",
    "firstName" = "John",
    "lastName" = "Doe"
}

So what about transient properties?

If your Person bean has a caclculated fullName property, like this (not showing the underlying properties and the builder, to save space):

public class Person {
    ...
    public String getFullName() {
        if (getFirstName() != null && getLastName() != null) {
            return getFirstName() + " " + getLastName();
        }

        if (getFirstName() != null) {
            return getFirstName();
        }

        return getUsername();
    }
    ...
}

then just copy that getter into the record, as-is:

public record Person(String username, String firstName, String lastName) {

    public String getFullName() {
        if (firstName() != null && firstName() != null) {
            return firstName() + " " + firstName();
        }

        if (firstName() != null) {
            return firstName();
        }

        return username();
    }
    ...
}

this will result in a JSON representation looking like this:

{
    "username" = "jod",
    "firstName" = "John",
    "lastName" = "Doe"
    "fullName" = "John Doe"
}

So to be clear: transient properties method names should follow the convention of Java beans getters:

  1. Start with “get”
  2. Follow “get” with the camel cased property names

So e.g. if you have a property “fullName”, the method name should be “getFullName”.

The jackson maintainer considers serializing transient getters as fiels a feature (and so do I, since it let me keep my generated properties when moving from beans to records).

Her are my bean-to-record transformations

  1. osgiservice.users
  2. The ukelonn weekly allowance application
  3. handlereg, a grocerices tracking and statistics application
  4. oldalbum, a web album application
  5. sampleapp, my template application for creating new applications

Some statistics

application lines added/modified lines deleted net lines removed
osgi-service 63 205 142
ukelonn 512 948 436
handlereg 263 668 405
oldalbum 514 754 240
sampleapp 91 207 116

One thought on “Build Java records with builders”

Leave a comment

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