AlgoMaster Logo

Reflection Use Cases

Medium Priority14 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

Reflection is not a curiosity tucked into java.lang.reflect. It is the engine inside almost every Java framework you have used. Spring, Hibernate, Jackson, JUnit, JPA, Lombok-at-runtime, and most testing libraries all use the same handful of reflection calls. This lesson shows how each of these tools uses reflection in practice, so the next time you write @Component or @Test, you can picture exactly what is happening on the other side. We will not re-teach the API. We will apply it.

The running example throughout is a small e-commerce domain: Product, Order, Cart, Customer. Each use case takes that domain and shows how reflection turns it into something useful.

Dependency Injection (Spring Core)

Spring's job is simple to describe: find classes you have marked as components, build instances of them, and inject the dependencies they need. The hard part is doing it without you wiring anything by hand. Reflection is what closes that gap.

The pattern Spring runs at startup is:

  1. Scan the classpath for classes annotated with @Component, @Service, @Repository, or @Controller.
  2. For each one, use Class.getDeclaredConstructor(...) and Constructor.newInstance(...) to build an instance.
  3. Walk the instance's declared fields. For each field annotated with @Autowired, find a bean of the matching type and set it with Field.set(instance, value).
  4. Return the instance to the application context.

A small DI container that wires OrderService to a ProductRepository. We define our own annotations so the example runs without Spring on the classpath.

That is the entire idea behind a DI container in 50 lines. Real Spring does far more (scopes, proxies, lazy beans, qualifier matching, configuration classes), but the core loop is identical: read annotations, instantiate, inject by type. Constructor injection works the same way, except Spring looks at Constructor.getParameterTypes() instead of Field.getType() and passes the resolved beans as arguments to newInstance(...).

Spring caches reflective lookups (constructors, fields, target beans) at startup. Your request path doesn't pay reflection cost per call; it pays it once per bean during context initialization.

JSON Serialization (Jackson, Gson)

When you call objectMapper.writeValueAsString(order), Jackson doesn't know your Order class at compile time. It learns it at runtime, the same way the DI container learned about OrderService: by reflecting over the class.

The pattern Jackson follows is:

  1. Take the object's class and call getDeclaredFields() (or getMethods() for getter-based serialization).
  2. For each field, decide on a JSON key. If the field is annotated with @JsonProperty("name"), use that name. Otherwise use the field name.
  3. Read the value with Field.get(instance) (after setAccessible(true) for private fields) or call the getter via Method.invoke.
  4. Recurse for nested objects, format primitives directly, and quote strings.

A tiny "describe to JSON-like string" utility for Product. It supports custom names through an annotation, mirroring how Jackson works.

The annotation @JsonProperty is the user's way of overriding the default naming. The reflection loop is identical for any class you point it at. That generality is exactly why a single Jackson dependency can serialize your Order, Cart, and Customer without any per-class glue code.

Deserialization is the reverse direction. Jackson parses the JSON into a Map-like tree, looks up the target class, calls Class.getDeclaredConstructor().newInstance() (or matches a JSON-creator constructor), then assigns each field by name. For records, it uses the canonical constructor instead, calling Constructor.newInstance(name, price, stock) with the parsed values in declared order. Records are particularly clean here because the field order and types are guaranteed by the compiler.

Reflective Field.get is slower than a direct method call, but Jackson caches the per-class metadata (fields, accessors, name mappings) in a JavaType object so the lookup happens once per type, not per object.

ORM (Hibernate, JPA)

An ORM has to do two things that look like magic when you first see them: turn a Java class into a SQL table, and turn a ResultSet row back into a fully populated Java object. Both are reflection.

The mapping side reads @Entity, @Table, @Id, and @Column annotations from the class once at startup and builds an internal model:

  1. The class itself, plus @Table(name = ...), becomes a table mapping.
  2. Each field annotated with @Column (or each field if @Entity opts in by default) becomes a column.
  3. The field marked @Id becomes the primary key.

