InterviewKata
Design patterns · 09 of 26 · Fitting objects together

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

A celebrity's agent answers the phone for them. To callers, the agent is the way to reach the celebrity: same number, same questions. But the agent can answer common questions from memory ("Tuesdays are booked"), turn away people who shouldn't get through, and only interrupt the real person when it's needed.

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

RoleIts jobIn Pedal
SubjectThe interface shared by the real object and its proxy.Availability, DockControl
Real subjectThe object that does the work.LiveAvailability, RealDockControl
ProxySame interface; controls when and whether the real subject is called.CachedAvailability (30 s), OperatorsOnly
ClientUses 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 changeWithout the patternWith it
Unlock needs a live count, not a cached oneA flag through every callUnlock uses LiveAvailability directly
Support staff may force-unlock tooEdit hardware codeEdit the role check in OperatorsOnly
Test the dock controllerSet up roles firstTest 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.

Map requests a minute
Bikes taken or returned a minute
Cache time (0 = no proxy)

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 for07 Decorator
The interface itself doesn't fit06 Adapter
Permissions apply to every request in a web appFramework middleware or a gateway
Freshness matters more than loadNo cache: ask the real object

8. How it goes wrong

Hiding that answers may be staleIf the map shows "7 free" from a 30-second-old cache, label it or keep the time short. Unlock must never use the cached count.
A remote proxy pretending the network isn't thereA call that looks local but can take seconds and fail needs timeouts and errors callers can handle.
Proxy and real object drifting apartIf the interface gains a method and the proxy doesn't forward it, callers get different behaviour. Share the interface.
Permissions in only some proxiesIf one path reaches RealDockControl without OperatorsOnly, the check is decoration, not protection.

9. Remember it and say it

Memory hook: "The agent answers first"Same phone number, but the agent decides whether the real person is disturbed.
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 agentThe code
The celebrityLiveAvailability, RealDockControl
"Tuesdays are booked" from memoryA cached free-bike count
Turning away a strangerOperatorsOnly refusing a rider
Same phone numberSame interface

Watch it on an example

  1. The map asks for S-12's free bikes at t=0: the proxy asks the docks.
  2. At t=10 and t=25 it answers 7 from memory, though at t=25 the docks would say 6.
  3. 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

10. Check yourself

What does a proxy share with the real object?

Callers can't tell which one they're holding.

Cache time 30 s. The count changes at t=20. What does a request at t=25 get?

That staleness is the price of fewer lookups.

Which proxy checks who is calling?

OperatorsOnly refuses riders before the real controller is reached.

Proxy vs decorator: what really differs?

In code they look almost identical.

Should the unlock path use the cached availability?

Keep the real object reachable where freshness matters.

11. Go deeper

ResourceWhy
07 DecoratorSame shape, different intent.
System design trackCaching, TTLs and invalidation at system scale.
Java java.lang.reflect.ProxyProxies generated at runtime for any interface.
Wikipedia: Proxy patternThe kinds of proxy and examples.