Explore the Java programming world! Discover articles, insights, tips, and tutorials designed to gui ... Show more

What is a Java record?

Records are a special type of Java class designed to facilitate the creation of immutable objects. Introduced with the aim of simplifying and expediting the process of creating immutable data carriers, records eliminate much of the boilerplate code typically associated with such tasks.

Records significantly reduce boilerplate code.

Records offer a concise and elegant solution to the problem of boilerplate code in Java programming. By encapsulating data into a compact syntax, records eliminate the need for verbose getter methods, constructors, equals, hashCode, and toString implementations, thereby enhancing code readability and maintainability. This streamlined approach not only saves development time but also promotes clearer, more expressive code, allowing developers to focus on the core logic of their applications.

Before the introduction of records, Java classes were typically written with more verbosity.

public final class ImmutableUser {  
    private final String name;  
    private final Integer age;  
  
    public ImmutableUser(String name, Integer age) {  
        this.name = name;  
        this.age = age;  
    }  
  
    public String getName() {  
        return name;  
    }  
  
    public Integer getAge() {  
        return age;  
    }  
  
    @Override  
    public boolean equals(Object o) {  
        if (this == o) return true;  
        if (o == null || getClass() != o.getClass()) return false;  
        ImmutableUser that = (ImmutableUser) o;  
        return Objects.equals(name, that.name) && Objects.equals(age, that.age);  
    }  
  
    @Override  
    public int hashCode() {  
        return Objects.hash(name, age);  
    }  
  
    @Override  
    public String toString() {  
        return "ImmutableUser{" +  
                "name='" + name + '\'' +  
                ", age=" + age +  
                '}';  
    }  
}

Records provide a more concise and elegant alternative for achieving the same functionality. With a single line of code you can replace the class above. Here is an example:

public record User(String name, Integer age) { }

Code introduced by the compiler.

When compiling records in Java, the compiler introduces several pieces of code to support the functionality of records. These include:

  • Canonical Constructor: The compiler generates a canonical constructor that initializes the record's components based on the parameters provided during object creation.
  • Accessor Methods: Accessor methods are automatically generated for each record component, allowing access to their values.
  • equals() Method: The compiler generates an equals() method that compares the values of all record components for equality.
  • hashCode() Method: An implementation of the hashCode() method is provided, computing a hash code based on the values of the record components.
  • toString() Method: A toString() method is created to generate a string representation of the record, including the values of its components.

In traditional classes, the equals implementation relies on reference equality, comparing the memory locations of objects. Conversely, records adopt a data equality approach, where the equals method compares the actual values of their components. This distinction ensures that records prioritize content-based equality checks.

Syntax and rules.

In Java, records offer a concise and efficient way to define immutable data carrier classes. The syntax for records is straightforward: you declare a record using the record keyword followed by the record name and a list of components enclosed in parentheses. Each component consists of a type and a name. While records cannot inherit from other classes, they can implement interfaces and define methods. Moreover, records support compact constructors for custom initialization and static methods. Overall, records streamline the creation of simple, transparent, and immutable data carriers, enhancing code readability and maintainability.

  • Records can only be declared as public or package-private.
  • Records are declare using the record keyword instead of the class keyword.
  • Record components are a list of types and names declared within parentheses.
  • Records don’t allow instance fields (just those on the list of components).
  • Records allow only static fields.
  • Records allow to customize accessors.
  • Records allow instance methods.
  • Records allow static methods.
  • Records don’t extend other classes
  • Records cannot be extended.
  • Records can implement interfaces.
  • Records can be nested.

Benefits of immutability.

Immutable objects in software development are defined by their unchangeability once instantiated, essentially serving as read-only classes. This characteristic offers various benefits.

  • Immutability ensures that objects maintain valid states, which is crucial for correct application functionality. Firstly, it guarantees consistency in object states, fostering trust in the application's integrity by providing accurate and coherent information. Secondly, it upholds data integrity, preserving reliability and preventing corruption.
  • Immutability also guarantees thread-safe objects, particularly advantageous in multithreaded applications. Without immutability, managing object states in such environments requires careful consideration to avoid unexpected alterations, leading to unreliable states.

Overall, immutability contributes to more readable and maintainable code.

Immutable classes before the introduction of record classes.

Before the introduction of record classes in Java, creating immutable classes involved implementing certain practices and patterns. Here are some common approaches used to create immutable classes in traditional Java:

  1. The class must be declared private final, preventing subclassing.
  2. Fields are declared final, ensuring their values are read-only and cannot be modified.
  3. Field values are initialized within the constructor.
    1. Defensive copying is applied to all mutable arguments.
  4. All methods provided by the class are read-only, preserving the object's state.

