To compare Java objects without getters, public fields or an equals() method, we call EqualsBuilder.reflectionEquals() from Apache Commons Lang, which reads every private field through reflection and compares the values. It works without any change to the class, so it fits classes we cannot edit, such as classes generated by a tool or shipped in a library jar.
We use it in tests, in data migration checks and in code that has to detect duplicates of third-party objects. When we own the class, a proper equals() and hashCode() pair or a record is the better fix.
The following example compares two Shipment objects, a class with three private fields and no accessors.
Shipment first = new Shipment(1L, "Anna", "Pune");
Shipment second = new Shipment(1L, "Anna", "Pune");
Shipment other = new Shipment(2L, "Anna", "Pune");
boolean byEquals = first.equals(second); // false, compares references
boolean byFields = EqualsBuilder.reflectionEquals(first, second); // true
boolean differentId = EqualsBuilder.reflectionEquals(first, other); // false
boolean ignoringId = EqualsBuilder.reflectionEquals(first, other, "id"); // true
Notice that the plain equals() returns false, because Shipment inherits the reference check from Object. We look at the rules reflectionEquals() follows, excluding fields, superclass fields, nested objects, the limits of reflection on Java 25 and the alternatives for unit tests.
1. EqualsBuilder.reflectionEquals() Method
The EqualsBuilder class is part of the commons-lang3 library. Its static reflectionEquals() methods call AccessibleObject.setAccessible(true) on each field, read the private values of both objects and compare them pair by pair.
The comparison follows fixed rules, and two of them matter most, namely transient fields and nested objects.
| Field kind | Compared by default? | How |
|---|---|---|
| Primitive fields | Yes | By value |
| Object fields | Yes | With the field’s own equals(), so a nested object without equals() is compared by reference |
| Arrays | Yes | Element by element |
| Superclass fields | Yes | Up to Object, or up to a class we pass as reflectUpToClass |
| transient fields | No | Only when we pass testTransients = true |
| static fields | No | Never |
We add the library with the org.apache.commons:commons-lang3 dependency. Version 3.21.0 is the current release on Maven Central.
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.21.0</version>
</dependency>
Gradle projects use implementation ‘org.apache.commons:commons-lang3:3.21.0’. The method also comes in several overloads, each with the same basic behavior.
- reflectionEquals(lhs, rhs, String… excludeFields) skips the named fields.
- reflectionEquals(lhs, rhs, Collection<String> excludeFields) does the same with a list of names.
- reflectionEquals(lhs, rhs, testTransients) includes transient fields when the flag is true.
- reflectionEquals(lhs, rhs, testTransients, reflectUpToClass, testRecursive, excludeFields…) controls every option at once.
2. How to Compare Java Objects Without Getters
Suppose a shipping partner gives us a jar with a Shipment class generated from their XML schema. The class has a constructor and private fields, but no getters, no equals() and no toString(), and we cannot regenerate it.
class Shipment {
private Long id;
private String receiver;
private String city;
Shipment(Long id, String receiver, String city) {
this.id = id;
this.receiver = receiver;
this.city = city;
}
}
Our app receives the same shipment twice, once from a webhook and once from a nightly file, and it must skip the duplicate. A plain equals() call would treat every received object as new, while reflectionEquals() compares the content.
Shipment fromWebhook = new Shipment(7L, "Ravi", "Delhi");
Shipment fromFile = new Shipment(7L, "Ravi", "Delhi");
boolean duplicate = EqualsBuilder.reflectionEquals(fromWebhook, fromFile); // true
boolean nullSide = EqualsBuilder.reflectionEquals(fromWebhook, null); // false
The method returns false when one side is null and true when both arguments are the same reference. It does not throw for null arguments, so we can call it on optional data without a check.
3. Excluding Fields From Equality Comparison
Often two objects describe the same thing but differ in a technical field, such as a database id or a timestamp. We pass the names of such fields as extra arguments, either as varargs or as a List, and the method skips them.
Shipment saved = new Shipment(10L, "Meera", "Goa");
Shipment incoming = new Shipment(null, "Meera", "Goa");
boolean allFields = EqualsBuilder.reflectionEquals(saved, incoming); // false
boolean withoutId = EqualsBuilder.reflectionEquals(saved, incoming, "id"); // true
boolean fromList = EqualsBuilder.reflectionEquals(saved, incoming, List.of("id")); // true
Field names are plain strings, so a renamed field in the next version of the generated class makes the exclusion stop working without any compile error. When we can annotate the class, the @EqualsExclude annotation on the field keeps the rule next to the field instead.
4. Handling Inheritance With reflectUpToClass
By default, reflectionEquals() also compares the fields of every superclass. When the parent class holds technical fields that should not count, we stop the walk with setReflectUpToClass(), which includes the given class and excludes everything above it.
class BaseVo {
private Long version;
BaseVo(Long version) {
this.version = version;
}
}
class ParcelVo extends BaseVo {
private String receiver;
private String city;
ParcelVo(Long version, String receiver, String city) {
super(version);
this.receiver = receiver;
this.city = city;
}
}
In the following example, the two parcels differ only in the version field of BaseVo. The first comparison includes it, and the second one stops at ParcelVo.
ParcelVo v1 = new ParcelVo(1L, "Ravi", "Delhi");
ParcelVo v2 = new ParcelVo(2L, "Ravi", "Delhi");
boolean withParent = EqualsBuilder.reflectionEquals(v1, v2); // false
EqualsBuilder upToChild = new EqualsBuilder().setReflectUpToClass(ParcelVo.class);
boolean childOnly = upToChild.reflectionAppend(v1, v2).isEquals(); // true
The builder form, with new EqualsBuilder() and reflectionAppend(), exposes every option as a setter, such as setExcludeFields(), setTestTransients() and setTestRecursive().
5. Comparing Nested Objects Recursively
A field that holds another object is compared with that object’s equals(). If the nested class has no equals() either, the comparison falls back to references and returns false for two copies with the same data.

