Skip to main content

Command Palette

Search for a command to run...

Proxy Design Pattern

Updated
•View as Markdown
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.