Skip to main content

Reflection: Concepts, Use Cases, and Risks

Hard

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​

Direct call
wallet.deposit(100)
checked at compile time, JIT can inline it
Reflective call
method.invoke(wallet, 100)
string lookup + access check + boxing, no inlining

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​

DimensionDirect callReflection
SpeedSub-nanosecond, compiles to native instructions10-50x slower - lookup, boxing, security checks
Compile-time safetyTypos fail the buildNone - method names are strings, fail at runtime
EncapsulationCan't touch private/package-private membersBypassed via setAccessible(true)
JIT inliningYes - hot methods get inlinedMostly blocked
RefactoringIDE renames update every call siteString literals don't get renamed - breaks silently
Failure modeCompiler errorRuntime 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:

  1. It skips validation. DigitalWallet might enforce a credit limit on its balance - reflection walks straight past that rule.
  2. It's fragile in a way the compiler can't catch. If someone renames balance to currentBalance, nothing warns you at build time. It just throws NoSuchFieldException in 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 in module-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."