Chapter 16 of 18
16 — Reflection
Class, Field, and Method — the machinery behind frameworks you almost never need yourself.
Kata: an ObjectRenderer that turns any object into field=value lines — and can call a method by name — without knowing the class at compile time.
New ideas: Class, Field, Method, setAccessible, and the honest rule: reflection is for frameworks, not your application code.
This chapter is a kata — write the code yourself. If you’re following along inside the solutions repo, the finished class also ships in
src/main/java/reflection— don’t open it yet. Write each step from here first. Two REDs below, and both fix the next test, not the current one.
Step 1 — RED: the test names the behaviour
We don’t compile against Person; we hand the renderer any Object:
@Test
void rendersFieldsOfAnyObject() {
Person person = new Person("Ada", 42);
String rendered = new ObjectRenderer().render(person);
assertTrue(rendered.contains("name=Ada"));
assertTrue(rendered.contains("age=42"));
}
With the test’s record Person(String name, int age) in place, run
gradle test --tests "reflection.ObjectRendererTest":
error: cannot find symbol
String rendered = new ObjectRenderer().render(person);
^
symbol: class ObjectRenderer
RED. ObjectRenderer doesn’t exist, and neither does the invoke method the other two tests
refer to. You’re about to build both.
Step 2 — RED: getFields() finds nothing
The first attempt uses the innocent-looking getFields(). On a record, all fields are private
final, and getFields() returns only public fields — so it returns none:
for (Field field : value.getClass().getFields()) {
result.append(field.getName()).append('=').append(field.get(value))...
}
Run rendersFieldsOfAnyObject again:
ObjectRendererTest > rendersFieldsOfAnyObject() FAILED
org.opentest4j.AssertionFailedError: expected: <true> but was: <false>
(Bland message, isn’t it? assertTrue tells you nothing about what was missing — compare that
with chapter 18, where AssertJ prints the actual objects.) The rendered string is empty because
the public-only API skipped every field.
Step 3 — GREEN: getDeclaredFields + setAccessible
for (Field field : value.getClass().getDeclaredFields()) {
field.setAccessible(true);
result.append(field.getName()).append('=')
.append(field.get(value)).append(lineSeparator);
}
getClass()→ the runtimeClass<...>for the actual object.getDeclaredFields()returns all fields, includingprivate(a record’s fields are private final).field.get(value)reads the value;setAccessible(true)unlocks the private fields — exactly what JSON or ORM libraries do under the hood.
Run it: rendersFieldsOfAnyObject is GREEN.
Step 4 — RED: the method round-trip swallows the real error
The suite also expects invoke(target, "name") to call the method, and invoke(target, "panic")
to throw the real exception:
@Test
void unwrapsTheCauseOfAThrowingMethod() {
IllegalStateException exception = assertThrows(
IllegalStateException.class,
() -> new ObjectRenderer().invoke(new Person("Ada", 42), "panic"));
assertEquals("boom", exception.getMessage());
}
A first cut:
return target.getClass().getMethod("name").invoke(target);
fails at compile time (Method.invoke throws checked exceptions) — RED #1 — so you wrap:
try {
return target.getClass().getMethod(name).invoke(target);
} catch (InvocationTargetException e) {
throw new IllegalStateException(e.getCause());
}
Run the suite. invokesAMethodByName passes, then:
RED #2:
ObjectRendererTest > unwrapsTheCauseOfAThrowingMethod() FAILED
org.opentest4j.AssertionFailedError: expected: <boom> but was: <java.lang.IllegalStateException: boom>
invoke wraps any exception the target throws in InvocationTargetException — but
new IllegalStateException(cause) builds its message from cause.toString(). The real boom
is one layer down and your wrapper swallowed it.
Step 5 — GREEN: unwrap, keeping the message
} catch (InvocationTargetException e) {
throw new IllegalStateException(e.getCause().getMessage(), e.getCause());
}
Now the caller sees the genuine IllegalStateException("boom") — type, message, and cause — and
the test is GREEN.
REFACTOR — and now, don’t use it
For any specific class, reflection is strictly worse than the straightforward direct code: no
compile-time checks, slower, and a NoSuchMethodException at runtime instead of a compiler
error. So the mature version touches the type directly:
public String describe(Person person) {
return "name=" + person.name() + lineSeparator + "age=" + person.age();
}
Keep the generic machinery only where the type genuinely cannot be known: test frameworks
(JUnit finding @Test methods), JSON mappers (Jackson), DI containers (Spring wiring
beans). That is reflection’s natural habitat — and now you know what they’re all actually doing.
Java-specific notes
- Reflection trades compile-time safety for runtime flexibility. In application code, “I don’t
know the type” is usually a smell that a design — an interface, a
Map, aFunction— would fix with static types. - Module system respects
setAccessibleonly when the package is open; records make this explicit. Frameworks supply--add-opens, which is why you see it in app launchers. - Notice the TDD shape of this chapter: each failure taught something specific —
getFields()vsgetDeclaredFields(), and message vs cause. The tests didn’t just verify; they uncovered.
Run it
gradle test --tests "reflection.*"
Key takeaway: knowing Class/Field/Method demystifies every framework you’ll ever use —
but reach for them in your code only when no static type can do the job.
Next: 17 — webserver — a real HTTP server, built test-first.