The Proxy pattern puts a stand-in object in front of the real one, with the same interface, to control access: intercepting calls, logging, caching, checking permissions, or delaying initialization. In Java, the Proxy pattern shows up at three levels: hand-written as an ordinary wrapper class, generated at runtime with java.lang.reflect.Proxy (JDK dynamic proxy), and generated as a runtime subclass by CGLIB, which sits behind Spring's @Transactional, @Cacheable and @Async. This article goes from the concept to code that actually runs (compiled for Java 17) with Spring Boot 3.0, including the traps that make an annotation "do nothing" in real projects.
The previous article, Decorator pattern: lessons from java.io applied to services, showed a stack of wrappers sharing one interface. The Proxy pattern has almost the same shape but a different intent, and this article spells out that difference. If you need a refresher on decoupling dependencies through interfaces, see SOLID principles in Java with practical examples.
What problem the Proxy pattern solves
To see why you need the Proxy pattern, picture a ProductService that queries a database. You want to log every call, cache read results, and check permissions before edits. Stuffing all of that into ProductServiceImpl mixes business logic with infrastructure concerns and violates the Single Responsibility principle. Forcing every caller to wrap its own logging and caching duplicates code. The Proxy pattern lets clients keep calling ProductService as before, while an intermediary object does the infrastructure work and then forwards the call to the real object.
According to refactoring.guru, the Proxy pattern provides a "substitute or placeholder" for another object and controls access to it, letting you run logic before or after the request reaches the original object. Common variants:
| Proxy type | What it does | Familiar example |
|---|---|---|
| Virtual proxy | Delays creating a heavy object until it is really needed | Lazy loading of relations in an ORM |
| Protection proxy | Forwards a request only when the caller has permission | Method security in Spring Security |
| Remote proxy | Represents an object that lives on another machine | RPC clients, Feign, JAX-WS |
| Logging proxy | Records each call, then forwards it | Logging aspect |
| Caching proxy | Stores the results of repeated calls | @Cacheable |
| Smart reference | Tracks users and releases the object when nobody uses it | Reference counting, connection pools |
Proxy pattern structure and how it differs from Decorator
The structure of the Proxy pattern has four parts: a Service Interface that both the proxy and the real object implement, the Service as the real object, a Proxy that holds a reference to the Service and forwards calls, and a Client that only knows the interface and so uses the proxy and the real object the same way. This shape matches Decorator, so the difference lies in intent and lifecycle:
- Decorator adds behavior and is assembled by the client, stacking as many layers as it wants.
- The Proxy pattern controls access and often manages the real object's lifecycle itself: it creates the object (lazily) and decides whether to call it at all (permissions, cache).
- Adapter changes the interface; the Proxy pattern keeps the interface unchanged.
- Facade also separates the client from complexity, but a Facade exposes a simpler, different interface, while the Proxy pattern shares the real object's interface so the two are interchangeable. Facade is the topic of the next article.
In practice the boundary between the two patterns is blurry. A caching wrapper can be called a Decorator or a Proxy pattern depending on which intent you look at. Do not waste time arguing over Proxy versus Decorator; pick the name that reflects the intent so readers of the code understand it quickly.
Implementing the Proxy pattern by hand
The most direct approach is to write a class that implements the same interface. Here is a cache for reading a product name:
interface ProductService {
String findName(long id);
}
class CachingProductProxy implements ProductService {
private final ProductService real;
private final Map<Long, String> cache = new ConcurrentHashMap<>();
CachingProductProxy(ProductService real) { this.real = real; }
public String findName(long id) {
return cache.computeIfAbsent(id, real::findName);
}
}For an interface with a single method, a hand-written proxy is enough and the clearest option. The problem with writing it by hand appears when the interface has ten methods and you want the same logic (logging, timing, permission checks) applied to all of them, or applied to dozens of different interfaces. That is when you need the Proxy pattern in its runtime-generated form.
JDK dynamic proxy: the Proxy pattern generated at runtime
Since Java 1.3, java.lang.reflect.Proxy creates a class at runtime that implements the interfaces you specify, and routes every call to an InvocationHandler. According to the Proxy Javadoc, Proxy.newProxyInstance(loader, interfaces, handler) returns an object that implements those interfaces, and a call to an interface method is always dispatched to invoke(Object proxy, Method method, Object[] args). The value that invoke returns becomes the result of the call.
interface ProductService {
String findName(long id);
void rename(long id, String name) throws java.io.IOException;
}
static class LoggingCachingHandler implements InvocationHandler {
private final Object target;
private final Map<String, Object> cache = new HashMap<>();
LoggingCachingHandler(Object target) { this.target = target; }
@Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (method.getDeclaringClass() == Object.class) {
System.out.println(" [proxy] received " + method.getName() + " (declared on Object)");
}
String key = method.getName() + Arrays.toString(args);
if (method.getName().equals("findName") && cache.containsKey(key)) {
System.out.println(" [proxy] cache hit " + key);
return cache.get(key);
}
// args is null when the method has no parameters (for example hashCode)
String hienThi = args == null ? "()" : Arrays.toString(args);
System.out.println(" [proxy] -> " + method.getName() + hienThi);
try {
Object result = method.invoke(target, args);
if (method.getName().equals("findName")) cache.put(key, result);
return result;
} catch (InvocationTargetException e) {
System.out.println(" [proxy] !! " + e.getCause());
throw e.getCause(); // strip the reflection wrapper so the client sees the real exception
}
}
}
ProductService proxy = (ProductService) Proxy.newProxyInstance(
ProductService.class.getClassLoader(),
new Class<?>[] { ProductService.class },
new LoggingCachingHandler(real));Note that LoggingCachingHandler uses a HashMap, which is not thread-safe, so this is illustration code only; a production version needs ConcurrentHashMap or a cache library. A single handler serves every method, and it can be reused for other interfaces as long as target implements that interface. Compile with javac --release 17, then run java JdkProxyDemo for the real output (a class name like $Proxy38 in the Spring section below can differ in numbering between JDK versions, as the Javadoc warns, so do not rely on it):
1) Calling findName twice:
[proxy] -> findName[7]
[impl] findName(7) hits the DB
result: Product #7
[proxy] cache hit findName[7]
result: Product #7
2) A declared checked exception passes through the proxy intact:
[proxy] -> rename[7, ]
[proxy] !! java.io.IOException: empty name
client caught IOException: empty nameThe second call never touches the real object. Pay attention to the catch (InvocationTargetException e) block: Method.invoke wraps every exception from the target in an InvocationTargetException. If you forget throw e.getCause(), the client receives the reflection exception instead of the original IOException, and if that is a checked exception that is not declared, it receives an UndeclaredThrowableException, as the next section shows.
Rules of the JDK dynamic proxy, verified with code
The Javadoc states many rules; I verified each one in the same program (this is the output of items 3 to 7; item 5 checks that getClass is not intercepted by the handler). For hashCode(), the args parameter passed to the handler is null because the method has no parameters, so the code prints hashCode():
3) Information about the proxy class:
Proxy.isProxyClass: true
class name starts with $Proxy: true
is final: true
extends Proxy: true
instanceof ProductServiceImpl: false
4) toString/hashCode/equals also go through the handler:
[proxy] received hashCode (declared on Object)
[proxy] -> hashCode()
5) getClass is not intercepted:
proxy.getClass() has interfaces: [interface ProductService]
6) Undeclared checked exception -> UndeclaredThrowableException:
java.lang.reflect.UndeclaredThrowableException <- undeclared checked error
7) Proxy works only for interfaces: try proxying a concrete class:
IllegalArgumentException: ProductServiceImpl is not an interface- Only interfaces can be proxied. Passing a concrete class in the interface list throws
IllegalArgumentException, as in line 7. This is the core limitation that creates the need for CGLIB. - The proxy class is final and extends
java.lang.reflect.Proxy. Because Java has single inheritance, the proxy cannot also be a subclass of your class. The consequence:proxy instanceof ProductServiceImplisfalse, and casting to the concrete class causesClassCastException. Always declare variables with the interface type. hashCode,equalsandtoStringalso go through the handler, with aMethodwhose declaring class isObject. A naive handler that forwardsequalstotargetgives wrong comparison results (the target does not equal the proxy). Line 4 shows our handler seeinghashCode.- Checked exceptions must match the declaration. If
invokethrows a checked exception that is not in thethrowsclause of the interface method, the proxy throwsUndeclaredThrowableException(line 6). OtherObjectmethods such asgetClassorwaitare not overridden (item 5 in the output above). - The proxy class name is unspecified. The Javadoc only guarantees that names starting with
$Proxyare reserved; do not depend on a specific name.
CGLIB: the Proxy pattern as a subclass when there is no interface
When the target class implements no interface, the JDK dynamic proxy is out of options. The solution is to generate a subclass of the target class at runtime and override its methods to inject logic. That is the job of CGLIB (Code Generation Library), the open-source library that Spring repackages inside spring-core. Because it is a subclass, a CGLIB proxy is a target class as far as types go, so instanceof OrderService returns true; at the same time it inherits the limits of inheritance, which the sections below spell out.
The Spring documentation notes that the constructor of a proxied object is not called twice, because the CGLIB proxy instance is created through Objenesis. CGLIB is also already bundled in spring-core, so you do not need to add a separate library to the classpath.
Which kind of Proxy pattern Spring AOP chooses
According to the Spring Framework 6.0 documentation on proxying mechanisms: if the target object implements at least one interface, a JDK dynamic proxy is used and all of its interfaces are proxied; if it implements none, a CGLIB proxy is created, a subclass generated at runtime. You can force CGLIB with the proxy-target-class attribute in XML or proxyTargetClass in @EnableAspectJAutoProxy. The core documentation describes the default behavior as preferring interface-based proxies; with Spring Boot, however, the real output below shows that Boot 3.0.0's default configuration picks CGLIB even when the bean has an interface (Boot sets spring.aop.proxy-target-class=true by default).
So I would not have to rely on memory, I built a small Maven project (Spring Boot 3.0.0, Java 17 target, spring-boot-starter-aop) with an @Around aspect that logs, and four beans:
OrderServiceimplements the interfaceOrders;PricingServiceimplements no interface;Reporterhas onefinalmethod and one normal method;- a
CommandLineRunnerprints the proxy type of each bean.
With the default Spring Boot 3.0.0 configuration, the real output:
=== 1. Proxy type ===
OrderService (has interface Orders): actual class = OrderService$$SpringCGLIB$$0 | JDK proxy=false | CGLIB proxy=true
PricingService (no interface): actual class = PricingService$$SpringCGLIB$$0 | JDK proxy=false | CGLIB proxy=true
OrderService is Orders? true
OrderService is OrderService (concrete class)? trueThe notable point: OrderService has an interface but is still CGLIB-proxied. That is the default behavior of Spring Boot 3.0.0 that I measured, meaning the spring.aop.proxy-target-class property is on. Turn it off with --spring.aop.proxy-target-class=false and run again:
=== 1. Proxy type ===
OrderService (has interface Orders): actual class = $Proxy38 | JDK proxy=true | CGLIB proxy=false
PricingService (no interface): actual class = PricingService$$SpringCGLIB$$0 | JDK proxy=false | CGLIB proxy=true
OrderService is Orders? true
OrderService is OrderService (concrete class)? falseThe two modes show that the Proxy pattern in Spring does not have one fixed form. This time OrderService is a JDK proxy ($Proxy38) and is no longer an OrderService as far as types go. The consequence is real: on my first run in this mode I declared the parameter OrderService orders for the CommandLineRunner and the Spring context failed to start, reporting "Bean named 'orderService' is expected to be of type 'demo.OrderService' but was actually of type 'jdk.proxy2.$Proxy38'". It only worked after I switched to injecting by the Orders interface. To reproduce this error, change the parameter Orders orders in App.demo(...) of code/s6-08/spring-demo to OrderService orders and run with the flag --spring.aop.proxy-target-class=false; the error message is saved in output-spring-jdk-inject-class-error.txt. The lesson: inject dependencies by interface, and that code works correctly in both proxy modes.
These are measurements from Spring Boot 3.0.0; with another version, check again the same way.
The biggest Proxy pattern trap: self-invocation bypasses the proxy
The inherent weakness of the Proxy pattern is that it can only intercept calls that go through it. When a method inside the real object calls another of its own methods through this, that call goes straight to the real object and skips the proxy. The Spring Framework 6.0 documentation explains it: a call through this goes directly to the target object rather than the proxy, so "self-invocation is not going to result in the advice associated with a method invocation getting a chance to run". In the demo project, placeOrderAndNotify calls this.notifyCustomer(id) internally:
=== 2. Advice runs when called from outside ===
[advice] entering OrderService.placeOrder(..)
result: placed DH2001
=== 3. Self-invocation bypasses the advice ===
[advice] entering OrderService.placeOrderAndNotify(..)
result: placed DH2002 + notified customer DH2002In item 3, the advice prints exactly one line, for placeOrderAndNotify. The two inner calls (placeOrder and notifyCustomer) have no [advice] line even though the aspect matches both. If the aspect is @Transactional or @Cacheable, that means no transaction is opened, no cache is used, and no error tells you so. The result is the same in JDK proxy mode (my second output shows exactly that), because the root cause is that this is not the proxy.
The current Spring documentation on proxying mechanisms ranks the workarounds in priority order: refactor so self-invocation does not happen (the "best" approach in the documentation's words, for example by moving the part that needs advice into another bean); inject a reference to the bean itself (self injection) and call through it instead of this; and finally AopContext.currentProxy(), which is strongly discouraged because it couples code to Spring AOP and requires enabling exposeProxy. The 6.0.0 documentation page cited above lists only the first and last options (refactoring and currentProxy); self injection was added in a later version, and it is also a common technique in the Spring community. AspectJ weaving at compile time or load time does not have this problem because it injects advice straight into the bytecode instead of going through a proxy.
Limits of the CGLIB Proxy pattern: final and private
Because a CGLIB-style proxy overrides methods in a subclass, the current Spring documentation lists the following (the 6.0.0 version mentions only final methods): a final class cannot be proxied because it cannot be extended; final and private methods cannot be advised because they cannot be overridden; methods that are not visible, such as package-private methods in a superclass in a different package, count as private too. Item 5 of the demo verifies this:
=== 5. A final method is not advised ===
result: final: not through the advice
[advice] entering Reporter.normalMethod()
result: normal: through the adviceThe final method runs without an advice line, while the normal method has one. The danger is that it "works" but silently skips logging, security or transactions.
The Proxy pattern in libraries you already use
- Spring AOP and
@Transactional. This is the most common Proxy pattern in Spring: your bean is replaced by a proxy; the proxy opens a transaction, calls the real method, then commits or rolls back. - Hibernate lazy loading. A lazy relation is a virtual proxy, a form of the Proxy pattern: an object holds the primary key and queries only when you call the first property.
- Spring Data repositories. The
JpaRepositoryinterface you declare has no implementation; Spring generates a JDK proxy at runtime. - Mockito. A mock is a runtime-generated proxy that records and simulates calls; this is why you could not mock
finalclasses in older configurations. - Declarative HTTP clients such as Feign or Retrofit. A Java interface is proxied into network calls, a remote proxy.
When not to use a dynamic Proxy pattern
- Only one or two methods and simple logic. A hand-written proxy class is clearer, has shorter stack traces, and is easier to debug than reflection.
- Hot paths that need maximum performance. Every call through a JDK proxy goes through
Method.invoke. That cost is small compared with a database query or a network call, but it is not free; measure with JMH before deciding instead of guessing. - Logic that needs explicit semantics. An implicitly applied aspect hides the behavior from readers at the call site. For important logic such as payment retries, an explicit Decorator like the one in the previous article is easier to understand.
- You need to advise private or final methods, or self-invocation. Use AspectJ weaving, or redesign by splitting into separate beans.
Common mistakes with the Proxy pattern
@Transactionalon a private or final method, or called throughthis. No transaction, no error. Check withAopUtils.isAopProxy(bean)and read the transaction log.- Casting a proxy to the concrete class. With a JDK proxy this causes
ClassCastException; inject by interface. - Forgetting
throw e.getCause()in the handler of a dynamic Proxy pattern. The client receivesInvocationTargetExceptionorUndeclaredThrowableExceptioninstead of the business error. - Comparing
equalsbetween a proxy and the real object. They are two different objects; compare by business key. - Using a lazy proxy outside the session. Calling a property of an ORM proxy after the session is closed causes an error; load the data you need inside the transaction scope.
Proxy pattern checklist
- Identify the intent: controlling access (Proxy pattern) or adding behavior (Decorator)?
- One or two methods: write the Proxy pattern by hand. Many methods sharing logic: JDK dynamic proxy or AOP.
- Always use and inject by interface; treat a proxy as a type that cannot be cast to the concrete class.
- Do not rely on self-invocation when applying the Proxy pattern; split out a bean if internal calls need advice.
- Avoid final and private methods on advised objects.
- Verify with code like the demo above: print
AopUtils.isJdkDynamicProxyandisCglibProxy, and do not assume.
Next article
The Proxy pattern keeps the interface unchanged, while the next pattern does the opposite: it offers one simple interface that hides an entire complex subsystem. Next in the series: Facade pattern: a single entry point to a complex system.



