Proxy
A proxy stands in for a real object, with the same interface, and decides whether, when and how the real one is reached. This page puts a caching proxy in front of Pedal's live dock counts and a protection proxy in front of force-unlock, then lets you choose how stale the map may be.
1. The pain
The map in Pedal's app shows how many bikes are free at each station. Finding out means asking every dock at the station over its radio link, which takes a second and competes with unlocks for the same link. During the morning rush the map asks about popular stations hundreds of times a minute, and unlocks start to lag.
A second problem sits nearby. DockControl.force_unlock() exists for operators freeing stuck bikes, but nothing stops any logged-in user's request from reaching it. Neither fix belongs inside the real classes: the availability service should keep asking docks honestly, and the dock controller shouldn't know about user roles.
2. The idea in one picture
Proxy is a stand-in with the same interface as the real object that controls access to it. Callers can't tell they're talking to a proxy. Common kinds: a caching proxy (answer repeated questions from memory), a protection proxy (check permissions first), a virtual proxy (create the expensive real object only when first used) and a remote proxy (make an object on another machine look local).
A proxy looks exactly like a decorator in code: a class wrapping another with the same interface. The difference is intent. A decorator adds behaviour the caller wants and usually stacks; a proxy decides whether, when and how the real object is reached.
3. The roles
| Role | Its job | In Pedal |
|---|---|---|
| Subject | The interface shared by the real object and its proxy. | Availability, DockControl |
| Real subject | The object that does the work. | LiveAvailability, RealDockControl |
| Proxy | Same interface; controls when and whether the real subject is called. | CachedAvailability (30 s), OperatorsOnly |
| Client | Uses the subject interface, unaware of the proxy. | The map screen; the support console |
4. Before and after
Both concerns both ways (Python sketch; the full program is in section 5).
Without the pattern
class LiveAvailability:
def free_bikes(self, station):
# caching mixed into the network code
if self.fresh(station):
return self.saved[station]
...
class RealDockControl:
def force_unlock(self, user, dock):
# permissions mixed into hardware code
if roles[user] != "operator":
return "refused"
...With it
avail = CachedAvailability(
LiveAvailability(), clock,
ttl_seconds=30)
docks = OperatorsOnly(
RealDockControl(), roles)
# callers still see Availability
# and DockControl
avail.free_bikes("S-12")| The next change | Without the pattern | With it |
|---|---|---|
| Unlock needs a live count, not a cached one | A flag through every call | Unlock uses LiveAvailability directly |
| Support staff may force-unlock too | Edit hardware code | Edit the role check in OperatorsOnly |
| Test the dock controller | Set up roles first | Test RealDockControl on its own |
Access control lives next to the real object, not inside it, so each can be changed or left out on its own.
5. The code
A caching proxy with a 30-second lifetime in front of live availability, driven by a fake clock, and a protection proxy that lets operators force-unlock a dock but refuses riders.
from __future__ import annotations
from abc import ABC, abstractmethod
class Clock:
"""A fake clock, so the demo is repeatable."""
def __init__(self) -> None:
self.now = 0
# ---- Caching proxy: same interface as the real service, stands in front of it. ----
class Availability(ABC):
@abstractmethod
def free_bikes(self, station: str) -> int: ...
class LiveAvailability(Availability):
"""The real thing: asks every dock at the station over the network."""
def __init__(self) -> None:
self.counts = {"S-12": 7}
self.lookups = 0
def free_bikes(self, station: str) -> int:
self.lookups += 1
return self.counts[station]
class CachedAvailability(Availability):
def __init__(self, real: Availability, clock: Clock, ttl_seconds: int) -> None:
self.real, self.clock, self.ttl = real, clock, ttl_seconds
self.saved: dict[str, tuple[int, int]] = {} # station -> (count, when)
def free_bikes(self, station: str) -> int:
hit = self.saved.get(station)
if hit is None or self.clock.now - hit[1] >= self.ttl:
self.saved[station] = (self.real.free_bikes(station), self.clock.now)
return self.saved[station][0]
# ---- Protection proxy: same interface again, checks who is asking first. ----
class DockControl(ABC):
@abstractmethod
def force_unlock(self, user: str, dock: str) -> str: ...
class RealDockControl(DockControl):
def force_unlock(self, user: str, dock: str) -> str:
return f"{dock} unlocked"
class OperatorsOnly(DockControl):
def __init__(self, real: DockControl, roles: dict[str, str]) -> None:
self.real, self.roles = real, roles
def force_unlock(self, user: str, dock: str) -> str:
if self.roles.get(user) != "operator":
return "refused, operators only"
return self.real.force_unlock(user, dock)
if __name__ == "__main__":
clock, live = Clock(), LiveAvailability()
avail: Availability = CachedAvailability(live, clock, ttl_seconds=30)
for t in [0, 10, 25, 40]:
clock.now = t
if t == 25:
live.counts["S-12"] = 6 # a rider took a bike at t=20s
before = live.lookups
n = avail.free_bikes("S-12")
where = "asked the docks" if live.lookups > before else "from the cache"
stale = f"; the docks now say {live.counts['S-12']}" if n != live.counts["S-12"] else ""
print(f"t={t}s: S-12 has {n} free bikes ({where}{stale})")
print(f"dock lookups: {live.lookups} for 4 requests")
roles = {"ana": "operator", "mia": "rider"}
docks: DockControl = OperatorsOnly(RealDockControl(), roles)
for user in ["ana", "mia"]:
print(f"force_unlock by {user} ({roles[user]}): {docks.force_unlock(user, 'D7')}")
// A fake clock, so the demo is repeatable.
class Clock { now = 0; }
// ---- Caching proxy: same interface as the real service, stands in front of it. ----
interface Availability { freeBikes(station: string): number; }
class LiveAvailability implements Availability { // the real thing: asks every dock over the network
readonly counts: Record<string, number> = { "S-12": 7 };
lookups = 0;
freeBikes(station: string): number { this.lookups++; return this.counts[station]; }
}
class CachedAvailability implements Availability {
private readonly saved = new Map<string, [number, number]>(); // station -> [count, when]
constructor(private readonly real: Availability, private readonly clock: Clock, private readonly ttlSeconds: number) {}
freeBikes(station: string): number {
const hit = this.saved.get(station);
if (!hit || this.clock.now - hit[1] >= this.ttlSeconds) this.saved.set(station, [this.real.freeBikes(station), this.clock.now]);
return this.saved.get(station)![0];
}
}
// ---- Protection proxy: same interface again, checks who is asking first. ----
interface DockControl { forceUnlock(user: string, dock: string): string; }
class RealDockControl implements DockControl {
forceUnlock(user: string, dock: string): string { void user; return `${dock} unlocked`; }
}
class OperatorsOnly implements DockControl {
constructor(private readonly real: DockControl, private readonly roles: Record<string, string>) {}
forceUnlock(user: string, dock: string): string {
if (this.roles[user] !== "operator") return "refused, operators only";
return this.real.forceUnlock(user, dock);
}
}
const clock = new Clock(), live = new LiveAvailability();
const avail: Availability = new CachedAvailability(live, clock, 30);
for (const t of [0, 10, 25, 40]) {
clock.now = t;
if (t === 25) live.counts["S-12"] = 6; // a rider took a bike at t=20s
const before = live.lookups;
const n = avail.freeBikes("S-12");
const where = live.lookups > before ? "asked the docks" : "from the cache";
const stale = n !== live.counts["S-12"] ? `; the docks now say ${live.counts["S-12"]}` : "";
console.log(`t=${t}s: S-12 has ${n} free bikes (${where}${stale})`);
}
console.log(`dock lookups: ${live.lookups} for 4 requests`);
const roles: Record<string, string> = { ana: "operator", mia: "rider" };
const docks: DockControl = new OperatorsOnly(new RealDockControl(), roles);
for (const user of ["ana", "mia"]) console.log(`force_unlock by ${user} (${roles[user]}): ${docks.forceUnlock(user, "D7")}`);import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class Main {
// A fake clock, so the demo is repeatable.
static class Clock { int now; }
// ---- Caching proxy: same interface as the real service, stands in front of it. ----
interface Availability { int freeBikes(String station); }
static class LiveAvailability implements Availability { // the real thing: asks every dock over the network
final Map<String, Integer> counts = new HashMap<>(Map.of("S-12", 7));
int lookups;
public int freeBikes(String station) { lookups++; return counts.get(station); }
}
static class CachedAvailability implements Availability {
private final Availability real;
private final Clock clock;
private final int ttlSeconds;
private final Map<String, int[]> saved = new HashMap<>(); // station -> {count, when}
CachedAvailability(Availability real, Clock clock, int ttlSeconds) { this.real = real; this.clock = clock; this.ttlSeconds = ttlSeconds; }
public int freeBikes(String station) {
int[] hit = saved.get(station);
if (hit == null || clock.now - hit[1] >= ttlSeconds) saved.put(station, new int[] {real.freeBikes(station), clock.now});
return saved.get(station)[0];
}
}
// ---- Protection proxy: same interface again, checks who is asking first. ----
interface DockControl { String forceUnlock(String user, String dock); }
static class RealDockControl implements DockControl {
public String forceUnlock(String user, String dock) { return dock + " unlocked"; }
}
static class OperatorsOnly implements DockControl {
private final DockControl real;
private final Map<String, String> roles;
OperatorsOnly(DockControl real, Map<String, String> roles) { this.real = real; this.roles = roles; }
public String forceUnlock(String user, String dock) {
if (!"operator".equals(roles.get(user))) return "refused, operators only";
return real.forceUnlock(user, dock);
}
}
public static void main(String[] args) {
Clock clock = new Clock();
LiveAvailability live = new LiveAvailability();
Availability avail = new CachedAvailability(live, clock, 30);
for (int t : new int[] {0, 10, 25, 40}) {
clock.now = t;
if (t == 25) live.counts.put("S-12", 6); // a rider took a bike at t=20s
int before = live.lookups;
int n = avail.freeBikes("S-12");
String where = live.lookups > before ? "asked the docks" : "from the cache";
String stale = n != live.counts.get("S-12") ? "; the docks now say " + live.counts.get("S-12") : "";
System.out.println("t=" + t + "s: S-12 has " + n + " free bikes (" + where + stale + ")");
}
System.out.println("dock lookups: " + live.lookups + " for 4 requests");
Map<String, String> roles = Map.of("ana", "operator", "mia", "rider");
DockControl docks = new OperatorsOnly(new RealDockControl(), roles);
for (String user : List.of("ana", "mia")) System.out.println("force_unlock by " + user + " (" + roles.get(user) + "): " + docks.forceUnlock(user, "D7"));
}
}Output (the same in all three languages)
t=0s: S-12 has 7 free bikes (asked the docks) t=10s: S-12 has 7 free bikes (from the cache) t=25s: S-12 has 7 free bikes (from the cache; the docks now say 6) t=40s: S-12 has 6 free bikes (asked the docks) dock lookups: 2 for 4 requests force_unlock by ana (operator): D7 unlocked force_unlock by mia (rider): refused, operators only
6. Try it
Choose the cache time
The map asks about station S-12 many times a minute; bikes come and go. A longer cache time means fewer dock lookups but older answers.
Simplified: bike changes arrive at random at the chosen rate; an answer is out of date if the count changed since it was cached.
7. When not to use it
What you get
- Callers don't change: the proxy has the real interface.
- Real object stays focused: no caching or permission code inside it.
- Control in one place: laziness, caching or checks applied consistently.
- Easy to remove: use the real object directly where control isn't wanted.
What you pay
- Surprise behaviour: callers may not expect stale or refused answers from what looks like the real thing.
- Another hop: a layer to step through when debugging.
- Cache problems come with it: staleness, invalidation and memory.
- Leaky remoteness: a remote proxy can't hide latency and network failure.
| If… | Use instead |
|---|---|
| You want to add stackable behaviour the caller asked for | 07 Decorator |
| The interface itself doesn't fit | 06 Adapter |
| Permissions apply to every request in a web app | Framework middleware or a gateway |
| Freshness matters more than load | No cache: ask the real object |
8. How it goes wrong
RealDockControl without OperatorsOnly, the check is decoration, not protection.9. Remember it and say it
How this hook works: what “The agent answers first” means
What the slogan means, phrase by phrase
- “The agent”
- The proxy.
- “answers first”
- Every call reaches the proxy before the real object.
- “whether the real person is disturbed”
- Cache, permission check, or lazy creation.
Why this picture fits: An agent is a stand-in you reach the same way as the person, but with judgement about access.
The story → the concept
| The agent | The code |
|---|---|
| The celebrity | LiveAvailability, RealDockControl |
| "Tuesdays are booked" from memory | A cached free-bike count |
| Turning away a stranger | OperatorsOnly refusing a rider |
| Same phone number | Same interface |
Watch it on an example
- The map asks for S-12's free bikes at t=0: the proxy asks the docks.
- At t=10 and t=25 it answers 7 from memory, though at t=25 the docks would say 6.
- At t=40 the 30-second copy has expired, so it asks again and gets 6.
The trap it saves you from
Putting caching and permission checks inside the classes that talk to hardware.
It still works when…
Yes: ORMs load related records lazily through proxies, RPC clients are remote proxies, and CDNs are caching proxies at web scale.
In an interview or code review
- Name the kind: "A caching proxy in front of dock availability with a 30-second TTL, and a protection proxy on force-unlock."
- Say what can't use it: unlocks read the live count; only the map tolerates staleness.
- Contrast with decorator: same structure, different intent: control access versus add behaviour.
- Real examples: lazy-loading ORM relations, gRPC client stubs, Java's
java.lang.reflect.Proxy.
10. Check yourself
What does a proxy share with the real object?
Cache time 30 s. The count changes at t=20. What does a request at t=25 get?
Which proxy checks who is calling?
Proxy vs decorator: what really differs?
Should the unlock path use the cached availability?
11. Go deeper
| Resource | Why |
|---|---|
| 07 Decorator | Same shape, different intent. |
| System design track | Caching, TTLs and invalidation at system scale. |
Java java.lang.reflect.Proxy | Proxies generated at runtime for any interface. |
| Wikipedia: Proxy pattern | The kinds of proxy and examples. |