Proxy Pattern: Controlling Access to an Object
Virtual, protection, and remote proxies for controlling access to an object — lazy loading, authorization checks, and network boundaries — plus Java's dynamic proxy mechanism.
Proxy Pattern
Intent: provide a surrogate or placeholder for another object to control access to it. The proxy implements the same interface as the real object, so clients interact with the proxy exactly as they would the real thing — but the proxy gets a chance to intervene before, after, or instead of delegating to the real object.
Where Decorator's job is adding behavior, Proxy's job is gatekeeping: deciding whether, when, and how a call reaches the real object at all. That single distinction spawns three well-known variants: virtual proxy (delay creating an expensive object until it's actually needed), protection proxy (check permissions before allowing a call through), and remote proxy (hide the fact that the real object lives on a different machine).
A building receptionist is a protection proxy: same "front door" as the person you're trying to reach, but they decide whether your request goes through. A stunt double on a film set is a virtual proxy: stands in for the actor until the actual dangerous work is needed. A travel agent booking a hotel on your behalf across a phone line is a remote proxy: local interface, remote reality.
1. Virtual Proxy — lazy initialization
Some objects are expensive to construct — a high-resolution image loaded from disk, a large report generated from a slow query, a connection pool warmed up at startup. If the object might not even be used, constructing it eagerly wastes time and memory.
VIOLATION: eager construction regardless of use
// VIOLATION: every ProductPage loads the full-resolution image eagerly,
// even if the user never scrolls down to see it.
class ProductPage {
private final HighResImage image;
ProductPage(String imagePath) {
this.image = new HighResImage(imagePath); // expensive disk I/O, every single time
}
void render() {
// ... renders text, price, reviews ...
// image.display() only called if user scrolls to the gallery section
}
}
class HighResImage {
HighResImage(String path) {
loadFromDisk(path); // slow: decodes a 20MB file
}
void display() { /* ... */ }
private void loadFromDisk(String path) { /* ... */ }
}FIXED: a virtual proxy defers construction until first real use
interface Image {
void display();
}
class HighResImage implements Image {
private final String path;
HighResImage(String path) {
this.path = path;
loadFromDisk(path); // expensive — but now only runs when THIS class is instantiated
}
public void display() { /* render pixels already in memory */ }
private void loadFromDisk(String path) { /* decode 20MB file */ }
}
// Same interface as HighResImage — clients can't tell the difference
class ImageProxy implements Image {
private final String path;
private HighResImage realImage; // null until actually needed
ImageProxy(String path) {
this.path = path; // cheap — no disk I/O yet
}
public void display() {
if (realImage == null) {
realImage = new HighResImage(path); // construct on first real use
}
realImage.display();
}
}
class ProductPage {
private final Image image;
ProductPage(String imagePath) {
this.image = new ImageProxy(imagePath); // instant — no disk I/O at construction
}
void render() {
// ... renders text, price, reviews ...
// image.display() called only if/when the gallery section actually renders
}
}Image image = new ImageProxy(path) and Image image = new HighResImage(path) are interchangeable from the client's point of view — that interchangeability is what makes it a proxy rather than just a factory or a lazy-loading wrapper class with a different name.
2. Protection Proxy — access control
A protection proxy checks whether the caller is allowed to perform an operation before delegating to the real object.
interface DocumentService {
Document get(String docId);
void delete(String docId);
}
class RealDocumentService implements DocumentService {
public Document get(String docId) { /* fetch from store */ return new Document(docId); }
public void delete(String docId) { /* actually delete */ }
}
// Same interface — enforces authorization before delegating
class ProtectedDocumentService implements DocumentService {
private final RealDocumentService realService;
private final CurrentUser currentUser;
ProtectedDocumentService(RealDocumentService realService, CurrentUser currentUser) {
this.realService = realService;
this.currentUser = currentUser;
}
public Document get(String docId) {
return realService.get(docId); // read access: anyone
}
public void delete(String docId) {
if (!currentUser.hasRole("ADMIN")) {
throw new AccessDeniedException("delete requires ADMIN role");
}
realService.delete(docId);
}
}The real object never needs to know authorization rules exist — that concern lives entirely in the proxy, which is exactly the Single Responsibility split you want: RealDocumentService owns document storage; ProtectedDocumentService owns access policy.
3. Remote Proxy — hiding the network
A remote proxy makes a call to an object on another machine look like a normal local method call. This is the pattern underneath every RPC framework: gRPC stubs, Java RMI, and most generated HTTP client SDKs.
interface InventoryService {
int checkStock(String sku);
}
// Client code calls this exactly like a local object
class InventoryServiceRemoteProxy implements InventoryService {
private final HttpClient httpClient;
private final String remoteBaseUrl;
InventoryServiceRemoteProxy(HttpClient httpClient, String remoteBaseUrl) {
this.httpClient = httpClient;
this.remoteBaseUrl = remoteBaseUrl;
}
public int checkStock(String sku) {
// marshalling, network call, unmarshalling — all hidden from the caller
HttpResponse response = httpClient.get(remoteBaseUrl + "/stock/" + sku);
return parseStockLevel(response);
}
private int parseStockLevel(HttpResponse response) { /* deserialize JSON */ return 0; }
}
// Calling code has NO idea this is a network call:
class OrderService {
private final InventoryService inventory; // could be local or remote — indistinguishable
OrderService(InventoryService inventory) { this.inventory = inventory; }
boolean canFulfill(String sku, int qty) {
return inventory.checkStock(sku) >= qty;
}
}Remote proxies are useful precisely because they hide the network — but that convenience is also their most infamous risk (the "first fallacy of distributed computing": the network is not reliable). A remote proxy that looks exactly like a local call invites callers to forget about timeouts, partial failures, and retries. Production remote proxies should surface these explicitly — timeouts as constructor parameters, checked or clearly-documented exceptions for network failure — rather than pretending the call is truly local.
4. Dynamic proxies: generating the proxy at runtime
Writing a hand-rolled proxy class per interface (as above) is fine for a handful of cases, but doesn't scale to "add logging/transactions/caching to every Spring @Repository interface automatically." Java gives you two mechanisms to generate proxies at runtime instead of hand-writing them.
java.lang.reflect.Proxy — interface-based dynamic proxies
interface InvocationHandler {
Object invoke(Object proxy, Method method, Object[] args) throws Throwable;
}
class LoggingInvocationHandler implements InvocationHandler {
private final Object target;
LoggingInvocationHandler(Object target) { this.target = target; }
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("Calling " + method.getName());
try {
return method.invoke(target, args); // delegate to the real object via reflection
} finally {
System.out.println("Finished " + method.getName());
}
}
}
// Usage: generates a proxy class implementing DocumentService at runtime — no DocumentServiceProxy .java file needed
DocumentService real = new RealDocumentService();
DocumentService proxy = (DocumentService) Proxy.newProxyInstance(
DocumentService.class.getClassLoader(),
new Class<?>[]{DocumentService.class},
new LoggingInvocationHandler(real));
proxy.get("doc-1"); // prints "Calling get" / "Finished get", then delegatesjava.lang.reflect.Proxy can only proxy interfaces — it generates a class implementing them at runtime. This is exactly how Spring generates JDK dynamic proxies for @Transactional beans that implement at least one interface.
CGLIB — subclass-based proxies for classes without interfaces
When there's no interface to proxy — a concrete class with no interface, common with @Service classes that don't implement anything — Spring falls back to CGLIB, which generates a runtime subclass that overrides each method to add the interceptor logic, then delegates to super.method(...). This is why Spring AOP historically required either an interface (JDK proxy) or a non-final class with non-final methods (CGLIB proxy) — final classes and methods can't be subclassed, so CGLIB can't proxy them.
JDK dynamic proxy (java.lang.reflect.Proxy) | CGLIB proxy | |
|---|---|---|
| Requires | Target implements at least one interface | No interface required |
| Mechanism | Generates a class implementing the interface(s) | Generates a runtime subclass of the target class |
Can proxy final classes/methods | N/A — proxies the interface, not the class | No — can't subclass or override final |
| Used by Spring for | Beans with interfaces (default when available) | Beans without interfaces (@Configuration classes, concrete @Service classes) |
This is precisely the mechanism behind @Transactional, @Cacheable, and @Async in Spring: the bean you @Autowired is frequently not your class at all, but a JDK or CGLIB proxy wrapping it, intercepting the annotated methods before delegating to the real object. If you've ever been confused why calling an @Transactional method from within the same class doesn't start a transaction, this is why — the call bypasses the proxy entirely and goes directly to this, never passing through the interceptor.
5. When to use vs. when it's overkill
| Use Proxy when | Skip it when |
|---|---|
| An object is expensive to create and might not be used (virtual proxy) | Construction is already cheap — laziness adds complexity for no measurable benefit |
| You need access control that's orthogonal to the object's core logic (protection proxy) | Authorization can be checked once at a higher layer (e.g., a servlet filter) with no per-object nuance needed |
| You're hiding a network boundary behind a local-looking interface (remote proxy) | You want callers to be explicitly aware they're making a network call — sometimes true awareness is safer than seamless abstraction |
| You need to add a uniform cross-cutting concern to many classes without editing each one (dynamic proxy / AOP) | Only one or two classes need the behavior — a hand-written wrapper is more debuggable than a generated proxy |
6. Decorator vs. Proxy
Structurally these two patterns are the same shape — a class implementing the same interface as the object it wraps, holding a reference to that object. The difference is purely intent, and it's worth stating precisely because interviewers probe exactly this line:
| Decorator | Proxy | |
|---|---|---|
| Question it answers | "What extra behavior should run in addition to the real behavior?" | "Should this call even reach the real object, and under what conditions?" |
| Relationship to the wrapped object | Always delegates, always adds on top | May delegate, may deny, may defer, may redirect entirely |
| Stacking | Designed to be chained (multiple decorators) | Usually exactly one proxy in front of the real subject |
| Who controls construction of the wrapped object | Caller constructs it and passes it in | Proxy often controls construction itself (virtual proxy constructs lazily; remote proxy never constructs a local instance at all) |
See Decorator Pattern for the full Decorator write-up, including the Java I/O example and behavior-chaining details.
A caching wrapper is the case that genuinely straddles both: it adds behavior (checking a cache first, which is Decorator-flavored) and it controls access (short-circuiting the real call entirely on a cache hit, which is Proxy-flavored). Don't over-index on forcing every wrapper into exactly one bucket — use the two patterns as vocabulary for intent, not a rigid taxonomy.
Interview Questions
- What's the core difference in intent between Proxy and Decorator, given that they're structurally almost identical?
- Explain virtual proxy with a concrete example. What has to be true about an object for lazy initialization via proxy to be worth the complexity?
- How does a protection proxy differ from an authorization check inside the real object itself? Why might you prefer the proxy?
- What "fallacy of distributed computing" does a remote proxy risk reinforcing, and how would you design around it?
- How does
java.lang.reflect.Proxygenerate a proxy at runtime, and what's the hard requirement on the target type for it to work? - Why does Spring need CGLIB in addition to JDK dynamic proxies? What's the concrete limitation JDK proxies have that CGLIB solves, and what limitation does CGLIB have that JDK proxies don't?
- Why does calling an
@Transactionalmethod from within the same class (self-invocation) not actually start a transaction in default Spring AOP configuration? - Give an example of a wrapper that has characteristics of both Decorator and Proxy, and explain why it doesn't fit cleanly into either category.