class Size {
private int widthCm;
private int heightCm;
Size(int widthCm, int heightCm) {
this.widthCm = widthCm;
this.heightCm = heightCm;
}
}
class Box {
private String label;
private Size size;
Box(String label, Size size) {
this.label = label;
this.size = size;
}
}
Box boxA = new Box("books", new Size(40, 30));
Box boxB = new Box("books", new Size(40, 30));
boolean shallow = EqualsBuilder.reflectionEquals(boxA, boxB); // false, Size compared by reference
EqualsBuilder recursive = new EqualsBuilder().setTestRecursive(true);
boolean deep = recursive.reflectionAppend(boxA, boxB).isEquals(); // true
With setTestRecursive(true), the builder walks into every nested object that is not a primitive, a wrapper or a class in its bypass list. String is in the default bypass list, so strings are still compared with equals(). Recursive mode reflects into every object in the graph on every call, so its cost grows with the size of the graph, and we keep it for small objects.
6. Limits of Reflection-Based Equality on Java 25
Reflection works on our own classes and on classes from libraries on the classpath. It does not work on JDK classes, because since Java 17 the JDK modules do not open their packages for deep reflection.
boolean jdkObject = EqualsBuilder.reflectionEquals(new StringBuilder("a"), new StringBuilder("a")); // InaccessibleObjectException
The exception message names the field and the package, here java.lang, that the module does not open. A JDK class as a field value is fine in the default mode, because reflectionEquals() calls the field’s own equals(). The problem appears when the top-level objects are JDK classes, or when recursive mode walks into a JDK class that is not in the bypass list.
Reflection-based equality has a few more costs that matter in production code.
- It is slower than a hand-written equals(), because every call looks up fields and reads them through reflection.
- It has no matching hashCode() unless we also use HashCodeBuilder.reflectionHashCode(), so a HashSet of such objects still uses identity.
- It compares every field, including caches and lazily computed values, unless we exclude them by name.
- Code that runs with a module path must open the package of the compared class to org.apache.commons.lang3 with an opens directive.
When writing the comparison by hand is acceptable, a short reflection loop does the same job for flat classes without the extra library. The reflection API gives us the fields, and Objects.equals() handles null values.
static boolean fieldsEqual(Object a, Object b) throws IllegalAccessException {
if (a == b) {
return true;
}
if (a == null || b == null || a.getClass() != b.getClass()) {
return false;
}
for (Field field : a.getClass().getDeclaredFields()) {
if (Modifier.isStatic(field.getModifiers())) {
continue;
}
field.setAccessible(true);
if (!Objects.equals(field.get(a), field.get(b))) {
return false;
}
}
return true;
}
boolean manual = fieldsEqual(new Shipment(3L, "Ali", "Agra"), new Shipment(3L, "Ali", "Agra")); // true
The fieldsEqual() loop ignores superclass fields and nested objects, which is why EqualsBuilder is the better choice for anything beyond a flat class.
7. Alternatives for Tests and for Classes We Own
In unit tests, AssertJ compares objects field by field and reports which field differs, which reflectionEquals() cannot do. Its recursive comparison reads a getter when one exists and the field otherwise, so it works for classes without getters. Since AssertJ 3.17.0, it also ignores overridden equals() methods by default.
Shipment expected = new Shipment(null, "Anna", "Pune");
Shipment actual = new Shipment(42L, "Anna", "Pune");
assertThat(actual).usingRecursiveComparison().ignoringFields("id").isEqualTo(expected);
assertThat(new Box("toys", new Size(20, 20))).usingRecursiveComparison().isEqualTo(new Box("toys", new Size(20, 20)));
Both assertions pass, and the second one compares the nested Size objects without any extra setting. AssertJ 3.27.7 is the current stable release, and the AssertJ guide covers the setup.
When we own the class, we do not need reflection at all. A record generates equals(), hashCode() and accessors from its components, and a regular class can get both methods from the IDE or from Lombok.
record ShipmentRecord(Long id, String receiver, String city) {}
boolean recordEquals = new ShipmentRecord(1L, "Anna", "Pune").equals(new ShipmentRecord(1L, "Anna", "Pune")); // true
| Situation | Best option |
|---|---|
| Class from a jar or code generator, production code | EqualsBuilder.reflectionEquals() with excluded fields |
| Same, with nested objects without equals() | EqualsBuilder with setTestRecursive(true) |
| Unit test assertions | AssertJ usingRecursiveComparison() |
| Class we can edit | Record, or hand-written equals() and hashCode() |
8. Comparing Objects Without Getters FAQs
Speed, transient fields and the matching hash code are the details that decide whether reflection-based equality fits a project.
8.1. Does reflectionEquals() compare transient fields?
No, not by default. Transient fields often hold derived or cached values, so the plain overloads skip them. We pass testTransients = true, for example reflectionEquals(a, b, true), to include them.
8.2. Is EqualsBuilder.reflectionEquals() slow?
Yes, compared with a hand-written equals(). The method looks up and reads fields through reflection on every call. For a duplicate check on incoming messages, the difference rarely matters. For a comparison inside a tight loop or a large sort, we write a comparison against the fields we need, or cache the field list.
8.3. Can we use reflectionEquals() inside our own equals() method?
Yes. The line return EqualsBuilder.reflectionEquals(this, o); together with return HashCodeBuilder.reflectionHashCode(this); keeps both methods in sync when fields are added. It costs reflection on every HashMap lookup, so a record or a generated pair is the faster choice.
8.4. Why does reflectionEquals() throw InaccessibleObjectException?
One of the compared objects, or a nested object in recursive mode, is a JDK class such as StringBuilder or ArrayList, and its module does not open the package for reflection. We compare such values with their own equals(), or add the class to setBypassReflectionClasses() in recursive mode, as section 6 explains.
9. Conclusion
EqualsBuilder.reflectionEquals() compares two objects field by field when the class has private fields and no getters or equals(). It skips static and transient fields, includes superclass fields up to reflectUpToClass, and accepts field names to exclude.
Nested objects without equals() need setTestRecursive(true), and JDK classes cannot be compared through reflection on Java 17 and later. For tests, AssertJ’s recursive comparison gives better failure messages, and for classes we own, a record removes the need for reflection. More core topics are in the Java tutorial, and the object-oriented programming guide explains why a class should own its equality rules.
10. References
- EqualsBuilder Javadoc, Commons Lang 3.21.0
- HashCodeBuilder Javadoc
- AssertJ Recursive Comparison
- JEP 403, Strongly Encapsulate JDK Internals
- AccessibleObject.setAccessible() Javadoc
Happy Learning !!