Proxy
Occasional. Turns up in specific problems. Worth recognising and being able to sketch, not worth drilling.
Start with the problem
corpus/parking-lot's PricingPolicy decides how much a stay costs:
public interface PricingPolicy {
long feeMinor(VehicleType type, Duration stay);
}
Today's implementation, FlatHourlyPricing, is a map lookup. It is fast, and there is nothing wrong with it. Now the lot signs up for surge pricing: the rate carries a multiplier that a pricing service somewhere else updates every few minutes. The surge side of the calculation needs one number from that service, so give it its own small interface rather than folding a network call into PricingPolicy directly.
interface SurgeFeed {
double currentMultiplier();
}
final class RemoteSurgeFeed implements SurgeFeed {
private final SurgeFeedClient client;
RemoteSurgeFeed(SurgeFeedClient client) {
this.client = client;
}
@Override
public double currentMultiplier() {
return client.fetchMultiplier(); // one HTTP round trip, every call
}
}
A pricing policy built on top of this asks currentMultiplier() once per checkout. That is correct, and for a quiet lot it is fine.
Watch where it goes
The lot gets busy. Twenty cars check out in the same minute, and each checkout is a separate HTTP call to a service that only changes its answer once every few minutes. Nineteen of those twenty calls asked a question whose answer had not moved.
The fix that comes to mind first is to add a cache field and a timestamp inside RemoteSurgeFeed itself. That works, but it mixes two jobs into one class: fetching a number over the network, and deciding whether the last answer is still fresh enough to reuse. The next remote source this lot ever adds, a loyalty-discount feed, or a weather-based rate feed, pays for that same freshness logic again, by hand, inside its own class.
The move
Write the caching decision once, as a second implementation of SurgeFeed that holds the real one.
final class CachingSurgeFeed implements SurgeFeed {
private final SurgeFeed real;
private final Duration ttl;
private final Clock clock;
private double cached = 1.0;
private Instant fetchedAt = Instant.MIN;
CachingSurgeFeed(SurgeFeed real, Duration ttl, Clock clock) {
this.real = real;
this.ttl = ttl;
this.clock = clock;
}
@Override
public double currentMultiplier() {
Instant now = clock.instant();
if (now.isBefore(fetchedAt.plus(ttl))) {
return cached;
}
cached = real.currentMultiplier();
fetchedAt = now;
return cached;
}
}
Whoever assembles the lot wires new CachingSurgeFeed(new RemoteSurgeFeed(client), Duration.ofSeconds(30), clock) and hands the result to the pricing policy. Nothing that calls currentMultiplier() can tell it is not talking to the network directly.
This is Proxy. A proxy implements the same interface as the thing it stands in for, holds a reference to that thing, and decides on every call whether to forward it.
GoF names three flavours by what the decision does. A protection proxy checks permission before forwarding. A virtual proxy defers building something expensive until the first real call. A remote or caching proxy, this one, hides the cost of reaching the real answer. All three share the same shape: one interface, one real object, one stand-in that decides.
What modern Java changes here
Most Java teams never write a class like CachingSurgeFeed by hand for this exact case, because a framework already generates one. Spring's @Cacheable and @Transactional, and JPA's lazy-loaded associations, work by handing back a dynamic proxy built at runtime. java.lang.reflect.Proxy or a bytecode generator wraps the real object, and nobody types a class for it. If the team already has that machinery, reaching for a hand-written proxy is usually a sign the annotation was missed, not a design decision.
Lazy construction, the virtual-proxy case, is often a memoized Supplier<T> in current Java, rather than a whole second class implementing the same interface as the thing being deferred. That is smaller than Proxy when the caller only ever needs one lazily-built object, not a full stand-in that can also decide whether to forward later calls.
When naming it is wrong
A wrapper that forwards every call straight through, with no decision anywhere in it, is not a proxy. It is a hop the caller could skip, and the fix is to delete it, not to give it a name from the GoF book.
A wrapper that adds genuinely new behaviour on every call, logging, retries, formatting, rather than deciding whether to forward at all is drifting toward Decorator instead. The distinguishing question is whether more than one of these would ever be stacked. Proxy expects exactly one stand-in in front of one real object. If a second and third wrapper start piling on for independent reasons, that is Decorator's job, not Proxy's, and B7 covers it.
Where this lives in the app
No corpus problem in this app builds a proxy. The anchor given for this page is B7, titled "Chain, decorator, middleware." Reading it confirms that. Its worked example is corpus/middleware-router's access gate in front of /admin, built as a chain link that decides whether a request continues. It is not a class standing in for one specific downstream object.
That gate is the closest thing here in spirit, since it does check access before forwarding. Its shape is Chain of Responsibility though, and B7's own when-not.md is about exactly that seam, not this one.
The SurgeFeed example above was built for this page from PricingPolicy's real signature in corpus/parking-lot. It is not a curveball anywhere in the corpus.