Proxy Design Pattern

Imagine you want to meet the CEO of a company. You don't just walk into their office. You go through a secretary — someone who controls access, schedules the meeting, maybe handles some requests on the CEO's behalf without ever bothering the CEO at all. That secretary is a Proxy.
The Proxy Design Pattern is a structural pattern where one object acts as a stand-in for another. The proxy controls access to the real object, and in doing so, it can add behavior before or after the request reaches the actual target — without changing the target itself.
The Core Structure
At its heart, there are three players:
Subject — the shared interface that both the real object and proxy implement
RealSubject — the actual object that does the heavy lifting
Proxy — the stand-in that wraps the real subject and intercepts calls to it
The client only ever talks to the Subject interface. It doesn't know (or care) whether it's talking to the real thing or a proxy. This is the key insight — the substitution is transparent.
The Four Flavors
The Proxy pattern shows up in four distinct forms, each solving a different problem.
Virtual Proxy — Create on Demand
Some objects are expensive to create. A virtual proxy delays that creation until the object is actually needed.
Think of a document editor that can contain high-resolution images. Loading all images when the document opens would be slow. A virtual proxy holds a placeholder — renders a grey box — and only loads the actual image when the user scrolls to it.
// Subject — the contract both real and proxy honour
public interface Report {
void display();
String getTitle();
}
// RealSubject — expensive: hits the DB, parses XML, builds a model
public class FullReport implements Report {
private final String reportId;
private final String title;
private final String content;
public FullReport(String reportId) {
this.reportId = reportId;
System.out.println("[DB] Fetching full report: " + reportId);
// Simulates a slow, heavy database fetch
this.title = "Quarterly Report - " + reportId;
this.content = "<data>...large payload for " + reportId + "...</data>";
}
@Override
public void display() {
System.out.println("=== " + title + " ===");
System.out.println(content);
}
@Override
public String getTitle() {
return title;
}
}
// Virtual Proxy — creates the FullReport only when display() is called
public class LazyReport implements Report {
private final String reportId;
private final String title; // lightweight — loaded up front for the list view
private FullReport fullReport; // null until the user actually opens the report
public LazyReport(String reportId, String title) {
this.reportId = reportId;
this.title = title;
// No DB call here. Just metadata.
}
@Override
public String getTitle() {
return title; // cheap — no proxy delegation needed
}
@Override
public void display() {
if (fullReport == null) {
fullReport = new FullReport(reportId); // lazy: first access triggers the real load
}
fullReport.display();
}
}
// Client
public class Dashboard {
public static void main(String[] args) {
// 50 reports loaded instantly — only titles fetched
List<Report> reports = List.of(
new LazyReport("Q1-2026", "Quarterly Report - Q1-2026"),
new LazyReport("Q2-2026", "Quarterly Report - Q2-2026"),
new LazyReport("Q3-2026", "Quarterly Report - Q3-2026")
);
// Render the list — no DB hits
reports.forEach(r -> System.out.println("- " + r.getTitle()));
// User clicks on Q2 — NOW the DB is hit
reports.get(1).display();
// User clicks Q2 again — already loaded, instant
reports.get(1).display();
}
}
Protection Proxy — Guard the Gate
A protection proxy sits in front of a sensitive resource and checks permissions before forwarding any call.
public interface PaymentService {
String charge(String customerId, double amount);
String refund(String transactionId, double amount);
String voidTransaction(String transactionId);
}
// The real implementation — knows nothing about who is calling it
public class RealPaymentService implements PaymentService {
@Override
public String charge(String customerId, double amount) {
System.out.printf("[PaymentGateway] Charging %.2f to customer %s%n", amount, customerId);
return "TXN-" + System.currentTimeMillis();
}
@Override
public String refund(String transactionId, double amount) {
System.out.printf("[PaymentGateway] Refunding %.2f for transaction %s%n", amount, transactionId);
return "REFUND-" + transactionId;
}
@Override
public String voidTransaction(String transactionId) {
System.out.printf("[PaymentGateway] Voiding transaction %s%n", transactionId);
return "VOID-" + transactionId;
}
}
public enum Role { CASHIER, SUPERVISOR, ADMIN }
// Protection Proxy — enforces role-based rules the real service doesn't know about
public class SecurePaymentProxy implements PaymentService {
private final RealPaymentService delegate = new RealPaymentService();
private final Role callerRole;
public SecurePaymentProxy(Role callerRole) {
this.callerRole = callerRole;
}
@Override
public String charge(String customerId, double amount) {
// All roles can charge
return delegate.charge(customerId, amount);
}
@Override
public String refund(String transactionId, double amount) {
if (callerRole == Role.CASHIER) {
throw new SecurityException("Cashiers cannot issue refunds. Requires SUPERVISOR or above.");
}
return delegate.refund(transactionId, amount);
}
@Override
public String voidTransaction(String transactionId) {
if (callerRole != Role.ADMIN) {
throw new SecurityException("Voiding transactions requires ADMIN role.");
}
return delegate.voidTransaction(transactionId);
}
}
// Client
public class PaymentController {
public static void main(String[] args) {
PaymentService cashierView = new SecurePaymentProxy(Role.CASHIER);
PaymentService supervisorView = new SecurePaymentProxy(Role.SUPERVISOR);
PaymentService adminView = new SecurePaymentProxy(Role.ADMIN);
cashierView.charge("CUST-001", 49.99); // works fine
supervisorView.refund("TXN-123", 49.99); // works fine
adminView.voidTransaction("TXN-123"); // works fine
cashierView.refund("TXN-456", 20.00); // throws SecurityException
}
}
Remote Proxy — Local Handle, Remote Reality
A remote proxy makes a remote object look local. You call methods on it like it's right there, but under the hood it's serializing arguments and sending them over a network. This is the idea behind gRPC stubs, RMI, and REST client wrappers.
public record Product(String sku, String name, double price) {}
public interface ProductService {
Product getProduct(String sku);
List<Product> search(String query);
}
// Remote Proxy — wraps HTTP calls so callers treat it like a local service
public class ProductServiceClient implements ProductService {
private final String baseUrl;
private final HttpClient httpClient;
private final ObjectMapper mapper;
public ProductServiceClient(String baseUrl) {
this.baseUrl = baseUrl;
this.httpClient = HttpClient.newHttpClient();
this.mapper = new ObjectMapper();
}
@Override
public Product getProduct(String sku) {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/products/" + sku))
.GET()
.header("Accept", "application/json")
.build();
try {
HttpResponse<String> response =
httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 404) {
throw new NoSuchElementException("Product not found: " + sku);
}
if (response.statusCode() != 200) {
throw new RuntimeException("Upstream error: HTTP " + response.statusCode());
}
return mapper.readValue(response.body(), Product.class);
} catch (IOException | InterruptedException e) {
throw new RuntimeException("Failed to reach product service", e);
}
}
@Override
public List<Product> search(String query) {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/products?q=" + URLEncoder.encode(query, StandardCharsets.UTF_8)))
.GET()
.build();
try {
HttpResponse<String> response =
httpClient.send(request, HttpResponse.BodyHandlers.ofString());
return mapper.readValue(response.body(),
mapper.getTypeFactory().constructCollectionType(List.class, Product.class));
} catch (IOException | InterruptedException e) {
throw new RuntimeException("Search failed", e);
}
}
}
// Client code — has no idea it's talking to a remote machine
public class OrderService {
private final ProductService products;
public OrderService(ProductService products) {
this.products = products;
}
public void placeOrder(String sku, int qty) {
Product product = products.getProduct(sku); // feels local
System.out.printf("Order placed: %d x %s @ $%.2f%n", qty, product.name(), product.price());
}
}
Caching Proxy — Remember What You've Seen
A caching proxy stores results of expensive calls and returns the cached result for repeated identical requests.
public interface InventoryService {
int getStockLevel(String sku);
boolean isAvailable(String sku);
}
// Real service — hits an external inventory system
public class WarehouseInventoryService implements InventoryService {
@Override
public int getStockLevel(String sku) {
System.out.println("[Warehouse API] Fetching stock for: " + sku);
// Simulate slow network call
return 150; // pretend this came from the warehouse system
}
@Override
public boolean isAvailable(String sku) {
return getStockLevel(sku) > 0;
}
}
// Cache entry with a timestamp for TTL-based expiry
record CacheEntry(int value, long fetchedAt) {
boolean isExpired(long ttlMillis) {
return System.currentTimeMillis() - fetchedAt > ttlMillis;
}
}
// Caching Proxy — transparent to callers, avoids repeated warehouse calls
public class CachedInventoryProxy implements InventoryService {
private final InventoryService delegate;
private final Map<String, CacheEntry> cache = new ConcurrentHashMap<>();
private final long ttlMillis;
public CachedInventoryProxy(InventoryService delegate, long ttlMillis) {
this.delegate = delegate;
this.ttlMillis = ttlMillis;
}
@Override
public int getStockLevel(String sku) {
CacheEntry entry = cache.get(sku);
if (entry != null && !entry.isExpired(ttlMillis)) {
System.out.println("[Cache] Hit for SKU: " + sku);
return entry.value();
}
int stock = delegate.getStockLevel(sku);
cache.put(sku, new CacheEntry(stock, System.currentTimeMillis()));
System.out.println("[Cache] Stored result for SKU: " + sku);
return stock;
}
@Override
public boolean isAvailable(String sku) {
return getStockLevel(sku) > 0; // reuses the cached getStockLevel
}
}
// Client
public class ProductPage {
public static void main(String[] args) throws InterruptedException {
InventoryService service = new CachedInventoryProxy(
new WarehouseInventoryService(),
30_000L // 30-second TTL
);
service.getStockLevel("SKU-ALPHA"); // warehouse API call
service.getStockLevel("SKU-ALPHA"); // cache hit
service.isAvailable("SKU-ALPHA"); // cache hit (reuses getStockLevel)
service.getStockLevel("SKU-BETA"); // warehouse API call (different SKU)
}
}
How Calls Flow
Understanding the runtime behavior helps to see why this pattern is powerful:
The proxy has three hooks: before the call (validation, auth, cache check), the call itself (forwarded or not), and after the call (logging, caching the result).
A Composed Real-World Example
In practice, you often layer multiple proxy concerns together. Here's a service that combines logging, caching, and access control:
public interface ShipmentService {
String getShipmentStatus(String trackingId);
void cancelShipment(String trackingId);
}
// The real implementation — connects to the logistics provider
public class LogisticsProviderService implements ShipmentService {
@Override
public String getShipmentStatus(String trackingId) {
System.out.println("[Logistics API] Fetching status for: " + trackingId);
return "IN_TRANSIT"; // simulated response
}
@Override
public void cancelShipment(String trackingId) {
System.out.println("[Logistics API] Cancelling shipment: " + trackingId);
}
}
public enum UserRole { CUSTOMER, SUPPORT_AGENT, ADMIN }
// Proxy that layers: role check + caching + audit log in one place
public class SecureCachedShipmentProxy implements ShipmentService {
private final ShipmentService delegate;
private final UserRole callerRole;
private final Map<String, String> statusCache = new HashMap<>();
public SecureCachedShipmentProxy(ShipmentService delegate, UserRole callerRole) {
this.delegate = delegate;
this.callerRole = callerRole;
}
@Override
public String getShipmentStatus(String trackingId) {
// Caching layer
if (statusCache.containsKey(trackingId)) {
System.out.println("[Cache] Status hit for: " + trackingId);
return statusCache.get(trackingId);
}
String status = delegate.getShipmentStatus(trackingId);
statusCache.put(trackingId, status);
// Audit log post-call
audit("getShipmentStatus", trackingId);
return status;
}
@Override
public void cancelShipment(String trackingId) {
// Protection layer — customers cannot cancel via API
if (callerRole == UserRole.CUSTOMER) {
throw new SecurityException("Customers cannot cancel shipments directly. Contact support.");
}
delegate.cancelShipment(trackingId);
// Invalidate cache since state has changed
statusCache.remove(trackingId);
// Audit log post-call
audit("cancelShipment", trackingId);
}
private void audit(String operation, String trackingId) {
System.out.printf("[Audit] %s | operation=%s | trackingId=%s%n",
callerRole, operation, trackingId);
}
}
// Client
public class ShipmentController {
public static void main(String[] args) {
ShipmentService customerView = new SecureCachedShipmentProxy(
new LogisticsProviderService(), UserRole.CUSTOMER);
ShipmentService agentView = new SecureCachedShipmentProxy(
new LogisticsProviderService(), UserRole.SUPPORT_AGENT);
customerView.getShipmentStatus("TRK-001"); // hits API, caches
customerView.getShipmentStatus("TRK-001"); // cache hit
customerView.cancelShipment("TRK-001"); // throws SecurityException
agentView.cancelShipment("TRK-001"); // succeeds, audit logged
}
}
Proxy vs. Similar Patterns
It's easy to confuse Proxy with Decorator and Facade. Here's how they differ:
| Concern | Proxy | Decorator | Facade |
|---|---|---|---|
| Interface | Same | Same | Simpler |
| Purpose | Control access | Add behavior | Simplify |
| Client awareness | Unaware | Unaware | Aware (new interface) |
| Object lifecycle | Proxy may own it | Object pre-exists | Subsystem pre-exists |
The Good Parts
Performance without pain: Virtual and caching proxies let you defer expensive operations and avoid redundant work without the calling code knowing anything about it.
Security without scattering: Protection proxies centralize access control. You don't litter permission checks across every consumer of the service.
Transparency: Because the proxy implements the same interface, you can swap it in and out without touching client code. Ideal for testing — swap the real HTTP client for a mock proxy.
Open/Closed principle in action: You extend behavior by wrapping, not modifying. The real subject stays untouched.
The Trade-offs
Indirection cost: Every call goes through an extra layer. For hot paths with no real benefit (no caching, no protection), this is pure overhead.
Response delay: Virtual proxies initialize lazily — but that initialization happens on first access, which can cause a spike in latency at an unpredictable moment (the first caller takes the hit).
Stale cache risk: Caching proxies need invalidation strategies. Get it wrong and clients see stale data with no idea why.
Proliferation: It's tempting to proxy everything. A codebase with proxies-on-proxies-on-proxies becomes hard to trace through mentally. Keep it purposeful.
Debugging difficulty: Stack traces jump through proxy layers. Without good logging, it's hard to know if the proxy or the real subject is causing a bug.
Knowing When to Reach for It
You likely need a Proxy when:
You have an object that's expensive to create, and you don't always need it
You need to control who can do what to an object without modifying the object
You're wrapping a remote service and want local callers to treat it as a native object
You have a repeated expensive operation whose result can be safely reused
You want to add logging, metrics, or tracing around an existing service without modifying it
You probably don't need a Proxy when:
The overhead adds no real-world benefit (the object is cheap, calls are infrequent)
You're adding new behavior rather than controlling access — reach for Decorator instead
You're simplifying a complex system — that's Facade territory
Java's Built-in Dynamic Proxy
Java has a native java.lang.reflect.Proxy mechanism that generates proxy classes at runtime — telling you how fundamental this pattern is to the language itself. Spring AOP, Spring @Transactional, and Hibernate lazy loading all use this under the hood.
Here's how you'd use it to add execution timing to any service interface without writing a dedicated proxy class per interface:
import java.lang.reflect.*;
public interface OrderService {
void placeOrder(String orderId);
String getOrderStatus(String orderId);
}
public class RealOrderService implements OrderService {
@Override
public void placeOrder(String orderId) {
System.out.println("[OrderService] Placing order: " + orderId);
}
@Override
public String getOrderStatus(String orderId) {
System.out.println("[OrderService] Fetching status for: " + orderId);
return "CONFIRMED";
}
}
// A generic timing proxy via InvocationHandler — works for ANY interface
public class TimingInvocationHandler implements InvocationHandler {
private final Object delegate;
public TimingInvocationHandler(Object delegate) {
this.delegate = delegate;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
long start = System.nanoTime();
try {
return method.invoke(delegate, args);
} finally {
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
System.out.printf("[Timing] %s() took %d ms%n", method.getName(), elapsedMs);
}
}
}
// Client
public class Main {
public static void main(String[] args) {
OrderService real = new RealOrderService();
// Java generates a Proxy class at runtime — no boilerplate proxy class needed
OrderService timedProxy = (OrderService) Proxy.newProxyInstance(
real.getClass().getClassLoader(),
new Class<?>[]{ OrderService.class },
new TimingInvocationHandler(real)
);
timedProxy.placeOrder("ORD-42");
timedProxy.getOrderStatus("ORD-42");
}
}
This is how Spring's @Transactional works — Spring wraps your @Service bean in a dynamic proxy, intercepts method calls, opens a transaction before, and commits or rolls back after. You write plain Java; the proxy does the rest.
The Mental Model
If you take nothing else from this: the Proxy pattern is about one object speaking on behalf of another. The real subject doesn't move, doesn't change, doesn't know it's being proxied. The proxy stands at the door — checking credentials, returning cached answers, delaying the work, measuring the time — and only calls through when it needs to.
The pattern earns its keep when the interception has value — when there's something real to check, delay, log, or cache. Applied without purpose, it's just indirection for indirection's sake. Applied well, it's one of the cleanest ways to add cross-cutting behavior to a system without spreading that behavior everywhere.






