Proxy
Proxy stands in for another object behind an identical interface, letting it intercept every call to add checks, caching, lazy creation, or logging without the real object or its callers knowing.
Problem
A reporting service wraps a slow, network-bound call to a remote analytics API. Every screen that displays a report re-triggers that call, even when the underlying data hasn't changed in the last five minutes - burning bandwidth and making the UI feel sluggish.
The obvious fix is to add caching, but the service class is generated from an API spec (or shared across teams), so editing it directly means the caching logic gets tangled with unrelated code, or lost on the next regeneration. Meanwhile the service object itself is expensive enough to construct that creating it eagerly at startup - before anyone has even opened a report - wastes resources on every run.
Solution
Introduce a Proxy class that implements the exact same interface as the service and gets substituted for it everywhere the service was expected. Every call to the proxy first passes through its own logic - check the cache, verify permissions, construct the real service on first use - before optionally delegating to the wrapped object.
Because the interface is identical, nothing on the calling side changes: code that used to hold a service reference now holds a proxy reference and is none the wiser. The original service class stays untouched, so its logic and the cross-cutting behavior evolve independently.
When to Use
- A service object is expensive to create, and most execution paths through the application never actually end up needing it.
- Only certain callers should be allowed to invoke a service, and that check needs to live outside the service's own logic.
- Repeated calls with the same arguments return the same result for a meaningful window of time, making caching worthwhile without touching the service's implementation.
Real-World Examples
- **Nginx / HAProxy reverse proxies** - sit in front of application servers, terminating TLS, rate-limiting, and serving cached responses before a request ever reaches the backend.
- **The built-in JavaScript `Proxy` object** - wraps a target object and lets you intercept `get`, `set`, and function-call traps, which is how libraries like Vue 3 implement reactive data.
- **ORM lazy-loaded associations (Hibernate, SQLAlchemy, Entity Framework)** - a related entity is represented by a placeholder proxy object, and the actual SQL query only fires the first time a field on it is accessed.
Structure
A common ServiceInterface is implemented by both the real Service and its Proxy, which is what makes the substitution transparent. The Service focuses purely on its business logic. The Proxy holds a reference to the Service (created eagerly, lazily, or supplied externally) and wraps each call with extra behavior - caching, access checks, logging - before or after delegating. The Client depends only on ServiceInterface and never needs to know which implementation it received.
Participants
Defines the contract that both the real object and its stand-in must honor, making the two interchangeable from the caller's point of view.
Implements the real behavior clients ultimately want. Typically the expensive or sensitive part of the equation - slow to construct, costly to call, or requiring guarded access.
In the diagram: Database Service.
Implements the same interface and forwards calls to a Service instance it owns or creates, inserting its own logic - a cache lookup, a permission check, deferred construction - around the delegation.
In the diagram: Caching Proxy.
Depends only on ServiceInterface, so it can be handed a Proxy in place of the real Service with no code changes on its side.
In the diagram: Client.
class DataService {
fetchData(query) {
throw new Error('fetchData() must be implemented');
}
}
class DatabaseService extends DataService {
fetchData(query) {
console.log(`DatabaseService: executing query "${query}"...`);
return { query, result: `data for "${query}"`, timestamp: Date.now() };
}
}
class CachingProxy extends DataService {
#service;
#cache = new Map();
constructor(service) {
super();
this.#service = service;
}
fetchData(query) {
if (this.#cache.has(query)) {
console.log(`CachingProxy: returning cached result for "${query}"`);
return this.#cache.get(query);
}
const result = this.#service.fetchData(query);
this.#cache.set(query, result);
return result;
}
}
const service = new CachingProxy(new DatabaseService());
console.log(service.fetchData('users'));
console.log(service.fetchData('users'));
console.log(service.fetchData('orders'));
Step 1 of 5
DataService is the shared interface
`fetchData()` just throws - both the real service and the proxy standing in front of it must implement this same method.
Step 2 of 5
DatabaseService is the real, expensive object
`fetchData()` logs that it's actually executing a query and returns a fresh result every single call - this is the work a proxy can intercept.
Step 3 of 5
CachingProxy implements the same interface and wraps the real service
It `extends DataService`, so it's interchangeable with `DatabaseService` from the caller's point of view, and its constructor stores both the wrapped `#service` and a private `#cache` map.
Step 4 of 5
fetchData() checks the cache before touching the real service
If `#cache.has(query)` it returns the cached result immediately without calling `#service.fetchData()` at all; otherwise it fetches, stores the result in `#cache`, and returns it - controlling access to the real object.
Step 5 of 5
The client uses the proxy exactly like the real service
`service` is a `CachingProxy`, but every call is just `service.fetchData(...)` - the second call for `'users'` reuses the cache while `'orders'` triggers a fresh `DatabaseService` query, invisibly to the caller.
interface DataService {
fetchData(query: string): Record<string, unknown>;
}
class DatabaseService implements DataService {
public fetchData(query: string): Record<string, unknown> {
console.log(`DatabaseService: executing query "${query}"...`);
return { query, result: `data for "${query}"`, timestamp: Date.now() };
}
}
class CachingProxy implements DataService {
private cache = new Map<string, Record<string, unknown>>();
constructor(private service: DataService) {}
public fetchData(query: string): Record<string, unknown> {
if (this.cache.has(query)) {
console.log(`CachingProxy: returning cached result for "${query}"`);
return this.cache.get(query)!;
}
const result = this.service.fetchData(query);
this.cache.set(query, result);
return result;
}
}
const service: DataService = new CachingProxy(new DatabaseService());
console.log(service.fetchData('users'));
console.log(service.fetchData('users'));
console.log(service.fetchData('orders'));
import java.util.*;
interface DataService {
Map<String, Object> fetchData(String query);
}
class DatabaseService implements DataService {
public Map<String, Object> fetchData(String query) {
System.out.println("DatabaseService: executing query \"" + query + "\"...");
return Map.of("query", query, "result", "data for \"" + query + "\"");
}
}
class CachingProxy implements DataService {
private final DataService service;
private final Map<String, Map<String, Object>> cache = new HashMap<>();
CachingProxy(DataService service) { this.service = service; }
public Map<String, Object> fetchData(String query) {
if (cache.containsKey(query)) {
System.out.println("CachingProxy: returning cached result for \"" + query + "\"");
return cache.get(query);
}
Map<String, Object> result = service.fetchData(query);
cache.put(query, result);
return result;
}
}
DataService service = new CachingProxy(new DatabaseService());
System.out.println(service.fetchData("users"));
System.out.println(service.fetchData("users"));
System.out.println(service.fetchData("orders"));
interface IDataService
{
Dictionary<string, object> FetchData(string query);
}
class DatabaseService : IDataService
{
public Dictionary<string, object> FetchData(string query)
{
Console.WriteLine($"DatabaseService: executing query \"{query}\"...");
return new() { ["query"] = query, ["result"] = $"data for \"{query}\"" };
}
}
class CachingProxy : IDataService
{
private readonly IDataService _service;
private readonly Dictionary<string, Dictionary<string, object>> _cache = new();
public CachingProxy(IDataService service) => _service = service;
public Dictionary<string, object> FetchData(string query)
{
if (_cache.TryGetValue(query, out var cached))
{
Console.WriteLine($"CachingProxy: returning cached result for \"{query}\"");
return cached;
}
var result = _service.FetchData(query);
_cache[query] = result;
return result;
}
}
IDataService service = new CachingProxy(new DatabaseService());
Console.WriteLine(service.FetchData("users"));
Console.WriteLine(service.FetchData("users"));
Console.WriteLine(service.FetchData("orders"));
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class DataService(ABC):
@abstractmethod
def fetch_data(self, query: str) -> dict[str, Any]:
pass
class DatabaseService(DataService):
def fetch_data(self, query: str) -> dict[str, Any]:
print(f'DatabaseService: executing query "{query}"...')
return {"query": query, "result": f'data for "{query}"'}
class CachingProxy(DataService):
def __init__(self, service: DataService) -> None:
self._service = service
self._cache: dict[str, dict[str, Any]] = {}
def fetch_data(self, query: str) -> dict[str, Any]:
if query in self._cache:
print(f'CachingProxy: returning cached result for "{query}"')
return self._cache[query]
result = self._service.fetch_data(query)
self._cache[query] = result
return result
service: DataService = CachingProxy(DatabaseService())
print(service.fetch_data("users"))
print(service.fetch_data("users"))
print(service.fetch_data("orders"))
Step 1 of 5
DataService is the shared interface
`fetch_data()` is `@abstractmethod` - both the real service and the proxy must implement this same method to satisfy the interface.
Step 2 of 5
DatabaseService is the real, expensive object
`fetch_data()` prints that it's executing a query and builds a fresh result dict every call - this is the work a proxy can intercept.
Step 3 of 5
CachingProxy implements the same interface and wraps the real service
`CachingProxy(DataService)` is interchangeable with `DatabaseService` from the caller's point of view, and `__init__` stores both the wrapped `self._service` and an empty `self._cache` dict.
Step 4 of 5
fetch_data() checks the cache before touching the real service
If `query in self._cache` it returns the cached result immediately without calling `self._service.fetch_data()`; otherwise it fetches, stores the result, and returns it.
Step 5 of 5
The client uses the proxy exactly like the real service
`service` is a `CachingProxy`, but every call is just `service.fetch_data(...)` - the repeated `"users"` call reuses the cache while `"orders"` triggers a fresh `DatabaseService` query, invisibly to the caller.
Click "Run" to execute this code in a sandboxed frame and see console output here.
TypeScript runs as plain JavaScript here - type annotations are stripped, not type-checked.
Click "Run" to execute this code in a sandboxed frame and see console output here.
Advantages
- Cross-cutting concerns like caching, auditing, or throttling can be bolted onto a service without touching its source, keeping unrelated logic out of the business class.
- A virtual proxy can defer creating an expensive object until the moment it's genuinely needed, saving startup time and resources for the common case where it's never touched.
- Because the proxy and the service share an interface, new proxy behaviors can be introduced or swapped without editing the service class or its callers.
- A protection proxy can enforce authorization rules at the exact call boundary, independent of whatever checks (or lack of them) exist inside the service itself.
Disadvantages
- Every call now passes through an extra hop, which adds a small but nonzero latency cost, and can matter in tight loops calling the service repeatedly.
- A stale or overly aggressive cache inside the proxy can silently return outdated results, a class of bug that's easy to introduce and awkward to trace back to the proxy layer.
- The response received by the client might now come from the proxy (a cached value) rather than the service itself, which can be surprising if callers assume they're always hitting live data.
Question 1 of 10
Why doesn't the calling code need to change when a Proxy is substituted for the real service?
Correct! Because the interface is identical, nothing on the calling side changes: code that used to hold a service reference now holds a proxy reference and is none the wiser.
Not quite. Because the interface is identical, nothing on the calling side changes: code that used to hold a service reference now holds a proxy reference and is none the wiser.
Question 2 of 10
In the structure, what is the ServiceInterface's role?
Correct! ServiceInterface defines the contract that both the real object and its stand-in must honor, making the two interchangeable from the caller's point of view.
Not quite. ServiceInterface defines the contract that both the real object and its stand-in must honor, making the two interchangeable from the caller's point of view.
Question 3 of 10
According to the participant roles, what does the Proxy actually do with calls it receives?
Correct! The Proxy implements the same interface and forwards calls to a Service instance it owns or creates, inserting its own logic - a cache lookup, a permission check, deferred construction - around the delegation.
Not quite. The Proxy implements the same interface and forwards calls to a Service instance it owns or creates, inserting its own logic - a cache lookup, a permission check, deferred construction - around the delegation.
Question 4 of 10
In the walkthrough, what does the constructor of CachingProxy store?
Correct! The constructor stores both the wrapped service and a private cache map, which is what lets fetchData() later check the cache before deciding whether to delegate.
Not quite. The constructor stores both the wrapped service and a private cache map, which is what lets fetchData() later check the cache before deciding whether to delegate.
Question 5 of 10
How does a protection proxy differ in purpose from a caching proxy, based on the pros listed?
Correct! A protection proxy can enforce authorization rules at the exact call boundary, independent of whatever checks exist inside the service itself - a distinct concern from the caching proxy's job of storing and reusing results.
Not quite. A protection proxy can enforce authorization rules at the exact call boundary, independent of whatever checks exist inside the service itself - a distinct concern from the caching proxy's job of storing and reusing results.
Question 6 of 10
What tradeoff does introducing a proxy add to every call, according to the listed cons?
Correct! Every call now passes through an extra hop, which adds a small but nonzero latency cost, and can matter in tight loops calling the service repeatedly.
Not quite. Every call now passes through an extra hop, which adds a small but nonzero latency cost, and can matter in tight loops calling the service repeatedly.
Question 7 of 10
In the reporting-service example, what sits between the screen and the slow analytics API without either side noticing?
Correct! A caching proxy, implementing the exact same interface as the real service. Screens keep calling the same methods as before, but the proxy intercepts the calls and serves recent results from cache instead of hitting the network every time.
Not quite. A caching proxy, implementing the exact same interface as the real service. Screens keep calling the same methods as before, but the proxy intercepts the calls and serves recent results from cache instead of hitting the network every time.
Question 8 of 10
Why can't the reporting service's caching logic just be added directly inside the generated service class?
Correct! The service class is generated from an API spec or shared across teams, so editing it directly tangles caching logic with unrelated code or loses it on the next regeneration.
Not quite. The service class is generated from an API spec or shared across teams, so editing it directly tangles caching logic with unrelated code or loses it on the next regeneration.
Question 9 of 10
In the CachingProxy implementation, what happens when fetchData() is called with a query already present in the cache?
Correct! If the cache has the query, fetchData() returns the cached result immediately without calling the wrapped service's fetchData() at all - only a cache miss triggers the real call and stores the fresh result.
Not quite. If the cache has the query, fetchData() returns the cached result immediately without calling the wrapped service's fetchData() at all - only a cache miss triggers the real call and stores the fresh result.
Question 10 of 10
In the reporting service example, why does re-fetching data on every screen make the UI feel sluggish?
Correct! Every screen that displays a report re-triggers the slow, network-bound call to the remote analytics API, even when the underlying data hasn't changed in the last five minutes, burning bandwidth and making the UI feel sluggish.
Not quite. Every screen that displays a report re-triggers the slow, network-bound call to the remote analytics API, even when the underlying data hasn't changed in the last five minutes, burning bandwidth and making the UI feel sluggish.