How frameworks use annotations and reflection
How frameworks turn annotations into behaviour: classpath scanning, a mini DI container built step by step, JDK dynamic proxies versus bytecode subclasses, why @Transactional self-invocation fails, JPA entity rules, JUnit discovery, compile-time alternatives and startup cost.
On this page
- Picture it first: a hotel concierge with your booking file
- Under the hood: build a mini DI container
- JDK dynamic proxies versus bytecode subclasses
- Why @Transactional self-invocation fails
- Real world: JPA entities and JUnit discovery
- Advanced: compile-time alternatives and startup cost
- How to say it in the interview
🏗️ How do frameworks use annotations and reflection?
A framework reads your annotations to learn what you want (@Component, @Transactional, @Entity, @Test) and uses reflection and generated classes to make it happen: it finds your classes, calls your constructors, sets your private fields and wraps your objects in proxies. A test runner that finds 1,200 @Test methods and invokes each one never had a single direct call to them compiled in.
For this lesson, we'll build the machinery ourselves: scan a package, wire objects with a tiny DI container, then add transactions with a proxy. Once you've written those 100 lines, the famous production surprises (self-invocation, final methods, JPA's no-arg constructor) stop being trivia and become obvious. This is the lesson interviewers are really reaching for when they ask "how does @Transactional work?".
Below the line (out of scope)
- The reflection and annotation APIs themselves (A guide to Java reflection, Built-in and custom annotations)
- Any one framework's configuration in depth;
@Transactionalappears only as the standard proxy example - Writing a bytecode library or a Java agent
The 30-second answer
Frameworks scan the classpath for annotated classes, often reading bytecode without loading it, then use reflection to call constructors and inject dependencies. To add behaviour like transactions, they hand you a proxy instead of your object: a JDK dynamic proxy if they only need an interface, or a generated subclass (CGLIB, ByteBuddy) if they need the class. The proxy runs the extra logic and delegates to your real object, so a call from inside the object through this skips the proxy, which is why self-invoked @Transactional methods get no transaction. JPA needs a no-arg constructor and non-final classes because Hibernate instantiates entities reflectively and subclasses them for lazy loading. All of this costs startup time, so Dagger, Micronaut and GraalVM native images move the work to compile time.
What you’ll learn
- Build a mini dependency injection container with classpath scanning, constructor injection and cycle detection
- Explain JDK dynamic proxies versus bytecode-generated subclasses (CGLIB, ByteBuddy) and the limits of each
- Explain why a
@Transactionalmethod called throughthisruns without a transaction, and list three fixes - Justify the JPA entity rules (no-arg constructor, non-final class and methods) from how Hibernate uses reflection and proxies
- Describe how JUnit finds and runs
@Testmethods - Compare runtime reflection with compile-time alternatives (Lombok, MapStruct, Dagger, Micronaut, GraalVM native image) and their startup cost
Interview radar
Asked very often: How does @Transactional work, and why doesn't it apply when a method calls another method in the same class?
Senior rounds probe: JDK dynamic proxy versus CGLIB, why final methods break proxies, and why entities can't be final or records.
Production angle: Data saved without a rollback because the transactional method was called through this, or an NPE from a final method on a proxied bean.
01Picture it first: a hotel concierge with your booking file
Think of a framework as a hotel concierge holding your booking file. The annotations are the notes in the file ("needs a car at 9", "wake-up call", "vegetarian"). The concierge never changes who you are; they read the notes and arrange things around you. And when the restaurant calls your room, the call goes through the front desk, which is the proxy, so the desk can apply the "vegetarian" note before connecting.
The machinery always runs in the same order, which this diagram walks through.
Six numbered steps: scan the classpath, filter by annotation, build a dependency graph, call constructors reflectively, wrap beans in proxies, and hand out ready objects
Keep this picture. Every question in this lesson is about one of those six steps.
02Under the hood: build a mini DI container
Step 1: classpath scanning
There's no JDK API that says "give me every class in package com.shop". Frameworks ask the class loader for the package as a resource and walk what comes back: directories in development, JAR entries in production.
Try it yourself first
Before reading on, think about why the scanner below passes false to Class.forName.
static List<Class<?>> scan(String basePackage) throws IOException, URISyntaxException {
String path = basePackage.replace('.', '/');
ClassLoader loader = Thread.currentThread().getContextClassLoader();
List<Class<?>> found = new ArrayList<>();
for (URL url : Collections.list(loader.getResources(path))) {
Path root = Paths.get(url.toURI()); // directories only; JARs need a JarFile walk
try (Stream<Path> files = Files.walk(root)) {
for (Path file : files.filter(f -> f.toString().endsWith(".class")).toList()) {
String name = basePackage + "." + root.relativize(file).toString()
.replace(FileSystems.getDefault().getSeparator(), ".").replace(".class", "");
try {
found.add(Class.forName(name, false, loader)); // load, don't initialise
} catch (ClassNotFoundException e) {
throw new IllegalStateException(e);
}
}
}
}
return found;
}
Notice:
falsemeans load without initialising, so scanning a class never runs its static blocks.- Real frameworks go further and don't load classes at all during scanning. They read the class file bytes with a bytecode reader (ASM, or the JDK's
java.lang.classfileAPI finalised in Java 24) and only load the classes that carry the right annotation. Loading every class in a large application would cost seconds and Metaspace. - Build-time indexes skip the scan entirely: Quarkus builds a Jandex index of annotations at build time, and Micronaut generates the bean list during compilation.
Step 2: the container
Now the container: find @Component classes, map interfaces to implementations, and build each one by resolving its constructor parameters recursively.
Try it yourself first
Before reading on, sketch how you'd detect a cycle like OrderService needing PaymentService needing OrderService.
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE)
@interface Component { }
final class MiniContainer {
private final Map<Class<?>, Class<?>> implFor = new HashMap<>();
private final Map<Class<?>, Object> singletons = new HashMap<>();
private final Set<Class<?>> inCreation = new HashSet<>();
MiniContainer(List<Class<?>> candidates) {
for (Class<?> c : candidates) {
if (!c.isAnnotationPresent(Component.class)) continue;
implFor.put(c, c);
for (Class<?> iface : c.getInterfaces()) implFor.put(iface, c);
}
}
<T> T get(Class<T> type) {
Class<?> impl = implFor.get(type);
if (impl == null) throw new IllegalStateException("No @Component for " + type.getName());
Object bean = singletons.get(impl);
if (bean == null) {
bean = create(impl);
singletons.put(impl, bean);
}
return type.cast(bean);
}
private Object create(Class<?> impl) {
if (!inCreation.add(impl)) throw new IllegalStateException("Cycle at " + impl.getSimpleName());
try {
Constructor<?>[] ctors = impl.getDeclaredConstructors();
if (ctors.length != 1) throw new IllegalStateException(impl + " needs exactly one constructor");
Constructor<?> ctor = ctors[0];
Object[] deps = Arrays.stream(ctor.getParameterTypes()).map(this::get).toArray();
ctor.setAccessible(true);
return ctor.newInstance(deps);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("Cannot create " + impl.getName(), e);
} finally {
inCreation.remove(impl);
}
}
}
Premium lesson
Keep reading with the Java Pass
The rest of this lesson is part of Java One-Stop Interview Prep: in-depth explanations, diagrams, model answers and how to say them in the interview.
- JDK dynamic proxies versus bytecode subclasses
- Why @Transactional self-invocation fails
- Real world: JPA entities and JUnit discovery
- Advanced: compile-time alternatives and startup cost
- How to say it in the interview
From ₹199 · one-time payment · No auto-renewal
Further reading (9)
- java.lang.reflect.Proxy (Java SE 21 javadoc)
- Spring Framework reference: Understanding AOP proxies
- Jakarta Persistence 3.1 specification, §2.1 The Entity Class
- JUnit 5 User Guide: Test classes and methods
- Byte Buddy tutorial
- Dagger documentation
- GraalVM: Reachability metadata
- JEP 483: Ahead-of-Time Class Loading and Linking
- Baeldung: Dynamic Proxies in Java