Immutability with Java record

Java record classes introduce immutability out-of-the-box (OOTB). By defining records, immutable classes can be created with minimal code, offering simplicity compared to traditional Java versions.

Make a record fully immutable.

Ensuring full immutability in Java records, even for attributes that are not inherently immutable, such as lists, is crucial for maintaining data integrity and preventing unintended side effects. By making record attributes immutable, we guarantee that their state cannot be modified after instantiation, promoting thread safety and reducing the risk of bugs related to mutable state changes. For non-immutable attributes like lists, ensuring immutability through helper methods like Collections.unmodifiableList() provides an additional layer of protection, preventing external modifications and preserving the record's integrity. This practice fosters robustness in code, enhances maintainability, and promotes safer concurrency in multi-threaded environments.

record User(String name, Integer age, List<String> hobbies) {  
    
    public User {
        hobbies = Collections.unmodifiableList(hobbies);  
    }  
}

The canonical constructor.

In Java, records are equipped with a canonical constructor, ensuring proper initialization of record instances. This constructor, automatically provided by the compiler, initializes the record's fields based on the arguments passed during object creation. A record must always contain the canonical constructor, as it ensures the integrity and immutability of its state. The compiler ensures that the canonical constructor effectively initializes the list of record components based on the arguments passed during object creation.

Access level.

In Java records, the access level of the canonical constructor cannot be more restrictive than the access level of the record itself. For instance, if a record is declared as public, its canonical constructor must also be public. Conversely, if the record is declared as package-private, its canonical constructor can either be public or package-private. This rule ensures that the constructor remains accessible from outside the class by other classes or packages, thereby maintaining consistency with the access level specified for the record.

public record User(String name, Integer age, List<String> hobbies) { 
	// This constructor has more restrictive access level than the record itself.
	// Therefore, the compiler will complaint saying that:
	// Canonical constructor access level cannot be more restrictive than the        // record access level ('public')
    User(String name, Integer age, List<String> hobbies) {  
        if(age < 18) {  
            throw new IllegalArgumentException("");  
        }  
        this.name = name != null ? name.toUpperCase() : "";  
        this.age = age;  
        this.hobbies = Collections.unmodifiableList(hobbies);  
    }  
}

Overloading constructors.

In Java records, overloading chained constructors involves defining multiple constructors within a record class to enable flexible object initialization. Each overloading constructor must call another the canonical constructor using this() at the first line, passing arguments as necessary. This approach centralizes initialization logic, avoids redundancy, and promotes code reuse. By ensuring this() calls are at the first line, developers adhere to the mandatory requirement, enhancing the readability, maintainability, and versatility of record-based data structures in Java programming.

Constraints on using instance methods in Java Record constructors.

In Java records, it's important to note that constructors, including chained constructors, cannot reference instance methods before canonical constructor has been called. This limitation exists because constructors are responsible for initializing object state before any instance methods can be invoked. This would lead to unpredictable behavior and potential errors during object construction. Therefore, to maintain consistency and ensure reliable object initialization, Java prohibits constructors from invoking instance methods directly.

public record User(String name, Integer age, List<String> hobbies) {  
    public User(String name, Integer age, List<String> hobbies) {  
        if(age < 18) {  
            throw new IllegalArgumentException("");  
        }  
        this.name = name != null ? name.toUpperCase() : "";  
        this.age = age;  
        this.hobbies = Collections.unmodifiableList(hobbies);  
    }  
  
    public User(String name, Integer age) { 
	    // The getDefaultHobbies method reference causes a compiler error saying:
	    // Cannot reference 'User.getDefaultHobbies()' before supertype 
	    // constructor has been called
        this(name, age, getDefaultHobbies());  
    }  

    private List<String> getDefaultHobbies() {  
        return Collections.emptyList();  
    }  
}

If the method is converted to static, the overloading method gains access to it. This adjustment allows the overloading method to utilize the static method's functionality without the constraints imposed by instance method invocation.

The compact constructor.

Compact constructors in Java records offer a concise and streamlined approach to initializing record components. Unlike traditional constructors, compact constructors automatically initialize record fields based on the provided parameters without the need for explicit assignment statements.

