Java Dynamic Proxy: Proxy, InvocationHandler & Practical Patterns

A Java dynamic proxy is a class generated at runtime that implements one or more interfaces, routing every method call through a single InvocationHandler. Frameworks like Spring AOP, Mockito, Hibernate, and JAX-RS client stubs all use dynamic proxies to add cross-cutting behaviour (logging, security checks, retry logic, caching, transaction management) without touching the original class. This article covers Proxy.newProxyInstance(), writing InvocationHandlers, and practical patterns with real examples.

What Is a Dynamic Proxy?

Imagine you want to add logging to every method of a UserService interface, printing the method name, arguments, and return value on each call. One approach is to write a decorator class that implements the same interface and manually delegates every method while adding logging before and after each call. That works, but it scales poorly: a 10-method interface means 10 near-identical wrapper methods, and every future method added to the interface requires a matching change to every decorator.

A dynamic proxy solves this by letting you intercept all method calls through a single handler. The JVM generates the proxy class at runtime: you provide only the handler logic. Every call to every method on the proxy is funnelled through InvocationHandler.invoke(), regardless of how many methods the interface has. This is exactly how Spring AOP works for @Transactional and @Cacheable, how Mockito records expectations, and how Feign builds REST clients from annotated interfaces.

JDK dynamic proxies live in java.lang.reflect.Proxy and have been part of Java since Java 1.3. The key constraint: they can only proxy interfaces. Proxying concrete classes requires a bytecode library such as CGLIB or Byte Buddy.

Creating a Proxy with Proxy.newProxyInstance()

Proxy.newProxyInstance() takes three arguments: a ClassLoader to define the generated class, an array of interface types to implement, and the InvocationHandler that handles every call. It returns an Object that can be cast to any of the listed interface types. The generated proxy class is cached by the JVM: the same combination of ClassLoader and interfaces always returns the same generated class.

Java
import java.lang.reflect.*;

// Step 1 — define the interface to proxy
public interface UserService {
    String findById(long id);
    void save(String name);
}

// Step 2 — real implementation
public class UserServiceImpl implements UserService {
    @Override public String findById(long id) { return "User-" + id; }
    @Override public void save(String name) { System.out.println("Saved: " + name); }
}

// Step 3 — create a simple logging proxy
UserService real = new UserServiceImpl();

UserService proxy = (UserService) Proxy.newProxyInstance(
    real.getClass().getClassLoader(),      // ClassLoader
    new Class[]{ UserService.class },       // interfaces to implement
    (proxyObj, method, args) -> {          // InvocationHandler as lambda
        System.out.println("Calling: " + method.getName());
        Object result = method.invoke(real, args); // delegate to real object
        System.out.println("Returned: " + result);
        return result;
    }
);

proxy.findById(42);
// Calling: findById
// Returned: User-42

proxy.save("Alice");
// Calling: save
// Returned: null
The InvocationHandler lambda receives args as Object[]: it is null (not an empty array) when the method has no parameters. Always null-check before iterating.

InvocationHandler: Intercepting Method Calls

InvocationHandler is a single-method functional interface. Its invoke(Object proxy, Method method, Object[] args) method is called for every method invocation on the proxy, including methods declared in Object such as toString(), equals(), and hashCode(). The Method parameter gives you the method name, parameter types, return type, and annotations. You call method.invoke(target, args) to delegate to the real object, or you can skip delegation entirely to return a mocked/cached value.

A practical handler adds timing and structured logging around every call, including proper exception propagation. When method.invoke() throws, it wraps the original exception in an InvocationTargetException: you must unwrap it to preserve the original stack trace.

Java
public class TimingHandler implements InvocationHandler {
    private final Object target;

    public TimingHandler(Object target) { this.target = target; }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        long start = System.nanoTime();
        try {
            Object result = method.invoke(target, args);
            long ms = (System.nanoTime() - start) / 1_000_000;
            System.out.printf("[%s] completed in %d ms%n", method.getName(), ms);
            return result;
        } catch (InvocationTargetException e) {
            // Unwrap so callers see the original exception, not InvocationTargetException
            throw e.getCause();
        }
    }
}

UserService timed = (UserService) Proxy.newProxyInstance(
    UserService.class.getClassLoader(),
    new Class[]{ UserService.class },
    new TimingHandler(new UserServiceImpl())
);

timed.findById(1); // [findById] completed in 0 ms

Practical Pattern: Retry Proxy

Network calls and database operations can fail transiently: a TCP timeout, a momentary connection pool exhaustion, or a primary failover. A retry proxy wraps any service interface and transparently re-attempts the call up to a configured number of times before letting the exception propagate. Because the proxy intercepts all methods uniformly, there is no need to add retry logic to every individual service method.

Java
public class RetryHandler implements InvocationHandler {
    private final Object target;
    private final int maxAttempts;
    private final long delayMs;

    public RetryHandler(Object target, int maxAttempts, long delayMs) {
        this.target = target;
        this.maxAttempts = maxAttempts;
        this.delayMs = delayMs;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        Throwable lastException = null;
        for (int attempt = 1; attempt <= maxAttempts; attempt++) {
            try {
                return method.invoke(target, args);
            } catch (InvocationTargetException e) {
                lastException = e.getCause();
                System.err.printf("Attempt %d/%d failed for %s: %s%n",
                    attempt, maxAttempts, method.getName(), lastException.getMessage());
                if (attempt < maxAttempts) {
                    Thread.sleep(delayMs * attempt); // back-off: 1x, 2x, 3x…
                }
            }
        }
        throw lastException; // all attempts exhausted
    }
}

UserService resilient = (UserService) Proxy.newProxyInstance(
    UserService.class.getClassLoader(),
    new Class[]{ UserService.class },
    new RetryHandler(new UserServiceImpl(), 3, 200)
);

Limitations of JDK Dynamic Proxies

JDK dynamic proxies are powerful but have important constraints you must understand before choosing them as a solution.

Interfaces only. The generated proxy class implements the listed interfaces; it cannot extend a concrete class. If you need to proxy a class that was not designed around an interface, you need CGLIB (which Spring uses automatically when no interface is available) or Byte Buddy.

Self-calls are not intercepted. If a proxied method calls another method on this internally, that internal call bypasses the proxy entirely: it goes directly to the real object. Spring developers encounter this when one @Transactional method calls another in the same bean. The fix is to inject a self-reference or use AspectJ weaving instead of proxy-based AOP.

Performance overhead. Each proxied call involves a reflection dispatch through Method.invoke(). The JIT compiler inlines and optimises this well for steady-state code, but it is measurably slower than a direct method call. For hot, tight loops, prefer a hand-written decorator. For normal service-level call rates the overhead is irrelevant.

toString, equals, hashCode. These Object methods are also routed through the handler. If your handler delegates all calls to the target, this is usually fine. But if you have special logic (for example, caching), make sure to handle these methods explicitly to avoid unexpected behaviour.

Spring chooses between JDK proxies (when the bean implements an interface) and CGLIB subclass proxies (when it does not) automatically. You can force CGLIB with @EnableAspectJAutoProxy(proxyTargetClass = true).