🔴 The Error You're Seeing
Confirm this matches your console output. If it does, you're in the right place.
// DOC-DERIVED — canonical wrapper shape; the Caused by section varies with the target code
// ("4 more" below = the four outer frames elided from the cause trace: accessor, invoke, start, main)
Exception in thread "main" java.lang.reflect.InvocationTargetException
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at com.devinhyderabad.plugin.PluginRunner.start(PluginRunner.java:31)
at com.devinhyderabad.plugin.PluginRunner.main(PluginRunner.java:12)
Caused by: java.sql.SQLException: Connection refused: plugin database not reachable
at com.devinhyderabad.plugin.DbPlugin.connect(DbPlugin.java:18)
... 4 more⚡ Quick Fix Works 80% of the time
Stop reading the top line — call getCause() and debug that exception instead; the wrapper carries no information of its own.
try {
method.invoke(plugin);
} catch (InvocationTargetException e) {
Throwable real = e.getCause();
log.error("Plugin {} failed", pluginName, real);
throw new PluginExecutionException("start failed", real);
}🧠 Why this Happens
Tap to expand the deep technical explanation
Method.invoke declares only IllegalAccessException and InvocationTargetException, so it can never let an arbitrary checked exception escape raw. The reflective accessor therefore catches ANY Throwable the callee throws and rethrows it boxed inside a fresh wrapper whose own message is null by design — Constructor.newInstance does the same. That is why the trace shows generic plumbing on top while everything diagnostic sits below the first Caused by. Frameworks like Spring and JUnit unwrap it again internally, which is why the same bug looks different under a test runner than in hand-written reflection.
The HITEC City Parking Spot Analogy:
Padded courier packaging around a broken parcel: the courier refuses to tell you what shattered — you have to open the box yourself with getCause().
🔁 How to Reproduce Confirm this is your error
Give com.devinhyderabad.plugin.DbPlugin.connect() a body that throws SQLException("Connection refused"), then call clazz.getMethod("connect").invoke(instance) and inspect e.getCause(). (Envelope shape verified against OpenJDK 25 reflection frames.)
🛠️ Solutions (5 Ways to Fix)
Unwrap getCause() and handle the real exception type
👉 Use this when/if any reflective call fails and the log shows InvocationTargetException with no useful message.
The wrapper is noise; the cause is your bug. Unwrap once, switch on the cause type where you can act on it, and always log or rethrow the cause — never the envelope alone.
catch (InvocationTargetException e) {
Throwable cause = e.getCause();
if (cause instanceof SQLException sqlEx) {
throw new PluginExecutionException("db unreachable", sqlEx);
}
throw new PluginExecutionException("unexpected target failure", cause);
}Translate once at the reflection boundary into a domain exception
👉 Use this when/if several call sites reflect onto plugins and you want business layers free of java.lang.reflect types.
Centralize the unwrap in one helper that converts InvocationTargetException into your own PluginExecutionException carrying the original cause. Upper layers then see one predictable exception family regardless of what the target threw.
private <T> T dispatch(Callable<T> reflectiveCall) {
try {
return reflectiveCall.call();
} catch (InvocationTargetException e) {
throw new PluginExecutionException(e.getCause());
} catch (ReflectiveOperationException e) {
throw new PluginExecutionException(e);
}
}Log the whole chain — never getMessage() on the wrapper
👉 Use this when/if the wrapper keeps reaching dashboards with an empty message and nobody can tell what failed.
e.getMessage() on the envelope prints null because the JVM stored nothing there. Pass the throwable itself to the logger (SLF4J/Logback render the Caused by chain), or log e.getCause() explicitly.
// Wrong: log.error("invoke failed: {}", e.getMessage()); -> "null"
log.error("Reflective start of {} failed", pluginName, e); // full chainSkip the envelope with MethodHandles on hot paths
👉 Use this when/if reflective dispatch runs millions of times per minute and both the allocation cost and the noise bother you.
MethodHandle.invoke and invokeExact propagate the RAW Throwable thrown by the target — no InvocationTargetException allocation, cleaner stacks, and measurably faster than Method.invoke after warmup.
MethodHandle connect = MethodHandles.lookup()
.findVirtual(DbPlugin.class, "connect", MethodType.methodType(void.class));
try {
connect.invokeExact(plugin); // throws the raw exception directly
} catch (Throwable t) {
throw new PluginExecutionException(t); // you choose the wrapping policy
}Fail at wiring time, not mid-flight
👉 Use this when/if reflective targets come from configuration and you would rather crash at boot than during request 10,000.
Resolve Method/Constructor objects once at startup and validate accessibility there. Runtime invoke paths then carry only genuine business failures — which is exactly what InvocationTargetException should mean in your logs.
@PostConstruct
void wire() {
try {
this.connect = pluginClass.getMethod("connect");
this.connect.setAccessible(true);
} catch (NoSuchMethodException e) {
throw new IllegalStateException("plugin lacks connect()", e);
}
}📋 Version Notes
Wrapping contract identical since 1.1; legacy getTargetException() accessor still present alongside getCause().
Reflection internals moved to jdk.internal.reflect.DirectMethodHandleAccessor — traces show fewer sun.reflect frames.
JEP 416 reimplements core reflection over method handles; same wrapping contract, slimmer traces.
Unchanged; MethodHandles remain the wrapper-free alternative.
🛡️ How to Prevent This Next Time
Wrap every Method.invoke behind one utility that unwraps and translates exceptions in a single place, resolve reflective handles at startup rather than per request, and prefer direct interfaces, lambdas, or MethodHandles wherever performance matters.