Compact constructors simplify the constructor's implementation and enhance code readability by reducing boilerplate code. One of the key benefits of compact constructors is that they eliminate the need to manually update the constructor when new record components are added, as the compiler automatically incorporates any new components into the constructor's initialization process. This saves developers time and effort by reducing maintenance overhead and ensuring consistency between record components and constructor parameters. Overall, compact constructors contribute to more efficient and maintainable code in Java records.

  • The compiler implicitly derives the arguments to the constructor block from the record definition.
  • In a compact constructor, the constructor parameters directly correspond to the components of the record.
  • The compiler automatically generates the constructor body, assigning each parameter to its corresponding component.
  • The compact constructor’s body is being invoked before the actual field values assignment happens.
  • It’s more like a container for the code, that should be injected into the canonical constructor.
public record User(String name, Integer age, List<String> hobbies) {  
  
    public User {  
        hobbies = Collections.unmodifiableList(hobbies);  
        System.out.println("First, compact constructor gets invoked");  
    }  
      
    public User(String name, Integer age) {  
        this(name, age, Collections.emptyList());  
        System.out.println("After that, this constructor gets invoked");  
    }  
  
    public static void main(String[] args) {  
        User user = new User("gabrielmumo", 50);  
    }  
}

Custom canonical constructor cannot co-exists with compact constructor.

Custom canonical constructor and compact constructors cannot coexist. The simultaneous presence of both types of constructors could lead to ambiguity and inconsistencies in object initialization, hence why Java prohibits their coexistence.

public record User(String name, Integer age, List<String> hobbies) {

	private static final Integer MIN_ALLOWED_AGE = 10;
    
    // In both constructors the compiler will complaint sayin that 
	// the constructos is already defined
    public User(String name, Integer age, List<String> hobbies) {  
        if(age < MIN_ALLOWED_AGE) {  
            throw new IllegalArgumentException("User is under allowed age");  
        }  
        this.name = name != null ? name.toUpperCase() : "";  
        this.age = age;  
        this.hobbies = Collections.unmodifiableList(hobbies);  
    }

	// In both constructors the compiler will complaint sayin that 
	// the constructos is already defined
    public User {  
        hobbies = Collections.unmodifiableList(hobbies);  
        System.out.println("First, compact constructor gets invoked");  
    }  
}

Overriding accessor methods.

Overriding accessors in Java records refers to the ability to customize the behavior of accessor methods generated by default for record components. This flexibility allows for data manipulation, or transformation logic to be incorporated directly into the record class. Overwriting accessors is particularly useful when additional processing is needed before retrieving or setting the value of a record component. Overall, overwriting accessors enhances the versatility and adaptability of Java records, enabling more flexible data handling capabilities within record-based data structures.

public record User(String name, Integer age, List<String> hobbies) {  
  
    private static final String GREETING = "Hi";  
  
    public String name() {  
        return String.format("%s %s", GREETING, name);  
    }  
}

Object Creation: Builders and Withers

Builders and withers are essential patterns in Java programming, offering a mechanisms for constructing objects with ease and flexibility.

Builder

Builders provide a fluent and customizable way to construct complex objects, allowing developers to specify configurations during object creation. They help streamline the construction process, especially for objects with numerous optional parameters or intricate initialization logic.

public record User(String name, Integer age, List<String> hobbies) {  
  
    public static final class Builder {  
        String name;  
        Integer age;  
        List<String> hobbies;  
  
        public Builder(String name, Integer age) {  
            this.name = name;  
            this.age = age;  
        }  
  
        public Builder hobbies(List<String> hobbies) {  
            this.hobbies = Collections.unmodifiableList(hobbies);  
            return this;  
        }  
  
        public User build() {  
            return new User(name, age, hobbies);  
        }  
    }  
  
    public static void main(String[] args) {  
        var builder = new Builder("gabrielmumo", 50);  
        builder.hobbies(Arrays.asList("Reading", "Leather-craft"));  
        User user = builder.build();  
        System.out.println(user);  
    }  
}

Wither

The next most suitable option for modifying an immutable property, aside from using a setter, involves creating a copy of the object with a new value assigned to the specific field. The wither pattern for creating record instances enables immutable objects to be updated with modified values, offering benefits such as maintaining immutability while facilitating state modification, yet it may result in verbose code and potential confusion regarding the purpose of generated methods.

public record User(String name, Integer age, List<String> hobbies) {  
  
    public User withName(String name) {  
        return new User(name, this.age, this.hobbies);  
    }  
  
    public User withAge(Integer age) {  
        return new User(this.name, age, this.hobbies);  
    }  
  
    public User withHobbies(List<String> hobbies) {  
        return new User(this.name, this.age, hobbies);  
    }  
  
    public static void main(String[] args) {  
        User user = new User("gabrielmumo", 50, Collections.emptyList());  
        user = user.withHobbies(Arrays.asList("Reading", "Leather-craft"));  
        System.out.println(user);  
    }  
}