The query side is even more interesting. When you call entityManager.find(Order.class, 42), Hibernate runs SELECT ... WHERE id = 42, gets back a ResultSet, and needs to put each column into the right field of a new Order. That is a row mapper. A tiny one:

A real Hibernate EntityPersister does more: it handles @JoinColumn to follow foreign keys, lazy-loads collections through proxies, tracks dirty fields for write-back, and generates SQL on the fly. The skeleton above is the kernel inside that machinery.

Hibernate uses bytecode enhancement and proxy classes (via ByteBuddy or Javassist) for hot paths like dirty checking and lazy loading. Reflection is mostly limited to startup and one-shot field reads, where its overhead is negligible.

Test Frameworks (JUnit 5)

JUnit's job is to find your test methods and call them in the right order. The discovery is reflection. The orchestration is plain Java.

A 50-line mini test runner that supports @Test, @BeforeEach, and @AfterEach, with try/catch to record pass/fail per method:

The flow is unsurprising once you see it. Walk the methods, find the ones marked @Test, run lifecycle hooks around each one, catch failures, report. Real JUnit 5 adds parameter resolution (injecting TestInfo, mocks, temp directories), parallel execution, dynamic tests, parameterized tests, and a discovery API that distinguishes test classes from helper classes, but the bones are identical.

The diagram shows the lifecycle a JUnit-style runner repeats per test method. Discovery happens once. The per-test loop runs setup, the test body, and teardown, then moves to the next discovered method.

Plugin Systems and Service Loading

Reflection lets a program load classes whose names are not known when the program is compiled. That single ability is the foundation of plugin systems, application servers, database drivers, and IDE extensions.

The simplest version is Class.forName("com.example.MyPlugin") followed by getDeclaredConstructor().newInstance(). You read a plugin name from a config file at startup, load it, and call into it through a known interface. A one-shot loader for a PaymentMethod plugin in a checkout system:

pluginName is a String. It could come from a config file, an environment variable, or a database row. The compiled PluginLoader.class has no compile-time reference to StripePayment; the JVM resolves it at runtime.

Java has a standard mechanism for this called the ServiceLoader, in place since Java 6. You declare your service interface, drop a text file under META-INF/services/ listing implementing classes, and call ServiceLoader.load(PaymentMethod.class). The ServiceLoader reads the file, loads each class via reflection, and returns instances. It is a cleaner alternative to manual Class.forName because the registration lives next to the implementation (or in the module declaration with provides ... with ... for module-aware code), not in scattered config files.

Frameworks pick between manual reflection and ServiceLoader based on how dynamic the plugin set needs to be. ServiceLoader is fixed at classpath construction time. Custom reflection lets you load plugins added to a directory after the JVM has started, which is what server containers do.

Class.forName triggers class loading, verification, and static initializers. The first call to a plugin pays a millisecond-level cost on a modern JVM. Subsequent uses of the loaded class are fast.

Debuggers, Profilers, and IDE Inspectors

