Everything about reflection on one page, mapped to your SecurePay services (Login 9000, Gateway 8080, Payment 8081, Notification 8082). Start at the graph, go through stages 1 to 6, then run the workout.
Tap a node. Solid boxes are parts of java.lang.reflect; dashed boxes are the frameworks and JVM features that use or guard them.
Read an arrow as a sentence: ClassLoader loads Class, Class declares Method.
Reflection is reading and using a class's metadata while the program runs. When the JVM loads Payment.class, it keeps a description of it in memory (Metaspace). Every item below is a piece of that description.
| Data point | What it is | How you get it | In SecurePay |
|---|---|---|---|
| ClassLoader | Finds .class bytes and turns them into a Class object. | c.getClassLoader() | Spring Boot's loader reads classes from your fat jar. |
| Class<?> | The blueprint. One per class per loader. Entry point to everything else. | Payment.classobj.getClass()Class.forName("…") | org.example.Entity.Payment |
| Field | A variable declared in the class, with type and modifiers. | getDeclaredFields() | paymentId, accountNumber, amount, owner |
| Method | A method: name, parameter types, return type. Not its code. | getDeclaredMethods() | PaymentServiceImpl.createPayment(…) |
| Constructor | How to build an instance. | getDeclaredConstructor() | Hibernate needs Payment's no-arg constructor. |
| Parameter | A method argument. Real names only if compiled with -parameters (Boot does this). | m.getParameters() | request instead of arg0 in your trace logs. |
| Modifier | public, private, static, final, as a bit mask. | Modifier.toString(f.getModifiers()) | final service class can't be proxied. |
| Annotation | A label on a class, field or method. Visible at runtime only with @Retention(RUNTIME). | m.getAnnotation(PreAuthorize.class) | @PreAuthorize, @Entity, @PostMapping |
| Generic Type | Generics are erased at runtime, except in declarations (fields, method signatures, superclasses). | f.getGenericType() | Spring Data reads JpaRepository<Payment,String> to know the entity type. |
| Access check | Reflection obeys private unless you call setAccessible(true). | f.setAccessible(true) | Hibernate sets private fields of Payment this way. |
| Module | Java 9+ wall. Packages not opens-ed refuse setAccessible. | --add-opens | JDK internals are locked on Java 17. |
| Dynamic Proxy | A class generated at runtime that intercepts calls. JDK Proxy for interfaces, CGLIB for classes. | Proxy.newProxyInstance(…) | PaymentRepository impl, @Transactional wrapper. |
| MethodHandle | Faster, typed modern alternative to Method.invoke. Since JDK 18, core reflection runs on it. | MethodHandles.lookup() | You rarely use it directly; good interview depth. |
| Call | Private? | Inherited? |
|---|---|---|
getMethods() / getFields() | No, public only | Yes |
getDeclaredMethods() / getDeclaredFields() | Yes, all | No, this class only |
| Exception | Means |
|---|---|
ClassNotFoundException | Name typo or class not on classpath. |
NoSuchMethodException / NoSuchFieldException | Wrong name or parameter types (double.class ≠ Double.class), or private member asked with getMethod. |
IllegalAccessException | Private member used without setAccessible. |
InaccessibleObjectException | Module refused setAccessible (Java 9+). |
InvocationTargetException | The called method itself threw. The real error is in getCause(). |
IllegalArgumentException | Wrong argument types or wrong target object. |
Class<?> c = Class.forName("org.example.Service.PaymentServiceImpl");
Object svc = c.getDeclaredConstructor(PaymentRepository.class).newInstance(repo);
for (Field f : c.getDeclaredFields()) System.out.println(f.getName());
for (Method m : c.getDeclaredMethods()) System.out.println(m);
Method m = c.getMethod("createPayment", PaymentRequest.class);
PreAuthorize rule = m.getAnnotation(PreAuthorize.class); // null if absent
Object out = m.invoke(svc, request); // runs the method
Field f = c.getDeclaredField("paymentRepository");
f.setAccessible(true); // breaks private
Object value = f.get(svc);
Reflection on its own is a set of read and call APIs. It becomes useful when a framework connects it to annotations and proxies.
| Framework | Reads | Uses | SecurePay spot |
|---|---|---|---|
| Spring Core | @Component, @Bean, constructors | Constructor.newInstance, Method.invoke on @Bean methods | Every service at startup |
| Spring MVC | @RestController, @PostMapping, @RequestBody | Method objects stored in a route table, invoked per request | Gateway and Payment controllers |
| Spring Security | @PreAuthorize | Proxy + interceptor that reads the annotation | SCOPE_payment.write checks on 8081 |
| Spring AOP | @Transactional, @Async, @Aspect | CGLIB proxies | createPayment transaction, async email, your MethodTraceAspect |
| Spring Data | Repository interface, generics, method names | JDK dynamic proxy | PaymentRepository.findByIdAndOwner |
| Hibernate | @Entity, @Id, field types | No-arg constructor, private field set | Payment row ↔ object |
| Jackson | Fields, getters, constructors | Field set / getter call | JSON ↔ PaymentRequest / PaymentResponse |
| Thymeleaf | Getters | Method.invoke through SpEL | ${payment.amount} in the email |
| JUnit | @Test | Method.invoke | Your tests |
| Actuator | The bean factory | Reflection reports as JSON | /actuator/beans, /mappings |
| Tool | Sees | When |
|---|---|---|
| Reflection | Structure: classes, members, annotations | Runtime, inside the app |
| AOP proxy | Each call: inputs, output, time | Runtime, per call |
| Debugger | Line by line, local variables | Paused, dev only |
| Bytecode (ASM, ByteBuddy, agents) | Code inside methods; can change it | Class load time |
| Tracing (Zipkin) | A request across services | Runtime, all environments |
Five procedures. Steps marked reflection are where java.lang.reflect is actually called.
org.example with ASM (bytecode reading, no class loading yet) and finds @Service, @RestController, @Repository.Constructor.newInstance(paymentRepository) builds PaymentServiceImpl.reflection@Transactional, @PreAuthorize, @Async.reflectionPaymentServiceImpl$$SpringCGLIB$$0 and registers that instead of your object.@PostMapping Method object in a route table.reflectionBearerTokenAuthenticationFilter validates the JWT and stores the Authentication. No reflection.POST /payments.InvocableHandlerMethod.doInvoke calls your controller with Method.invoke.reflection@PreAuthorize and evaluates it against the JWT scopes. Fail → AccessDeniedException → 403.reflectionfindByIdAndOwner runs SQL; a row comes back.@Id is on a field).reflectionthis.otherMethod(). this is the real object, so the proxy is skipped.@Transactional, @PreAuthorize and your trace on otherMethod do nothing. Fix: move it to another bean.The same idea, used differently in each environment. Everything here is runnable in SecurePay.
See structure and calls: ReflectionReport, MethodTraceAspect, debugger, Actuator.
Enforce rules: ArchUnit, ReflectionTestUtils, masking tests.
Guard and observe: startup guard, masking, audit, Zipkin.
Diagnose live: trace IDs, Actuator loggers, jcmd.
Five standalone programs with full code and real output are on the Run it tab. Start with PaymentReflectionDemo.java: it lists the blueprint, enforces a role, then breaks private.
java PaymentReflectionDemo.java
Put ReflectionReport.java in org/example/debug/ of Service B. Run with the dev profile.
spring.profiles.active=dev # IntelliJ: Run config → Active profiles
package org.example.debug;
import jakarta.persistence.EntityManagerFactory;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import org.springframework.util.ClassUtils;
import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Arrays;
import java.util.stream.Collectors;
/**
* Learning tool: prints the "blueprint" of Payment Service B at startup using reflection.
* Runs ONLY with the dev profile (spring.profiles.active=dev). Prints names, never values.
*/
@Component
@Profile("dev")
public class ReflectionReport implements ApplicationRunner {
private static final String BASE_PACKAGE = "org.example";
private final ConfigurableListableBeanFactory factory;
private final ObjectProvider<EntityManagerFactory> emf;
public ReflectionReport(ConfigurableListableBeanFactory factory,
ObjectProvider<EntityManagerFactory> emf) {
this.factory = factory;
this.emf = emf;
}
@Override
public void run(ApplicationArguments args) {
System.out.println("\n================ REFLECTION REPORT ================");
// 1. Beans: classes, annotations, fields, methods, who-injects-whom
for (String name : factory.getBeanDefinitionNames()) {
Object bean;
try { bean = factory.getBean(name); }
catch (Exception notCreatableHere) { continue; } // abstract or request-scoped beans
Class<?> type = realClass(bean);
if (type == null || type == ReflectionReport.class) continue;
System.out.println("\nBEAN " + name + " -> " + type.getSimpleName()
+ (AopUtils.isAopProxy(bean) ? " [wrapped in a proxy]" : ""));
System.out.println(" class annotations: " + annotations(type));
for (Field f : type.getDeclaredFields()) {
if (Modifier.isStatic(f.getModifiers())) continue;
System.out.println(" field " + Modifier.toString(f.getModifiers()) + " "
+ f.getType().getSimpleName() + " " + f.getName() + " " + annotations(f));
}
for (Method m : type.getDeclaredMethods()) {
if (m.isSynthetic()) continue; // skip compiler-generated lambdas
System.out.println(" method " + Modifier.toString(m.getModifiers()) + " "
+ m.getName() + "(" + params(m) + ") " + annotations(m));
}
String deps = Arrays.stream(factory.getDependenciesForBean(name))
.filter(d -> d.startsWith(BASE_PACKAGE) || isOurs(d))
.collect(Collectors.joining(", "));
if (!deps.isEmpty()) System.out.println(" injected with: " + deps);
}
// 2. JPA entities are not beans, so ask Hibernate's metamodel for them
EntityManagerFactory factoryJpa = emf.getIfAvailable();
if (factoryJpa != null) {
factoryJpa.getMetamodel().getEntities().forEach(e -> {
Class<?> type = e.getJavaType();
if (!type.getName().startsWith(BASE_PACKAGE)) return;
System.out.println("\nENTITY " + type.getSimpleName() + " " + annotations(type));
for (Field f : type.getDeclaredFields())
System.out.println(" column " + f.getType().getSimpleName() + " "
+ f.getName() + " " + annotations(f));
});
}
System.out.println("\n===================================================\n");
}
private boolean isOurs(String beanName) {
try { return factory.containsBean(beanName) && realClass(factory.getBean(beanName)) != null; }
catch (Exception e) { return false; }
}
/** Unwraps proxies; returns null if the bean is not one of our own classes. */
private Class<?> realClass(Object bean) {
Class<?> target = AopUtils.getTargetClass(bean);
if (target.getName().startsWith(BASE_PACKAGE)) return ClassUtils.getUserClass(target);
// Spring Data repositories: the class is Spring's, but the interface is ours
for (Class<?> i : ClassUtils.getAllInterfacesForClass(bean.getClass()))
if (i.getName().startsWith(BASE_PACKAGE)) return i;
return null;
}
private String annotations(AnnotatedElement el) {
return Arrays.stream(el.getAnnotations())
.map(a -> "@" + a.annotationType().getSimpleName() + valueOf(a))
.collect(Collectors.joining(" "));
}
/** Reads an annotation's value() attribute using reflection, e.g. @PreAuthorize("..."). */
private String valueOf(java.lang.annotation.Annotation a) {
try {
Object v = a.annotationType().getMethod("value").invoke(a);
if (v instanceof String s && !s.isEmpty()) return "(\"" + s + "\")";
if (v instanceof String[] arr && arr.length > 0) return Arrays.toString(arr);
} catch (ReflectiveOperationException ignored) { }
return "";
}
private String params(Method m) {
return Arrays.stream(m.getParameterTypes()).map(Class::getSimpleName)
.collect(Collectors.joining(", "));
}
}
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
package org.example.debug;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
/**
* Learning tool: logs every call into our own beans while the app runs:
* which method, its inputs, its output (or exception) and how long it took.
* Needs spring-boot-starter-aop. Runs ONLY with the dev profile.
*/
@Aspect
@Component
@Profile("dev")
public class MethodTraceAspect {
private static final Logger log = LoggerFactory.getLogger(MethodTraceAspect.class);
@Around("execution(* org.example..*.*(..))"
+ " && !within(org.example.debug..*)"
+ " && !@within(org.springframework.context.annotation.Configuration)")
public Object trace(ProceedingJoinPoint jp) throws Throwable {
MethodSignature sig = (MethodSignature) jp.getSignature(); // reflection data about the method
String method = sig.getDeclaringType().getSimpleName() + "." + sig.getName();
log.info("-> {} inputs: {}", method, inputs(sig.getParameterNames(), jp.getArgs()));
long start = System.nanoTime();
try {
Object result = jp.proceed(); // the real method runs here
log.info("<- {} output: {} ({} ms)", method, safe(result), ms(start));
return result;
} catch (Throwable t) {
log.info("x {} threw {}: {} ({} ms)", method,
t.getClass().getSimpleName(), safe(t.getMessage()), ms(start));
throw t;
}
}
private String inputs(String[] names, Object[] values) {
StringBuilder sb = new StringBuilder("{");
for (int i = 0; i < values.length; i++) {
String name = (names != null && i < names.length) ? names[i] : "arg" + i;
sb.append(i == 0 ? "" : ", ").append(name).append("=").append(safe(values[i]));
}
return sb.append("}").toString();
}
/** Masks long numbers (account/card numbers) except the last 4 digits, and keeps logs short. */
private String safe(Object value) {
if (value == null) return "null";
String s = String.valueOf(value).replaceAll("\\d(?=\\d{4})", "*");
return s.length() > 200 ? s.substring(0, 200) + "..." : s;
}
private long ms(long start) {
return (System.nanoTime() - start) / 1_000_000;
}
}
Then send a payment from Postman and watch the console for ->, <- and x lines.
| Class.method (Ctrl+N in IntelliJ) | What you'll see |
|---|---|
BearerTokenAuthenticationFilter.doFilterInternal | The raw JWT arriving |
AbstractJackson2HttpMessageConverter.read | JSON becoming PaymentRequest |
InvocableHandlerMethod.doInvoke | Spring calling your controller with Method.invoke. Step into it. |
AuthorizationManagerBeforeMethodInterceptor.invoke | @PreAuthorize being evaluated |
MethodTraceAspect.trace | Your proxy layer; check the frames list for $$SpringCGLIB$$ |
While paused, use Evaluate Expression: method.getAnnotations(), getBean().getClass().
management.endpoints.web.exposure.include=beans,mappings,conditions,loggers
# dev only. In prod expose health only, or protect with ADMIN role.
// test dependency: com.tngtech.archunit:archunit-junit5:1.3.0
@AnalyzeClasses(packages = "org.example")
class SecurityRulesTest {
@ArchTest
static final ArchRule endpointsSecured = methods()
.that().areDeclaredInClassesThat().areAnnotatedWith(RestController.class)
.and().arePublic()
.should().beAnnotatedWith(PreAuthorize.class);
}
ReflectionTestUtils.setField(service, "maxAmount", new BigDecimal("10000"));
@Bean
ApplicationRunner securityGuard(RequestMappingHandlerMapping mapping) {
return args -> mapping.getHandlerMethods().forEach((route, handler) -> {
if (handler.getBeanType().getName().startsWith("org.example")
&& !handler.hasMethodAnnotation(PreAuthorize.class))
throw new IllegalStateException("Unsecured endpoint: " + route);
});
}
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD)
public @interface Sensitive {}
public final class Masker {
private static final Map<Class<?>, List<Field>> CACHE = new ConcurrentHashMap<>();
public static String describe(Object o) {
List<Field> fields = CACHE.computeIfAbsent(o.getClass(), c ->
Arrays.stream(c.getDeclaredFields())
.filter(f -> !Modifier.isStatic(f.getModifiers()))
.peek(f -> f.setAccessible(true)).toList()); // reflection once per class
StringJoiner out = new StringJoiner(", ", o.getClass().getSimpleName() + "[", "]");
for (Field f : fields) {
try {
Object v = f.get(o);
String s = f.isAnnotationPresent(Sensitive.class) && v != null
? "****" + String.valueOf(v).substring(Math.max(0, String.valueOf(v).length() - 4))
: String.valueOf(v);
out.add(f.getName() + "=" + s);
} catch (IllegalAccessException e) { out.add(f.getName() + "=?"); }
}
return out.toString();
}
}
// use: log.info("created {}", Masker.describe(payment));
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD)
public @interface Audited { String value(); }
@Aspect @Component
public class AuditAspect {
@AfterReturning("@annotation(audited)")
public void audit(JoinPoint jp, Audited audited) {
String user = SecurityContextHolder.getContext().getAuthentication().getName();
log.info("AUDIT action={} user={} method={}", audited.value(), user,
jp.getSignature().getName()); // real system: write to an audit table
}
}
// @Audited("PAYMENT_CREATED") on createPayment
<!-- each service -->
spring-boot-starter-actuator
io.micrometer:micrometer-tracing-bridge-brave
io.zipkin.reporter2:zipkin-reporter-brave
management.tracing.sampling.probability=1.0 # 100% in dev; 0.1 in prod
docker run -d -p 9411:9411 openzipkin/zipkin
# no Docker: curl -sSL https://zipkin.io/quickstart.sh | bash -s && java -jar zipkin.jar
# open http://localhost:9411
WebClient.Builder, or the trace header is not forwarded to 8081. For @Async email, register a ContextPropagatingTaskDecorator bean, or the email work starts a new trace.| Need | Command |
|---|---|
| Follow one bad request | Copy traceId from the log line, search it in Zipkin |
| Turn on debug without restart | POST /actuator/loggers/org.example {"configuredLevel":"DEBUG"} |
| Which classes loaded | java -Xlog:class+load=info -jar app.jar | grep org.example |
| Object counts per class | jcmd <pid> GC.class_histogram | grep org.example |
| Metaspace (proxy class leaks) | jcmd <pid> VM.metaspace |
| Stuck threads | jcmd <pid> Thread.print |
| 60 s flight recording | jcmd <pid> JFR.start duration=60s filename=rec.jfr |
Reflection moves errors from compile time to runtime. Most failures below are loud; the dangerous ones are silent.
| Symptom | Cause | Fix | Prevent |
|---|---|---|---|
NoSuchMethodException | Double.class instead of double.class, or private method via getMethod | Exact types; getDeclaredMethod | Avoid string-based lookups in business code |
InvocationTargetException with vague message | Target method threw | Log e.getCause() | Always unwrap in your own reflective code |
InaccessibleObjectException | setAccessible on a JDK or module-protected class | Don't; or --add-opens as last resort | Upgrade libraries instead of opening modules |
| Hibernate: no default constructor for entity | Removed no-arg constructor | Add protected Payment() {} | Entity unit test that loads a row |
| Jackson: cannot construct instance | DTO has no default constructor or creator | No-arg constructor, or a record | Controller slice test with JSON |
| Could not generate CGLIB subclass | Service class or method is final | Remove final | Code review rule for Spring beans |
| Silent: @PreAuthorize ignored | Self-invocation, or private method | Move to another bean; make public | ArchUnit + startup guard + 403 tests |
| Silent: annotation returns null | Retention not RUNTIME, or reading the proxy class | RUNTIME; AopUtils.getTargetClass / AnnotationUtils.findAnnotation | Unit test that reads the annotation |
| Silent: extra JSON fields bound | Mass assignment: entity used as request body | DTOs; owner from JWT | Never expose entities in controllers |
| Silent: secrets in logs | Logging args or toString of sensitive objects | @Sensitive masking; dev-only trace | Log review; secret scanning |
Parameter names are arg0 | Compiled without -parameters | Enable compiler flag | Use Boot's parent POM |
| Slow hot path | Reflection lookups per call, no cache | Cache Method/Field; or MethodHandle | Do lookups at startup only |
OutOfMemoryError: Metaspace | Generating proxy classes repeatedly, or classloader leak on redeploy | Find generator with VM.metaspace, fix; set -XX:MaxMetaspaceSize | Metaspace metric alert |
| Native image: method not found | GraalVM removes what it can't see is used | RuntimeHints / @RegisterReflectionForBinding | Native test run in CI |
| Remote code execution risk | Class.forName(userInput) or unsafe deserialization | Allow-list of classes | Never let input choose classes or methods |
| Report | First check | Likely reflection cause |
|---|---|---|
| Customer sees another customer's payment | Is the owner check reached? Trace + debug log | Owner taken from body (mass assignment) or check skipped by self-invocation |
| Payments slow since release | Zipkin: which span grew | New aspect doing reflection lookups per call |
| Service won't start after upgrade | First Caused by in the log | Final class, missing constructor, or module access |
| Card number appeared in logs | Search logs for the pattern; rotate if needed | Trace aspect enabled outside dev, or field missing @Sensitive |
Say them out loud. Each is about 30 seconds and tied to SecurePay.
@PreAuthorize checks, JPA mapping and JSON binding. I used it deliberately in two places: a dev-only report that prints the app's blueprint, and production guardrails, a startup check that refuses to boot if a payment endpoint lacks @PreAuthorize, plus annotation-based masking of account numbers. Because Jackson binds fields by reflection, I take the payment owner from the JWT, never from the request body."The ability of Java code to inspect and use classes, fields, methods, constructors and annotations at runtime, through java.lang.Class and java.lang.reflect. It reads the metadata the JVM keeps for every loaded class.
Creating beans through constructors, calling @Bean methods, building the MVC route table from @RequestMapping, reading @PreAuthorize and @Transactional to build proxies, and invoking controller methods. Hibernate and Jackson use it to fill fields.
Errors move to runtime, it bypasses encapsulation, it's harder to refactor because names are strings, and uncached lookups are slow. That's why I keep it in frameworks and cache lookups at startup.
getMethods returns public methods including inherited ones. getDeclaredMethods returns every method declared in that class, any visibility, but not inherited ones.
getDeclaredField, setAccessible(true), then get or set. It shows that private is not a security boundary. Secrets belong in a vault, and on Java 9+ the module system can block it.
The wrapper Method.invoke throws when the called method itself fails. The real exception is getCause(). Logging only the wrapper hides the root cause.
Spring adds that behaviour through a proxy around the bean. A call from inside the same object uses this, which is the target, so it never passes through the proxy. I move the method to another bean.
JDK proxy implements interfaces at runtime; Spring Data uses it for repositories. CGLIB generates a subclass, so it works on classes but not final classes or final methods. Spring Boot defaults to CGLIB for AOP.
Hibernate creates the object first through reflection, then fills fields from the row. It can't guess constructor arguments. It also subclasses entities for lazy loading, so they shouldn't be final.
Annotations are passive labels. Reflection is how code reads them at runtime, and only if they have RetentionPolicy.RUNTIME.
The lookup is the expensive part. Frameworks look up once at startup and cache the Method objects. Since JDK 18 invoke runs on method handles, which the JIT optimises well. In a hot path I cache or use MethodHandle.
Three ways: @PreAuthorize is enforced by reading the annotation; mass assignment is a risk because Jackson binds by reflection, so the owner comes from the JWT; and I never let user input choose a class or method name.
Reflection is the program reading its own structure at runtime. Debugging is a developer pausing it to inspect state. Tracing records a request's path across services in every environment. I use all three, for different questions.
Five single-file programs. Each runs on Java 17 with no Maven, no Spring and no compile step: save the code with the file name shown, open a terminal in that folder, run the command. The output below is from a real run; timings will differ.
mkdir %USERPROFILE%\reflection-lab && cd %USERPROFILE%\reflection-lab. Copy each program into a new file there (Notepad → Save as → Name.java, "All files"). Check Java with java -version.Reads a payment class's blueprint, enforces a role from an annotation the way Spring does, then breaks private.
java PaymentReflectionDemo.java
java PaymentReflectionDemo.java ADMIN
import java.lang.annotation.*;
import java.lang.reflect.*;
// Run: java PaymentReflectionDemo.java (Java 11+, no compile step)
public class PaymentReflectionDemo {
public static void main(String[] args) throws Exception {
Class<?> c = Class.forName("PaymentService");
System.out.println("=== 1. BLUEPRINT ===");
System.out.println("Class : " + c.getName());
System.out.println("Is @Service: " + c.isAnnotationPresent(Service.class));
System.out.println("Fields:");
for (Field f : c.getDeclaredFields())
System.out.println(" " + Modifier.toString(f.getModifiers()) + " " + f.getType().getSimpleName()
+ " " + f.getName() + (f.isAnnotationPresent(Sensitive.class) ? " <-- @Sensitive" : ""));
System.out.println("Methods:");
for (Method m : c.getDeclaredMethods()) {
Secured s = m.getAnnotation(Secured.class);
System.out.println(" " + Modifier.toString(m.getModifiers()) + " " + m.getName()
+ params(m) + (s != null ? " requires " + s.role() : " (no rule!)"));
}
System.out.println("\n=== 2. WHAT SPRING DOES ===");
Object service = c.getDeclaredConstructor().newInstance();
String role = args.length > 0 ? args[0] : "USER"; // try: java PaymentReflectionDemo.java ADMIN
for (String name : new String[]{"pay", "refund"}) {
Method m = c.getMethod(name, double.class);
String needed = m.getAnnotation(Secured.class).role();
System.out.println(needed.equals(role)
? " ALLOWED " + name + " -> " + m.invoke(service, 100.0)
: " DENIED " + name + " (needs " + needed + ", user is " + role + ")");
}
System.out.println("\n=== 3. THE DANGER ===");
Field secret = c.getDeclaredField("apiSecretKey");
secret.setAccessible(true);
System.out.println(" Leaked secret : " + secret.get(service));
Method fraud = c.getDeclaredMethod("fraudCheck", double.class);
fraud.setAccessible(true);
System.out.println(" fraudCheck(50000) : " + fraud.invoke(service, 50000.0));
Field balance = c.getDeclaredField("balance");
balance.setAccessible(true);
balance.set(service, 1_000_000.0);
System.out.println(" Balance tampered to: " + balance.get(service));
}
static String params(Method m) {
StringBuilder sb = new StringBuilder("(");
for (Class<?> p : m.getParameterTypes()) sb.append(sb.length() > 1 ? ", " : "").append(p.getSimpleName());
return sb.append(")").toString();
}
}
@Retention(RetentionPolicy.RUNTIME) @interface Service {}
@Retention(RetentionPolicy.RUNTIME) @interface Secured { String role(); }
@Retention(RetentionPolicy.RUNTIME) @interface Sensitive {}
@Service
class PaymentService {
@Sensitive private String apiSecretKey = "sk_live_9f8a7b6c";
@Sensitive private String cardNumber = "4111111111111111";
private double balance = 500.00;
@Secured(role = "ADMIN") public String refund(double amount) { balance += amount; return "Refunded " + amount; }
@Secured(role = "USER") public String pay(double amount) { balance -= amount; return "Paid " + amount; }
private boolean fraudCheck(double amount) { return amount > 10_000; }
}
Output
=== 1. BLUEPRINT ===
Class : PaymentService
Is @Service: true
Fields:
private String apiSecretKey <-- @Sensitive
private String cardNumber <-- @Sensitive
private double balance
Methods:
public pay(double) requires USER
public refund(double) requires ADMIN
private fraudCheck(double) (no rule!)
=== 2. WHAT SPRING DOES ===
ALLOWED pay -> Paid 100.0
DENIED refund (needs ADMIN, user is USER)
=== 3. THE DANGER ===
Leaked secret : sk_live_9f8a7b6c
fraudCheck(50000) : true
Balance tampered to: 1000000.0
Six drills: three ways to get a Class, public vs declared, reading @PreAuthorize, unwrapping InvocationTargetException, generics in declarations, constructing with arguments. Run it, then delete one drill's body and rewrite it from memory.
java ReflectionDrills.java
import java.lang.annotation.*;
import java.lang.reflect.*;
import java.util.*;
// Run: java ReflectionDrills.java
// Each drill prints a result. Then delete the body of a drill and rewrite it from memory.
public class ReflectionDrills {
public static void main(String[] args) throws Exception {
drill1_threeWaysToGetAClass();
drill2_publicVsDeclared();
drill3_readAnnotationValue();
drill4_unwrapInvocationTargetException();
drill5_genericsSurviveInDeclarations();
drill6_constructWithArguments();
}
// 1. Payment.class, obj.getClass(), Class.forName(...) give the SAME object
static void drill1_threeWaysToGetAClass() throws Exception {
Class<?> a = Payment.class;
Class<?> b = new Payment("PAY-1", "ACC-778899", 250.0).getClass();
Class<?> c = Class.forName("Payment");
System.out.println("1) same Class object? " + (a == b && b == c));
}
// 2. getMethods = public + inherited; getDeclaredMethods = all visibilities, this class only
static void drill2_publicVsDeclared() {
Method[] pub = PaymentServiceImpl.class.getMethods();
Method[] dec = PaymentServiceImpl.class.getDeclaredMethods();
System.out.println("2) getMethods=" + pub.length + " (includes Object's toString, hashCode...)"
+ " getDeclaredMethods=" + dec.length + " " + names(dec));
}
// 3. Read @PreAuthorize("...") the way Spring Security does
static void drill3_readAnnotationValue() throws Exception {
Method m = PaymentServiceImpl.class.getMethod("createPayment", Payment.class);
PreAuthorize rule = m.getAnnotation(PreAuthorize.class);
System.out.println("3) createPayment rule = " + rule.value());
Object generic = rule.annotationType().getMethod("value").invoke(rule); // without knowing the type
System.out.println(" read generically = " + generic);
}
// 4. Method.invoke wraps the real exception
static void drill4_unwrapInvocationTargetException() throws Exception {
Method m = PaymentServiceImpl.class.getMethod("createPayment", Payment.class);
try {
m.invoke(new PaymentServiceImpl(), new Payment("PAY-2", "ACC-1", -5));
} catch (InvocationTargetException e) {
System.out.println("4) wrapper: " + e.getClass().getSimpleName() + " message=" + e.getMessage());
System.out.println(" real : " + e.getCause());
}
}
// 5. Generics are erased, except in declarations (how Spring Data knows the entity type)
static void drill5_genericsSurviveInDeclarations() {
Type t = PaymentRepository.class.getGenericInterfaces()[0];
System.out.println("5) PaymentRepository extends " + t.getTypeName());
}
// 6. Build an object through a constructor with arguments (what Spring does for injection)
static void drill6_constructWithArguments() throws Exception {
Constructor<Payment> ctor = Payment.class.getDeclaredConstructor(String.class, String.class, double.class);
Payment p = ctor.newInstance("PAY-3", "ACC-555", 99.0);
Field amount = Payment.class.getDeclaredField("amount");
amount.setAccessible(true);
System.out.println("6) built " + p + ", private amount read = " + amount.get(p));
}
static List<String> names(Method[] ms) {
List<String> l = new ArrayList<>(); for (Method m : ms) l.add(m.getName()); Collections.sort(l); return l;
}
}
@Retention(RetentionPolicy.RUNTIME) @interface PreAuthorize { String value(); }
class Payment {
private final String paymentId; private final String accountNumber; private final double amount;
Payment(String id, String acc, double amount) { this.paymentId = id; this.accountNumber = acc; this.amount = amount; }
double amount() { return amount; }
public String toString() { return "Payment[" + paymentId + "]"; }
}
interface Repository<T, ID> {}
interface PaymentRepository extends Repository<Payment, String> {}
class PaymentServiceImpl {
@PreAuthorize("hasAuthority('SCOPE_payment.write')")
public String createPayment(Payment p) {
if (p.amount() <= 0) throw new IllegalArgumentException("amount must be positive");
return "created " + p;
}
@PreAuthorize("hasAuthority('SCOPE_payment.read')")
public String getPayment(String id) { return "found " + id; }
private boolean fraudCheck(Payment p) { return p.amount() > 10_000; }
}
Output
1) same Class object? true
2) getMethods=11 (includes Object's toString, hashCode...) getDeclaredMethods=3 [createPayment, fraudCheck, getPayment]
3) createPayment rule = hasAuthority('SCOPE_payment.write')
read generically = hasAuthority('SCOPE_payment.write')
4) wrapper: InvocationTargetException message=null
real : java.lang.IllegalArgumentException: amount must be positive
5) PaymentRepository extends Repository<Payment, java.lang.String>
6) built Payment[PAY-3], private amount read = 99.0
A JDK dynamic proxy that does what Spring does for @PreAuthorize plus your trace aspect: logs inputs and outputs, returns 403 for a missing scope, and shows the self-invocation gap.
java ProxyDemo.java
import java.lang.annotation.*;
import java.lang.reflect.*;
import java.util.*;
// Run: java ProxyDemo.java
// Builds what Spring builds for @PreAuthorize + your MethodTraceAspect, with a plain JDK proxy.
public class ProxyDemo {
public static void main(String[] args) {
PaymentService real = new PaymentServiceImpl();
PaymentService user = secure(real, Set.of("SCOPE_payment.read"));
PaymentService admin = secure(real, Set.of("SCOPE_payment.read", "SCOPE_payment.write"));
System.out.println("proxy class: " + user.getClass().getName() + "\n");
System.out.println("--- user with read scope only");
call(() -> user.getPayment("PAY-1001"));
call(() -> user.createPayment("PAY-2002", 250.00));
System.out.println("\n--- admin with write scope");
call(() -> admin.createPayment("PAY-2002", 250.00));
System.out.println("\n--- self-invocation: createAndAudit calls this.getPayment() inside");
call(() -> admin.createAndAudit("PAY-3003", 10.00));
System.out.println(" ^ only ONE trace line: the inner getPayment never went through the proxy");
}
@SuppressWarnings("unchecked")
static <T> T secure(T target, Set<String> scopes) {
InvocationHandler h = (proxy, method, args) -> {
Method impl = target.getClass().getMethod(method.getName(), method.getParameterTypes());
String name = target.getClass().getSimpleName() + "." + method.getName();
System.out.println("-> " + name + " inputs=" + Arrays.toString(args)); // trace: inputs
PreAuthorize rule = impl.getAnnotation(PreAuthorize.class); // security: read annotation
if (rule != null && !scopes.contains(rule.value()))
throw new SecurityException("403 Forbidden: needs " + rule.value());
long t = System.nanoTime();
try {
Object out = method.invoke(target, args); // the real method
System.out.println("<- " + name + " output=" + out + " (" + (System.nanoTime() - t) / 1000 + " us)");
return out;
} catch (InvocationTargetException e) { throw e.getCause(); }
};
return (T) Proxy.newProxyInstance(target.getClass().getClassLoader(),
target.getClass().getInterfaces(), h);
}
static void call(java.util.function.Supplier<Object> s) {
try { s.get(); } catch (SecurityException e) { System.out.println("x " + e.getMessage()); }
}
}
@Retention(RetentionPolicy.RUNTIME) @interface PreAuthorize { String value(); }
interface PaymentService {
String getPayment(String id);
String createPayment(String id, double amount);
String createAndAudit(String id, double amount);
}
class PaymentServiceImpl implements PaymentService {
@PreAuthorize("SCOPE_payment.read")
public String getPayment(String id) { return "Payment[" + id + "]"; }
@PreAuthorize("SCOPE_payment.write")
public String createPayment(String id, double amount) { return "Created " + id + " for " + amount; }
@PreAuthorize("SCOPE_payment.write")
public String createAndAudit(String id, double amount) {
return "Created " + id + ", audit saw " + this.getPayment(id); // 'this' = real object, not proxy
}
}
Output
proxy class: $Proxy0
--- user with read scope only
-> PaymentServiceImpl.getPayment inputs=[PAY-1001]
<- PaymentServiceImpl.getPayment output=Payment[PAY-1001] (849 us)
-> PaymentServiceImpl.createPayment inputs=[PAY-2002, 250.0]
x 403 Forbidden: needs SCOPE_payment.write
--- admin with write scope
-> PaymentServiceImpl.createPayment inputs=[PAY-2002, 250.0]
<- PaymentServiceImpl.createPayment output=Created PAY-2002 for 250.0 (3793 us)
--- self-invocation: createAndAudit calls this.getPayment() inside
-> PaymentServiceImpl.createAndAudit inputs=[PAY-3003, 10.0]
<- PaymentServiceImpl.createAndAudit output=Created PAY-3003, audit saw Payment[PAY-3003] (206 us)
^ only ONE trace line: the inner getPayment never went through the proxy
@Sensitive fields are masked in logs. Reflection runs once per class and is cached.
java MaskerDemo.java
import java.lang.annotation.*;
import java.lang.reflect.*;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
// Run: java MaskerDemo.java
// Production pattern: mark fields @Sensitive, mask them in logs. Reflection runs once per class (cached).
public class MaskerDemo {
public static void main(String[] args) {
Payment p = new Payment("PAY-1001", "ACC-778899", "4111111111111111", 250.00, "nithin");
System.out.println("toString (unsafe): " + p.unsafeToString());
System.out.println("Masker (safe) : " + Masker.describe(p));
System.out.println("cached classes : " + Masker.CACHE.keySet());
}
}
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) @interface Sensitive {}
class Payment {
private final String paymentId;
@Sensitive private final String accountNumber;
@Sensitive private final String cardNumber;
private final double amount;
private final String owner;
Payment(String id, String acc, String card, double amount, String owner) {
this.paymentId = id; this.accountNumber = acc; this.cardNumber = card; this.amount = amount; this.owner = owner;
}
String unsafeToString() { return paymentId + " " + accountNumber + " " + cardNumber + " " + amount; }
}
final class Masker {
static final Map<Class<?>, List<Field>> CACHE = new ConcurrentHashMap<>();
static String describe(Object o) {
List<Field> fields = CACHE.computeIfAbsent(o.getClass(), c -> {
List<Field> list = new ArrayList<>();
for (Field f : c.getDeclaredFields())
if (!Modifier.isStatic(f.getModifiers())) { f.setAccessible(true); list.add(f); }
return list;
});
StringJoiner out = new StringJoiner(", ", o.getClass().getSimpleName() + "[", "]");
for (Field f : fields) {
try {
String v = String.valueOf(f.get(o));
if (f.isAnnotationPresent(Sensitive.class)) v = "****" + v.substring(Math.max(0, v.length() - 4));
out.add(f.getName() + "=" + v);
} catch (IllegalAccessException e) { out.add(f.getName() + "=?"); }
}
return out.toString();
}
}
Output
toString (unsafe): PAY-1001 ACC-778899 4111111111111111 250.0
Masker (safe) : Payment[paymentId=PAY-1001, accountNumber=****8899, cardNumber=****1111, amount=250.0, owner=nithin]
cached classes : [class Payment]
Triggers every reflection failure from Stage 5 that doesn't need Spring, including the silent one. Each line maps to a row in the fault table.
java BreakItLab.java
import java.lang.annotation.*;
import java.lang.reflect.*;
// Run: java BreakItLab.java
// Every reflection failure from Stage 5 that you can trigger without Spring. Read each error, then the fix.
public class BreakItLab {
public static void main(String[] args) {
run("ClassNotFoundException", () -> Class.forName("org.example.Paymnet"));
run("NoSuchMethodException", () -> Svc.class.getMethod("pay", Double.class)); // needs double.class
run("NoSuchMethodException", () -> Svc.class.getMethod("fraudCheck", double.class)); // private: use getDeclaredMethod
run("IllegalAccessException", () -> Svc.class.getDeclaredMethod("fraudCheck", double.class).invoke(new Svc(), 1.0));
run("IllegalArgumentException", () -> Svc.class.getMethod("pay", double.class).invoke(new Svc(), "100"));
run("InvocationTargetException",() -> Svc.class.getMethod("pay", double.class).invoke(new Svc(), -1.0));
run("InaccessibleObjectException", () -> { String.class.getDeclaredField("value").setAccessible(true); return null; });
run("NoSuchMethodException (no no-arg constructor, Hibernate's error)",
() -> NoDefaultCtor.class.getDeclaredConstructor().newInstance());
System.out.println("\nSILENT failure: annotation with CLASS retention");
System.out.println(" @Runtime present? " + Svc.class.isAnnotationPresent(RuntimeTag.class));
System.out.println(" @ClassOnly present? " + Svc.class.isAnnotationPresent(ClassOnlyTag.class) + " <- no error, just false");
}
interface Step { Object go() throws Exception; }
static void run(String expected, Step s) {
try { s.go(); System.out.println("no error?! expected " + expected); }
catch (InvocationTargetException e) {
System.out.println("[" + e.getClass().getSimpleName() + "] real cause -> " + e.getCause());
} catch (Exception e) {
System.out.println("[" + e.getClass().getSimpleName() + "] " + e.getMessage());
}
}
}
@Retention(RetentionPolicy.RUNTIME) @interface RuntimeTag {}
@Retention(RetentionPolicy.CLASS) @interface ClassOnlyTag {}
@RuntimeTag @ClassOnlyTag
class Svc {
public String pay(double amount) {
if (amount <= 0) throw new IllegalArgumentException("amount must be positive");
return "Paid " + amount;
}
private boolean fraudCheck(double amount) { return amount > 10_000; }
}
class NoDefaultCtor { NoDefaultCtor(String id) {} }
Output
[ClassNotFoundException] org.example.Paymnet
[NoSuchMethodException] Svc.pay(java.lang.Double)
[NoSuchMethodException] Svc.fraudCheck(double)
[IllegalAccessException] class BreakItLab cannot access a member of class Svc with modifiers "private"
[IllegalArgumentException] argument type mismatch
[InvocationTargetException] real cause -> java.lang.IllegalArgumentException: amount must be positive
[InaccessibleObjectException] Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module @515aebb0
[NoSuchMethodException] NoDefaultCtor.<init>()
SILENT failure: annotation with CLASS retention
@Runtime present? true
@ClassOnly present? false <- no error, just false
ReflectionReport.java and MethodTraceAspect.java need a running Spring app, so they go into Service B. Full code and steps are in Stage 4 · Dev 2 and Dev 3.
Practical only. Each block ends with a check you can see on screen. Ticks are saved in this browser.