By embracing builders and withers, developers can enhance code readability, maintainability, and adaptability, ultimately leading to more robust and scalable software solutions.

The deserialization vulnerability.

What is serialization and deserialization?

Serialization involves converting the state of an object into a byte stream, which can be stored in files or transmitted over networks. Deserialization, on the other hand, is the process of reconstructing objects from the serialized byte stream. Serialization and deserialization play a crucial role in frameworks and messaging systems, enabling distributed communication between Java applications.

These mechanisms are commonly used in scenarios such as saving object state for later retrieval, transmitting objects between different Java applications or platforms, and implementing caching mechanisms for improved performance.

The vulnerability.

As mentioned, the deserialization is the process of reconstructing objects from a serialized byte stream. During deserialization, these bytes are read from the input stream, and the Java runtime reconstructs the object based on the serialized data.

Deserialization bypasses the constructor because the constructor is not invoked during the deserialization process. Instead, deserialization directly creates an object instance using the class's no-argument constructor (if available) or by instantiating the object directly without invoking any constructor. This is done to ensure that the object's state is restored accurately from the serialized data, regardless of the constructor logic.

However, this bypassing of the constructor can be a security concern, especially if the constructor contains important initialization logic or validation checks. Attackers can exploit this behavior by crafting malicious serialized objects that, when deserialized, execute unintended or harmful behavior due to the constructor being bypassed.

Steps to reproduce.

To replicate this vulnerability, we will first generate the byte representation of an object intentionally breaching a business constraint. Next, we'll construct an immutable class incorporating constraint validation in its constructor and attempt to deserialize the file to observe the constructor bypass. Lastly, we'll refactor the class into a record while retaining the same constraint to demonstrate the enhanced security of the deserialization process.

First design an immutable class.

final class Immutable implements Serializable {  
    private static final Integer MIN_ALLOWED_AGE = 10;  
  
    private final String name;  
    private final Integer age;  
  
    Immutable(String name, Integer age) {  
        this.name = name;  
        this.age = age;  
    }  
  
    String getName() {  
        return name;  
    }  
  
    Integer getAge() {  
        return age;  
    }  
  
    @Override  
    public String toString() {  
        return "Immutable{" +  
                "name='" + name + '\'' +  
                ", age=" + age +  
                '}';  
    }  
}

Now, lets generate the byte representation of the object and store it in a file.

public class TestDeserialization {  
    public static void main(String[] args) {  
        try {  
            var user = new Immutable("gabrielmumo", 7);  
            FileOutputStream fos = new FileOutputStream("immutableuser.ser");  
            ObjectOutputStream oos = new ObjectOutputStream(fos);  
            oos.writeObject(user);  
            oos.close();  
        } catch (Exception e) {  
            e.printStackTrace();  
        }  
    }  
}

The process will generate a file named immutableuser.ser.

Next, refactor the constructor by introducing this validation logic to enforce business constraints.

Immutable(String name, Integer age) {  
    if(age < MIN_ALLOWED_AGE) {  
        throw new IllegalArgumentException("User is under allowed age");  
    }  
    this.name = name;  
    this.age = age;  
}

Now, lets reconstruct a new object instance from the serialized file.

public class TestDeserialization {  
    public static void main(String[] args) {  
        try {  
            FileInputStream fis = new FileInputStream("immutableuser.ser");  
            ObjectInputStream ois = new ObjectInputStream(fis);  
            Immutable user = (Immutable) ois.readObject();  
            ois.close();  
            System.out.println(user);  
        } catch (Exception e) {  
            e.printStackTrace();  
        }  
    }  
}

The output will showcase the new object instance, illustrating the deserialization vulnerability.

Lastly, we'll refactor the class into a record while retaining the same constraint to demonstrate the enhanced security of the deserialization process.

record Immutable(String name, Integer age) implements Serializable {  
    private static final Integer MIN_ALLOWED_AGE = 10;  
  
    Immutable {  
        if(age < MIN_ALLOWED_AGE) {  
            throw new IllegalArgumentException("User is under allowed age");  
        }  
    }  
}

Rerun the deserialization process, and observe that this time, the validation logic remains intact without being bypassed.

Caused by: java.lang.IllegalArgumentException: User is under allowed age

Conclusion

The enhancements offered by Java records extend beyond deserialization improvements, incorporating the ease and simplicity of creating immutable classes while significantly reducing boilerplate code. Java records provide a concise syntax for defining immutable data structures, streamlining the development process and enhancing code readability.

By compactly encapsulating data and behaviors, records promote code clarity and maintainability. Furthermore, their built-in support for immutability and structural equality simplifies code maintenance and enhances code robustness.