When you pause a Java program in IntelliJ and the Variables panel shows you that cart.items is a list of three Product objects, no special debugger API populated that view. The debugger asked the running JVM, "what fields does this Cart have?" using reflection (via the JDI, the Java Debugger Interface, which is reflection's cousin), then walked each field and recursed.

Profilers like JFR, async-profiler, and YourKit do the same thing for sampling and for displaying object state at a breakpoint. IDE inspectors that show "implementations of this interface" or "callers of this method" walk class metadata the same way, often through bytecode parsing for static analysis and through reflection for runtime evaluation.

You are unlikely to write a debugger, but knowing that they lean on the same Class, Field, and Method objects is what removes the magic from the IDE.

Object Copying and Mapping Utilities

Apache BeanUtils.copyProperties(target, source) copies matching properties from one object to another. Spring's BeanUtils has the same call. ModelMapper, Dozer, and MapStruct's runtime fallback all do variants. They exist because manually writing 30 lines of target.setName(source.getName()) for every DTO conversion is busywork.

The core idea: read the fields of the source, find matching fields by name on the target, and copy.

The fields that match by name and type get copied; internalId is left alone because the DTO has no field of that name. Real utilities add conversion (string-to-int), nested object mapping, and exclude-list support, but the dispatch is field-by-field reflection.

Compile-time alternatives also work here. MapStruct generates the equivalent of the loop above at compile time. The generated code is a plain target.setName(source.getName()) chain, with no reflection at runtime. Lombok's @Builder is similar in spirit: a build-time code generator that replaces reflective access with direct method calls. The trade-off is direct: compile-time generation is faster at runtime but pinned to specific source-target shapes; runtime reflection is slower per call but adapts to any pair of classes without code generation.

The diagram shows the per-field copy loop. The loop runs once per source field, and within each iteration the runtime cost is a HashMap-style field lookup on the target plus a get-and-set. For small DTOs (under a few dozen fields), it is fast enough that batch jobs and request-scoped converters rarely show up in profiles.

Generic equals, hashCode, and toString

Apache Commons EqualsBuilder.reflectionEquals(a, b) lets you compare two objects field by field without writing a hand-rolled equals. The same library has HashCodeBuilder.reflectionHashCode and ToStringBuilder.reflectionToString. The implementation is exactly what you would write yourself: walk the fields, read each value, fold the comparisons or the hash bits together.

You will not write this often because records generate correct equals, hashCode, and toString automatically since Java 16, and IDEs generate them for non-record classes in two keystrokes. The reflection-based versions are still useful in legacy code, mixin libraries, and tools that need to compare arbitrary objects without a compile-time dependency on their types.

Reflective equals walks every field on every call. For records, the compiler emits an equals that reads fields directly through invokedynamic-backed bootstrap methods (the same pattern as MethodHandle), which is both safer and faster.

Modern Alternatives to Plain Reflection

Reflection is the lowest layer. Several newer mechanisms cover the same ground with different trade-offs.

`MethodHandle` and `VarHandle` are introduced in Java 7 and Java 9 respectively. They are like Method and Field references, but the JVM can inline calls through them once the JIT has specialized the call site. A MethodHandle.invokeExact(...) is usually faster than Method.invoke(...) because the receiver type and parameter types are baked into the handle. Frameworks that need both flexibility and speed (Lombok's @Builder runtime, Jackson's afterburner module, modern serializers) wrap reflection in MethodHandles built once at startup.

`Lookup` is the access-control object for MethodHandles. Where Field.setAccessible(true) is a global override, a Lookup is scoped: it represents "code that has the right to access these members." Modules use Lookup to grant cross-module access without opening the entire package, and MethodHandles.privateLookupIn(...) is the modern, narrow alternative to setAccessible(true).

Annotation processors run at compile time, not runtime. Where Spring reads @Component at startup with reflection, Dagger reads @Inject during javac and writes plain factory classes that the application calls without any reflection. The trade-off is rigidity: annotation processors cannot react to runtime state (no "scan a directory at startup"), but they are dramatically faster and produce smaller startup footprints. This is why Android, with its tight startup budget, favors Dagger over Spring.

`java.lang.reflect.Proxy` creates a class at runtime that implements one or more interfaces, routing every call through an InvocationHandler you provide. Spring's @Transactional proxies, JDK dynamic mock libraries, and many tracing wrappers all use it. The proxy itself is generated bytecode that is shaped like reflection (every call enters the handler), so you get the flexibility of intercepting every method call on an interface without writing one wrapper class per interface.

Records' canonical constructor for deserialization. Records guarantee a canonical constructor with the components in declared order. Serializers can call Constructor.newInstance(name, price, stock) once, without per-field reflection, and the JVM treats the record's components as final. Jackson and Gson both have fast paths for records that bypass much of the field-by-field walking.

The rule of thumb: if the targets are fixed at compile time, prefer annotation processors or generated code. If the targets are discovered at runtime (plugins, generic mappers, DI scopes), prefer reflection. If you need both, wrap reflection in MethodHandles for the hot path. Modern frameworks use the third option often: reflect once to discover, then build MethodHandles for everything they need to call at request time.

Quiz

Reflection Use Cases Quiz

10 quizzes