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

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 aswitch
expression, not aswitch
statement, because only the former enforce exhaustiveness. The assignment forces theswitch
to be an expression. This has the drawback that each branch of theswitch
needs to return a value (notvoid
). Since the assignment operator returns its right-hand side, we're good here. If we wanted to define the copy logic outside ofDocument
, we'd need to add a dummyyield 0;
at the end of each branch, unless we'd defined the setters as returning theDocument
(perhaps through Lombok's @Accessors annotation). If that's too weird for you, you can still use adefault
clause that alwaysthrow
s, 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.