Java has been evolving, with major improvements over the years, especially in the area of data handling. One of the latest advancements, introduced in Java 14, is the Record class, a special type of class aimed at reducing boilerplate code for immutable data carriers. While records offer many advantages, they come with certain limitations, especially regarding immutability and extensibility. Froporec, a Java annotation processor, offers a robust solution to these issues by simplifying the migration from POJOs (Plain Old Java Objects) to records while also providing deep immutability and the ability to extend records in ways Java doesn’t natively support.
WHAT IS FROPOREC ?
Froporec (https://froporec.org) is an open-source Java annotation processor that simplifies the migration from POJOs to records and enhances the functionality of records. Designed to work with Java 17 or higher, Froporec provides several key annotations to facilitate various use cases:
- Migration from POJOs to Records: Convert existing POJOs into records while retaining their data structure.
- Deep Immutability for Records: Address the limitation of Java records, where mutable objects like collections or POJOs can break the immutability of records. Froporec ensures deep immutability, making Records fully immutable and secure.
- Extending Records: Java records cannot be extended due to their final nature. Froporec introduces a way to extend records by merging them with fields from other POJOs or records.
Additionally, Froporec provides features for factory methods and constants for field names, but we will not cover them in this article.
For installation instructions and setup details, visit https://froporec.org/#installation.
MIGRATION FROM POJOS TO RECORDS
POJO (Plain Old Java Object) classes, the traditional Java classes for data transfer, are mutable by default, making them less secure in some cases. Froporec makes it easy to migrate from these mutable POJOs to immutable records using simple annotations. By annotating a POJO with @Record, Froporec generates a corresponding record class, ensuring that your codebase remains consistent and modern.
package org.froporec.annotation.client.record.data1_allclassesannotated; import org.froporec.annotations.Record; @Record public class Person { private String lastname; private int age; public Person(String lastname, int age) { this.lastname = lastname; this.age = age; } public String getLastname() { return lastname; } public void setLastname(String lastname) { this.lastname = lastname; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } } Note: We include the all-args constructor only to simplify instance creation, but Froporec does not require it to function.
Upon compilation, the code above will generate a PersonRecord class with the same fields, but all properties will be immutable. If you’re building a Maven project, the generated PersonRecord class will appear in the target folder, within the same package as the original Person POJO class. The PersonRecord class will be located alongside the Person POJO class and will be accessible from anywhere in your code in the same way as the Person POJO class.

Here is the content of the generated PersonRecord class (the @Generated annotation section, constant fields, and factory methods are omitted for better readability):
package org.froporec.annotation.client.record.data1_allclassesannotated; /* REMOVED @Generated annotation section */ public record PersonRecord(java.lang.String lastname, int age) { /* REMOVED Constant fields section */ public PersonRecord(org.froporec.annotation.client.record.data1_allclassesannotated.Person person) { this(person.getLastname(), person.getAge()); } /* REMOVED Factory methods section */ } As seen in the generated PersonRecord class above, two major changes stand out:
PersonRecordis a record class, not a POJO – The declaration is nowrecord PersonRecord(java.lang.String lastname, int age)- A special constructor is generated – This constructor accepts a
PersonPOJO instance as a single parameter and passes its data to the record’s canonical constructor. This ensures that the generated record maintains the same data as the original POJO class.
Below is an example of how to access and use the generated PersonRecord class by passing an instance of the original POJO class:
package org.froporec.annotation.client.sampleuse; import org.froporec.annotation.client.record.data1_allclassesannotated.Person; import org.froporec.annotation.client.record.data1_allclassesannotated.PersonRecord; public class PersonRecordUse { public static void main(String[] args) { Person johnPojo = new Person("Doe", 50); System.out.printf(""" %n --- POJO DATA --- LastName: %s, Age: %d """, johnPojo.getLastname(), johnPojo.getAge()); PersonRecord johnRecord = new PersonRecord(johnPojo); System.out.printf(""" %n --- RECORD DATA --- LastName: %s, Age: %d """, johnRecord.lastname(), johnRecord.age()); } } Here is the execution result:
--- POJO DATA --- LastName: Doe, Age: 50 --- RECORD DATA --- LastName: Doe, Age: 50
GUARANTEED DEEP IMMUTABILITY
Let’s now see what happens if the POJO class has a mutable collection member.
package org.froporec.annotation.client.record.data1_allclassesannotated; import org.froporec.annotations.Record; import java.util.List; @Record public class Person { private String lastname; private int age; private List<String> addresses; /* Mutable collection */ // Getters, setters,... } Here is the content of the generated PersonRecord class:
package org.froporec.annotation.client.record.data1_allclassesannotated; /* REMOVED @Generated annotation section */ public record PersonRecord(java.lang.String lastname, int age, java.util.List<java.lang.String> addresses) { /* REMOVED Constant fields section */ public PersonRecord(org.froporec.annotation.client.record.data1_allclassesannotated.Person person) { this( person.getLastname(), person.getAge(), java.util.Optional.ofNullable(person.getAddresses()).isEmpty() ? java.util.List.of() : person.getAddresses().stream().map(object -> (object)).collect(java.util.stream.Collectors.toUnmodifiableList()) ); } /* REMOVED Factory methods section */ } In the generated PersonRecord constructor’s body, the third argument ensures that the addresses field is always an unmodifiable list. It first checks if person.getAddresses() is null or empty, returning an empty immutable list (List.of()) if true. Otherwise, it processes the list with a stream, mapping each element unchanged and collecting the result as an unmodifiable list using Collectors.toUnmodifiableList(), ensuring immutability and preventing accidental modifications.
ALTERNATIVE USES OF @RECORD
The @Record annotation offers flexibility beyond standard class-level declarations. Below are different ways to use it effectively.
Annotating a Class Field Type
You can place @Record next to a field declaration to ensure that a record class is generated for the enclosed object type.
@Record class Person { private @Record Address address; } In this example:
- A corresponding
AddressRecordclass is generated for theAddresstype. - The generated
PersonRecordclass will contain anAddressRecordinstance instead of anAddressinstance, ensuring consistency in immutability.
Annotating a Method Parameter
You can also annotate a method parameter with @Record, triggering the generation of a record class during compilation.
void doSomething(@Record Person person) { PersonRecord personRecord = new PersonRecord(person); } This ensures that the PersonRecord class is available within the method. Note that the record class is generated and accessible only after at least one successful compilation.
Using the alsoConvert Attribute
Instead of annotating multiple classes separately, you can use the alsoConvert attribute to generate multiple record classes in one go.
@Record(alsoConvert = { Address.class, Email.class, Job.class }) class Person { private String lastname; private int age; private Address address; private Email email; private Job job; // Getters, setters,... } This approach generates record classes for Person, Address, Email, and Job, streamlining the conversion of multiple POJO classes into their record equivalents.
Note: The alsoConvert attribute may contain a mix of both existing POJO and record .class values, allowing seamless conversion regardless of the original class type.
DEEP IMMUTABILITY FOR RECORD CLASSES
In Java, record classes offer a convenient way to define immutable data carriers, with all their fields implicitly marked as final. However, this immutability is shallow by default, meaning that while the reference to the field itself is immutable, the contents of mutable objects within those fields (e.g. collections) are still mutable. This can lead to unintended side effects, especially when working with mutable collections.
Consider the following example:
package org.froporec.annotation.client.immutable.data4_collectionsannotated; import java.util.List; public record Person( String lastname, int age, List<String> addresses /* Mutable collection */) { } In this case, the addresses field is a mutable List<String>. Even though the record itself ensures that the reference to addresses is final, the content of the list remains mutable. Here’s how you can still modify the contents of the collection:
void addAddress() { Person person = new Person("Doe", 50, new ArrayList<>()); // an empty List is passed as third argument person.addresses().add("office address"); // we can add a new item to the addresses List } To resolve this issue, Froporec provides the @Immutable annotation, which guarantees that mutable collections within a record are wrapped in unmodifiable views, ensuring that the content is immutable.
package org.froporec.annotation.client.immutable.data4_collectionsannotated; import org.froporec.annotations.Immutable; import java.util.List; @Immutable public record Person( String lastname, int age, List<String> addresses) { } By annotating the Person record with @Immutable, the addresses field is transformed into an unmodifiable list. This is achieved by processing the collection as a stream, mapping each element unchanged, and then collecting the result as an unmodifiable list using Collectors.toUnmodifiableList(). Here is the content of the generated ImmutablePerson class:
package org.froporec.annotation.client.immutable.data4_collectionsannotated; /* REMOVED @Generated annotation section */ public record ImmutablePerson(java.lang.String lastname, int age, java.util.List<java.lang.String> addresses) { /* REMOVED Constant fields section */ public ImmutablePerson(org.froporec.annotation.client.immutable.data4_collectionsannotated.Person person) { this( person.lastname(), person.age(), java.util.Optional.ofNullable(person.addresses()).isEmpty() ? java.util.List.of() : person.addresses().stream().map(object -> (object)).collect(java.util.stream.Collectors.toUnmodifiableList()) ); } /* REMOVED Factory methods section */ } Notes:
- As you may have noticed in the code above, the generated record class is named
ImmutablePersoninstead ofPersonRecord, which is the default naming convention when using@Record. - The alternative uses of the
@Recordannotation (annotating a class field type, a method parameter, and using thealsoConvertattribute), as discussed in the previous section, also apply to the@Immutableannotation. This means you can ensure deep immutability of objects while benefiting from the same conversion and record class generation mechanisms.
EXTENDING RECORDS
Java records are implicitly final, meaning we cannot extend them. This design choice is intentional and aligns with the primary purpose of records: to be transparent, immutable data carriers (JEP 395).
To bypass this limitation, Froporec offers the @SuperRecord annotation, which allows you to combine fields from multiple POJOs and records into a new record class, without using traditional Java inheritance. The @SuperRecord annotation can be used on top of either a POJO or a record class.
@SuperRecord(mergeWith = {School.class, Student.class}) public class Person { private String lastname; private int age; // Getters, setters,... } record School(String name) { } class Student { private int mark; private String grade; // Getters, setters,... } Upon compilation, the code above generates a new record class named PersonSuperRecord, which combines all fields from the Person, School, and Student classes. The fields from the classes specified in the mergeWith attribute are automatically added to the Person class.
public record PersonSuperRecord(java.lang.String lastnamePerson, int agePerson, java.lang.String nameSchool, int markStudent, java.lang.String gradeStudent) { public PersonSuperRecord(org.froporec.annotation.client.superrecord.Person person, org.froporec.annotation.client.superrecord.School school, org.froporec.annotation.client.superrecord.Student student) { this( person.getLastname(), person.getAge(), school.name(), student.getMark(), student.getGrade() ); } } The generated PersonSuperRecord record provides a constructor that allows you to create a new instance by passing existing instances of the POJOs or records listed in the mergeWith attribute.
INTEGRATION WITH LOMBOK
In previous examples, our POJO classes explicitly defined getters and setters, a common characteristic of traditional Java POJOs. As of version 1.4, Froporec relies solely on getter methods to access fields in a POJO when generating the corresponding record class. This means that when using the @Record annotation, getters must be present in the POJO for Froporec to function correctly.
Manually writing getters for every field can be repetitive and time-consuming. This is where Lombok, a widely used open-source Java annotation processor, comes into play. Lombok simplifies Java development by reducing boilerplate code, particularly for common methods like getters, setters, constructors, and more. With Lombok’s @Getter annotation, developers can automatically generate getters at compile time without explicitly writing them in the class.
The following configurations are for projects built with Maven.
Maven Dependency Configuration
To integrate Froporec and Lombok in a Maven project, add the following dependencies to your pom.xml:
<dependencies> <dependency> <groupId>org.froporec</groupId> <artifactId>froporec</artifactId> <version>${froporec.version}</version> <!-- Use latest --> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>${lombok.version}</version> <!-- Use latest --> <scope>provided</scope> </dependency> </dependencies> Ensure Correct Annotation Processing Order
Since both Lombok and Froporec are annotation processors, they must be explicitly added to the Maven compiler plugin configuration to ensure they run during compilation. However, the order in which they are declared in the annotationProcessorPaths section is critical:
- Lombok must execute first to generate the necessary getter methods.
- Froporec must execute after Lombok to process the POJO with the generated getters.
If the annotation processing order is incorrect, Froporec may fail to detect the required getters.
Maven Compiler Plugin Configuration
<build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>${maven-compiler-plugin.version}</version> <configuration> <release>21</release> <compilerArgs>-Xlint:unchecked</compilerArgs> <annotationProcessorPaths> <!-- Annotation processors execute in the order listed below. Lombok runs first to generate boilerplate code (e.g., getters/setters), ensuring Froporec processes the updated classes correctly. --> <path> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>${lombok.version}</version> </path> <path> <groupId>org.froporec</groupId> <artifactId>froporec</artifactId> <version>${froporec.version}</version> </path> </annotationProcessorPaths> </configuration> </plugin> </plugins> </build> Using Lombok and Froporec Annotations Together
With this setup, you can now use both Lombok and Froporec in your POJO class:
@Record @Getter public class Person { private String lastname; private int age; private List<String> addresses; } Generated PersonRecord Class
Upon compilation, Froporec generates the following record class:
/* REMOVED @Generated annotation section */ public record PersonRecord(java.lang.String lastname, int age, java.util.List<java.lang.String> addresses) { /* REMOVED Constant fields section */ public PersonRecord(org.froporec.annotation.client.record.data1_allclassesannotated.Person person) { this( person.getLastname(), person.getAge(), java.util.Optional.ofNullable(person.getAddresses()).isEmpty() ? java.util.List.of() : person.getAddresses().stream().map(object -> (object)).collect(java.util.stream.Collectors.toUnmodifiableList()) ); } /* REMOVED Factory methods section */ } SUMMARY
Java records have introduced a more efficient way to handle immutable data structures, but they come with limitations, particularly around deep immutability and extensibility. Froporec addresses these issues by offering a streamlined way to migrate POJOs to records while ensuring true immutability.
| Library Name | FROPOREC |
| Library Type | Annotation Processor (Supports @Record, @Immutable, and @SuperRecord annotations as of v1.4) |
| Minimum Java Version | Java 17 |
| Specific Requirements | The annotated class must have Getter methods (unless it is a Record class) |
| Execution | – Runs at compile-time – Generates a record class alongside the annotated class within the same package |
| Compatibility | Works well with libraries like Lombok and similar tools (A correct order of annotation processors in the build configuration is required for seamless integration) |
Key takeaways from this article:
- Seamless POJO-to-Record conversion with minimal effort using the
@Recordannotation. - Guaranteed deep immutability, ensuring that even mutable objects like collections remain immutable within records. Additionally, the
@Immutableannotation can be applied to existing record classes to enforce immutability on fields that might otherwise be mutable. - Flexible annotation usage, including class-level, field-level, and method parameter-level conversions.
- Batch conversion via
alsoConvert, enabling multiple POJOs and/or Record classes to be transformed in a single annotation. - Record Extensibility with
@SuperRecord, combining fields from multiple POJOs and records into a new record class without relying on traditional Java inheritance, enhancing flexibility and reusability.
To explore Froporec further, including additional features, installation instructions, and code samples, visit the official webpage or GitHub repository. There, you can also contribute to the project’s growth by reporting issues or suggesting improvements.
Whether you’re modernizing legacy Java applications or starting a new project, Froporec provides a clean and efficient approach to handle immutable data. It’s a must-have tool for teams transitioning to modern Java practices. By simplifying immutable data management, Froporec helps build safer, more maintainable applications and ensures a smoother development experience.









