Lombok Field Iteration: Safer Object Copying in Java

Copying objects is a common and unexpectedly thorny problem in object oriented programming. Complex object graphs may include many different relationships that require different approaches — direct copying, deep copying, backreferencing, no action, or more bespoke operations unique to a specific domain. Reflective approaches to copying may make assumptions that constrain the object graph in undesirable ways and may require unchecked casts, while handwritten copy code introduces a maintenance burden. While both reflective and hand-written approaches have their place, this essay introduces a third option: compile-time field enumeration using Lombok. Requirements Lombok with experimental features Java 14+ for switch expressions (optional) A Motivating Example Consider the following model: @Data public class Document { private UUID id; private String title; private List authors; private List sections; } Copying a Document requires copying the title directly, copy-constructing one list (the authors), and deep copying another list (the sections). For example's sake, suppose the copy should have a new, randomly assigned id. Setting up a Document to be copied reflectively likely requires several annotations, depending on the library in use. Copying a Document manually is fairly straightforward, but still requires manual effort to keep in sync with the model; worse, it's possible to forget to update the tests. If only there was a way to retain the flexibility of hand-written copy code without the possibility of oversights. Fortunately, the Lombok library provides one. Approach 1: Exhaustive Constructor Lombok's @AllArgsConstructor annotation provides a form of field enumeration in the parameters of the generated constructor. If a manually written copy method uses this constructor, any new fields will cause a compile-time error unless the copy code is updated as well. Let's see how it works in our Document model: @Data @NoArgsConstructor @AllArgsConstructor public class Document { private UUID id; private String title; private List authors; private List sections; public Document copy() { return new Document( UUID.randomUUID(), // domain-specific logic: copies get unique IDs title, new ArrayList(authors), sections.stream().map(Section::copy).toCollection(ArrayList::new) ); } } This approach works. Reading the code makes it obvious how each field is copied, and handling special cases is easy. The use of @AllArgsConstructor does have some downsides, however. First, it isn't always appropriate for collection-valued fields. If the author of Document wanted to make defensive copies or guarantees about mutability, they can't easily do so. Making the generated constructor private or package-visible is an option, but brings some challenges of its own. Second, as the number of fields grows, so does the chance of getting the order of arguments wrong. (While all of our fields have distinct types, that often isn't the case — imagine a Customer type with multiple names, email addresses, and so on.) Third, while rare, any final fields with default values won't be captured by @AllArgsConstructor, so this technique breaks down in a non-obvious case. Let's see if we can do better. Approach 2: Field Enumeration and Iteration The @FieldNameConstants annotation enumerates the fields of a class at compile time, with the option to generate the results as an enum. Java can both iterate and switch over enum constants. Put together, this is a powerful combination that nicely solves our problem. We'll start with a version that ensures that each field is copied at runtime, then use the switch expressions introduced in Java 14 to replace the runtime check with a compile-time one. @Data @FieldNameConstants(asEnum = true) public class Document { private UUID id; private String title; private List authors; private List sections; public Document copy() { var copy = new Document(); for (var field : Fields.values()) { switch (field) { case id: copy.id = UUID.randomUUID(); break; case title: copy.title = title; break; case authors: copy.authors = new ArrayList(authors); break; case sections: copy.sections = sections() .stream() .map(Section::copy) .toCollection(ArrayList::new); break; default: throw new AssertionError("Unhandled field: " + field); } } return copy; } } That's much better! Like the previous technique, reading the code makes it clear how e

May 13, 2025 - 03:39
 0
Lombok Field Iteration: Safer Object Copying in Java

Copying objects is a common and unexpectedly thorny problem in object oriented programming. Complex object graphs may include many different relationships that require different approaches — direct copying, deep copying, backreferencing, no action, or more bespoke operations unique to a specific domain. Reflective approaches to copying may make assumptions that constrain the object graph in undesirable ways and may require unchecked casts, while handwritten copy code introduces a maintenance burden. While both reflective and hand-written approaches have their place, this essay introduces a third option: compile-time field enumeration using Lombok.

Requirements

  • Lombok with experimental features
  • Java 14+ for switch expressions (optional)

A Motivating Example

Consider the following model:

@Data
public class Document {

    private UUID id;

    private String title;

    private List<Author> authors;

    private List<Section> sections;

}

Copying a Document requires copying the title directly, copy-constructing one list (the authors), and deep copying another list (the sections). For example's sake, suppose the copy should have a new, randomly assigned id. Setting up a Document to be copied reflectively likely requires several annotations, depending on the library in use. Copying a Document manually is fairly straightforward, but still requires manual effort to keep in sync with the model; worse, it's possible to forget to update the tests.

If only there was a way to retain the flexibility of hand-written copy code without the possibility of oversights. Fortunately, the Lombok library provides one.

Approach 1: Exhaustive Constructor

Lombok's @AllArgsConstructor annotation provides a form of field enumeration in the parameters of the generated constructor. If a manually written copy method uses this constructor, any new fields will cause a compile-time error unless the copy code is updated as well. Let's see how it works in our Document model:

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Document {

    private UUID id;

    private String title;

    private List<Author> authors;

    private List<Section> sections;

    public Document copy() {
        return new Document(
            UUID.randomUUID(), // domain-specific logic: copies get unique IDs
            title,
            new ArrayList<>(authors),
            sections.stream().map(Section::copy).toCollection(ArrayList::new)
        );
    }

}

This approach works. Reading the code makes it obvious how each field is copied, and handling special cases is easy. The use of @AllArgsConstructor does have some downsides, however. First, it isn't always appropriate for collection-valued fields. If the author of Document wanted to make defensive copies or guarantees about mutability, they can't easily do so. Making the generated constructor private or package-visible is an option, but brings some challenges of its own. Second, as the number of fields grows, so does the chance of getting the order of arguments wrong. (While all of our fields have distinct types, that often isn't the case — imagine a Customer type with multiple names, email addresses, and so on.) Third, while rare, any final fields with default values won't be captured by @AllArgsConstructor, so this technique breaks down in a non-obvious case. Let's see if we can do better.

Approach 2: Field Enumeration and Iteration

The @FieldNameConstants annotation enumerates the fields of a class at compile time, with the option to generate the results as an enum. Java can both iterate and switch over enum constants. Put together, this is a powerful combination that nicely solves our problem. We'll start with a version that ensures that each field is copied at runtime, then use the switch expressions introduced in Java 14 to replace the runtime check with a compile-time one.

@Data
@FieldNameConstants(asEnum = true)
public class Document {

    private UUID id;

    private String title;

    private List<Author> authors;

    private List<Section> sections;

    public Document copy() {
        var copy = new Document();
        for (var field : Fields.values()) {
            switch (field) {
                case id:
                    copy.id = UUID.randomUUID();
                    break;
                case title:
                    copy.title = title;
                    break;
                case authors:
                    copy.authors = new ArrayList<>(authors);
                    break;
                case sections:
                    copy.sections = sections()
                        .stream()
                        .map(Section::copy)
                        .toCollection(ArrayList::new);
                    break;
                default:
                    throw new AssertionError("Unhandled field: " + field);
            }
        }
        return copy;
    }

}

That's much better! Like the previous technique, reading the code makes it clear how each field is copied, and we can still handle special cases easily. Unlike the previous technique, all of the fields are assigned by name, not order, so it's much harder to mix up two fields of the same type. We can also have mutable final fields, if we want to. While there's no compile-time check any more, if we forget to copy a field, the loop will hit the default clause and fail loudly. So long as our tests call this method even once, we still have confidence that we're copying every field.

Let's say we want to avoid the runtime check anyway. Java 14 and later support switch expressions, which we can leverage to enforce exhaustiveness at compile time instead. So long as we use the switch as an expression, forgetting to define how a field is copied will trigger a compilation failure.

    public Document copy() {
        var copy = new Document();
        for (var field : Fields.values()) {
            var _ = switch (field) {
                case id -> copy.id = UUID.randomUUID();
                case title -> copy.title = title;
                case authors -> copy.authors = new ArrayList<>(authors);
                case sections -> copy.sections = sections()
                    .stream()
                    .map(Section::copy)
                    .toCollection(ArrayList::new);
            };
        }
        return copy;
    }

What's up with var _ =? We want a switch expression, not a switch statement, because only the former enforce exhaustiveness. The assignment forces the switch to be an expression. This has the drawback that each branch of the switch needs to return a value (not void). Since the assignment operator returns its right-hand side, we're good here. If we wanted to define the copy logic outside of Document, we'd need to add a dummy yield 0; at the end of each branch, unless we'd defined the setters as returning the Document (perhaps through Lombok's @Accessors annotation). If that's too weird for you, you can still use a default clause that always throws, as described above.

Comparison: Reflection vs. Purely Manual Copies vs. Field Iteration

Manual copying provides the ultimate flexibility. Whether a field needs a deep or shallow copy, to be linked up as a backreference, to be skipped entirely, or to be treated in some specific way can be specified in ordinary Java code. However, it also places all responsibility for correctness on the author. It's easy to forget to copy a field, and hard to protect against that possibility in unit tests.

Reflective copies make almost the opposite tradeoff. They eliminate the effort of writing manual copy logic, but may require configuration, and rarely handle special domain logic well. Unit tests rarely catch mistakes in this configuration, either.

Field iteration with Lombok retains the flexibility of handwritten copy code, but adds compile-time protection against missing fields. If an author forgets to specify how a field should be copied, the application simply won't compile. Like with manual copies, the copy logic needs to be specified manually for each field; this does require some amount of boilerplate, but provides the strongest possible guarantee of completeness.

Testing

This field iteration can also be put to good use in tests. A test for a simple copy method, for example, can use it to ensure that all fields are tested:

var copy = source.copy();
for (var field : Document.Fields.values()) {
    var _ = switch (field) {
        case id -> assertThat(copy.getId()).isNotEqualTo(source.getId());
        case title -> assertThat(copy.getTitle()).isEqualTo(source.getTitle());
        // etc.
    };
}

Alternatively, JUnit's @ParameterizedTest and @EnumSource can be used instead of a loop.

Field iteration can even be used during test initialization, looping over the fields to ensure that each has an appropriate explicit value:

var source = new Document();
for (var field : Document.Fields.values()) {
    var _ = switch (field) {
        case id -> {
            source.setId(UUID.randomUUID());
            yield 0;
        }
        case title -> {
            source.setTitle(UUID.randomUUID().toString());
            yield 0;
        }
        // etc.
    };
}

The need to yield a dummy value can be avoided by using chaining setters instead.

Conclusion

While we've focused on copying, there's no reason that field iteration needs to be limited to this specific use case. One could imagine using field iteration for post-construction initialization, complex validation, or interning — all without reflective overhead. Even if a team would rather use more conventional techniques for their production code, using field iteration to ensure exhaustive unit tests can add considerably to their confidence in those tests.

The requirements of this technique are minimal. Lombok is widely used throughout the Java ecosystem, and projects targeting Java versions before Java 14 can still use the variant with a switch statement and a throwing default. The only truly hard requirement is a willingness to use @FieldNameConstants.

@FieldNameConstants(asEnum = true) provides a readily available, compile-time mechanism for enumerating a class's fields, and using a for loop with a switch expression provides a flexible way of processing those fields with a compile-time guarantee of completeness. In the event that some logic cannot easily be written as a switch expression, or in versions of Java in which switch expressions are not available, a switch statement with a throwing default clause ensures that the oversight is immediately caught during testing. The loop introduces minimal overhead — far less than reflection — while preserving static type safety. Field iteration strikes a valuable middle ground — providing strong compile-time guarantees with minimal runtime or mental overhead. It may not be widely known today, but it deserves a place in your toolbox.