Reflection: Concepts, Use Cases, and Risks
Interview Question: "Explain Java Reflection to me. Frameworks like Spring, Hibernate, and JUnit use it extensively, but if you were reviewing my PR and saw I wrote custom Reflection code in a business service, why would you reject it?"
The short version
Reflection (java.lang.reflect) lets code look at - and change - the structure of a class at runtime: its methods, fields, and constructors, even the private ones, bypassing the normal compiler checks entirely.
It's how the big frameworks work their magic: Spring scans for annotations and injects into private fields, JUnit finds and calls your @Test methods, Jackson maps JSON keys straight onto private POJO fields. None of that would be possible without reflection.
But that doesn't mean it belongs in your own business logic. Reflection throws away compile-time type checking (typos become runtime crashes), breaks encapsulation (it can read and overwrite private state directly), is noticeably slower (the JIT compiler can't inline a reflective call), and since Java 9's module system, is actively blocked by default unless a package explicitly opts in.
The analogy: an X-ray and a master key
Picture a bank vault: the lobby is public, the cash room is private. Normally the compiler is the security guard standing at the vault door, and it stops anyone who isn't supposed to be there.
Reflection is an X-ray scanner paired with a master key. It walks straight past that guard, sees every hidden compartment, and uses setAccessible(true) to unlock and change anything inside directly. Framework authors need that key to build generic tooling. Handing it to everyday business-logic code is how things go wrong.
Direct call vs. reflective call
wallet.deposit(100)method.invoke(wallet, 100)A direct call compiles straight to a fast bytecode instruction the JIT can later inline into raw machine code. A reflective call looks the method up by name, runs an access check, boxes the arguments into an array, and dispatches dynamically - none of which the JIT can optimize away. It's typically 10-50x slower.
Where you actually run into it: the frameworks you already use
Spring (dependency injection). You never call new Service() yourself. At startup, Spring's ApplicationContext scans the classpath with reflection, finds classes marked @Component/@Service/@Repository, constructs them via Constructor.newInstance(), and injects anything marked @Autowired directly into private fields.
JUnit (test discovery). JUnit doesn't have a hardcoded list of your test methods - it reflects on the class to find them:
for (Method method : TestClass.class.getDeclaredMethods()) {
if (method.isAnnotationPresent(Test.class)) {
method.invoke(testInstance); // runs your test dynamically
}
}
Jackson/Gson (JSON mapping). Given {"userId": 42, "role": "admin"}, Jackson inspects your POJO's fields via getDeclaredFields(), matches them to the JSON keys, and sets them directly - even if no public setter exists.
Direct calls vs. the Reflection API
| Dimension | Direct call | Reflection |
|---|---|---|
| Speed | Sub-nanosecond, compiles to native instructions | 10-50x slower - lookup, boxing, security checks |
| Compile-time safety | Typos fail the build | None - method names are strings, fail at runtime |
| Encapsulation | Can't touch private/package-private members | Bypassed via setAccessible(true) |
| JIT inlining | Yes - hot methods get inlined | Mostly blocked |
| Refactoring | IDE renames update every call site | String literals don't get renamed - breaks silently |
| Failure mode | Compiler error | Runtime exception (NoSuchMethodException, etc.) |
What it looks like to break encapsulation with it
import java.lang.reflect.Field;
class DigitalWallet {
private double balance = 150.00; // no public setter - by design
public double getBalance() {
return this.balance;
}
}
public class ReflectionBreaker {
public static void main(String[] args) throws Exception {
DigitalWallet wallet = new DigitalWallet();
System.out.println("Original Balance: " + wallet.getBalance()); // 150.0
Field balanceField = DigitalWallet.class.getDeclaredField("balance");
balanceField.setAccessible(true); // override the access check
balanceField.set(wallet, 1_000_000.00); // overwrite private state directly
System.out.println("Compromised Balance: " + wallet.getBalance()); // 1000000.0
}
}
Two reasons this is dangerous in business code, not just framework code:
- It skips validation.
DigitalWalletmight enforce a credit limit on its balance - reflection walks straight past that rule. - It's fragile in a way the compiler can't catch. If someone renames
balancetocurrentBalance, nothing warns you at build time. It just throwsNoSuchFieldExceptionin production.
Modern Java pushes back: the module system
Historically, setAccessible(true) was close to unlimited power over the whole JVM. Since Java 9's module system (Project Jigsaw), that's no longer true by default:
A class in one module can't reflectively reach private or package-private members in another module, unless that package is explicitly
opens-ed inmodule-info.java.
Try it against the JDK's own internals on Java 17+ and you get:
java.lang.reflect.InaccessibleObjectException: Unable to make field private final byte[]
java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module
Operators can still allow it explicitly:
java --add-opens java.base/java.lang=ALL-UNNAMED -jar app.jar
But the direction is clear - Java is steadily locking down casual reflective access rather than opening it up further.
Say this in the interview
"Reflection lets code inspect and modify a class's structure at runtime, including private members, bypassing compile-time checks. It's essential to how frameworks like Spring, JUnit, and Jackson work - scanning annotations, invoking test methods, mapping JSON onto private fields. But writing it directly in business logic is a red flag: it loses compile-time safety, breaks encapsulation, is meaningfully slower because the JIT can't inline it, and silently breaks when something gets renamed instead of failing at build time. Since Java 9, the module system also blocks unconfigured reflective access by default, which is the platform's own way of discouraging it."