intermediate~2h

Proxy Pattern

Learn how the Proxy pattern lets a stand-in object control when an expensive real object actually gets created, enabling lazy loading and caching without the client ever knowing the difference.

Learning objectives

  • Explain why conflating object construction with expensive setup work is wasteful
  • Build a proxy that lazily creates and caches a real object on first use
  • Distinguish Proxy from Decorator by intent despite their nearly identical structure
  • Recognize the thread-safety risk a naive lazy-init proxy has under concurrency

◆ Story

A busy executive doesn't personally answer every incoming call. A receptionist sits in front, screening calls, taking messages, and only actually connecting the genuinely important ones through. From the caller's side, they're still "reaching the office" -- they just don't always reach the executive directly, and that's entirely by design.

This chapter builds that same stand-in idea around an image viewer: an application that creates objects representing large image files, but should only actually load the expensive binary data from disk when an image is genuinely displayed, not the moment the object representing it is created.

◆ The problem

The obvious RealImage class does its expensive work -- loading from disk -- right inside its constructor. That means creating a RealImage object and actually loading its binary data are the same operation; there's no way to have one without the other.

That coupling is the real issue. Creating an object is normally a cheap, ordinary thing to do -- but here, it's inseparable from an expensive disk read that should only happen when the image is genuinely needed. An image that's constructed but never displayed still pays the full cost of that disk read, for zero benefit. And nothing here remembers that an image was already loaded, either -- constructing two separate RealImage objects for the same file loads that file from disk twice, with no caching at all.

💻 Code example

interface Image { void display(); } class RealImage implements Image { private final String fileName; RealImage(String fileName) { this.fileName = fileName; loadImageFromDisk(); // expensive -- runs inside the constructor, unconditionally } private void loadImageFromDisk() { System.out.println("Loading image from disk: " + fileName); } public void display() { System.out.println("Displaying image: " + fileName); } } Image image1 = new RealImage("dog.png"); // "Loading image from disk: dog.png" -- printed immediately Image image2 = new RealImage("cat.png"); // also loaded immediately image1.display(); // dog.png is actually shown // image2 is never displayed at all -- but it was already loaded from disk

An image should be loaded exactly when display() is first called, not the moment the object representing it is created -- this is lazy loading. Once loaded, it shouldn't be loaded again -- a second display() call should reuse the already-loaded data, which is caching. And the client shouldn't need to know any of this is happening -- it should just call display() on something that satisfies the Image contract, same as always.

That's the Proxy pattern: create a class implementing the exact same interface as the real object, standing in for it. Callers interact with the proxy exactly as if it were the real thing, while the proxy decides when -- and whether -- to actually construct and forward to the real object underneath.

ProxyImage holds the file name, but its constructor deliberately does not load anything -- compare that to RealImage's constructor, which loads unconditionally. A second field, realImage, starts out null, and that null is doing real work: it's how the proxy represents "nobody has asked to see this image yet." display() checks that field first. If it's still null, this is the first call, so the proxy constructs the real image now -- which is what actually triggers the disk load -- and remembers it. Either way, it then delegates to realImage.display(). Every call after the first skips construction entirely and just forwards.

💻 Code example

class ProxyImage implements Image { private final String fileName; private RealImage realImage; // null means "not loaded yet" ProxyImage(String fileName) { this.fileName = fileName; // deliberately does NOT load anything } public void display() { if (realImage == null) { realImage = new RealImage(fileName); // first call only -- this triggers the disk load } realImage.display(); // every call, including the first -- delegate the actual display work } }

Creating a ProxyImage is now cheap unconditionally, no matter how many are created -- there's no disk access until display() is actually called on one of them. The table below traces exactly what happens, line by line, once both images exist.

LineRealImage constructed?Disk load happens?
new ProxyImage("dog.png")NoNo
new ProxyImage("cat.png")NoNo
1st image1.display()Yes -- realImage was nullYes -- inside RealImage's constructor
2nd image1.display()No -- reuses the cached instanceNo -- only display() runs
image2 -- never displayedNeverNever

Compare this to the naive version: there, both images were loaded unconditionally at construction. Here, exactly one disk load happens across the entire program -- for dog.png, exactly once, on its first display() call -- and cat.png's load never happens at all, since it was never actually needed.

💻 Code example

public class Main { public static void main(String[] args) { Image image1 = new ProxyImage("dog.png"); // fast -- no disk access, no RealImage yet Image image2 = new ProxyImage("cat.png"); // also fast, for the same reason image1.display(); // "Loading image from disk: dog.png" then "Displaying image: dog.png" image1.display(); // only "Displaying image: dog.png" -- realImage already exists // image2.display() is never called -- cat.png is never loaded from disk at all } }

▲ Edge case — thread safety

ProxyImage.display()'s null-check-then-create has a classic race condition: two threads calling display() on the same ProxyImage at the same time could both see realImage == null and both construct a RealImage, doubling the load and breaking the caching guarantee. The usual fixes are synchronizing the check-and-create, or a double-checked locking pattern, depending on how much contention is actually expected.

▲ Edge case — a proxy adds a real layer of indirection, even on a cache hit

Every call to display(), even once the image is cached, still passes through ProxyImage's null check before reaching RealImage. For most applications this cost is negligible next to what it saves, but it's honest to note that Proxy is never entirely free -- and stacking several proxies, like a logging proxy wrapping a caching proxy wrapping the real object, compounds that overhead further.

▲ Common mistake — confusing Proxy with Decorator

Structurally, a proxy and a decorator look nearly identical: both wrap an object implementing a shared interface. The difference is intent, not structure -- Decorator adds new behavior meant to compose with other decorators; Proxy controls access to the real object and is typically used singly. Ask "am I adding a genuinely new capability" versus "am I controlling how, when, or whether the real object gets used" to tell them apart.

◆ Where this shows up

Image and media loading is this chapter's exact example, and it's common in any UI that shows large media only on demand rather than loading everything up front.

ORM frameworks like Hibernate return proxy objects for related database entities, only firing the actual query when a related field is genuinely accessed -- this is lazy loading applied directly to database access. java.lang.reflect.Proxy is Java's own built-in mechanism for generating proxy classes at runtime, commonly used for exactly this kind of access control and for framework-level interception. Spring's AOP proxies use the same underlying idea to wrap beans with cross-cutting behavior like transaction management, without the wrapped class needing to know anything about it. Protection proxies check permissions before forwarding a call to a sensitive underlying object, and remote proxies represent a service that actually runs on a different machine entirely, hiding the network call behind what looks like an ordinary local method.

Proxy typeWhat it controls
Virtual proxyDelays creating an expensive object until it's genuinely needed -- this chapter's example
Protection proxyChecks permissions before allowing access to the real object
Caching proxyAvoids repeating expensive work for the same request
Remote proxyRepresents an object that actually lives on a different machine

Q: Structurally, how do Proxy and Decorator differ? A: They don't, much -- both wrap an object implementing a shared interface. The real difference is intent: Decorator adds new behavior, often stacked; Proxy controls access to the real object, typically singly.

Q: In ProxyImage, what determines whether RealImage's expensive constructor actually runs? A: Whether display() has been called on that ProxyImage before -- the realImage field starts null and is only constructed on the first display() call, then reused on every call after.

Q: How does this chapter's lazy-loading logic resemble Singleton's lazy initialization? A: Both use the identical "if it's null, create it; otherwise reuse it" shape to delay expensive construction until it's genuinely needed and avoid repeating that cost.

Q: What real bug can this pattern have under concurrency, and why? A: The same race condition a naive Singleton has -- two threads can both see realImage as null at the same time and both construct a RealImage, defeating the caching guarantee unless the check-and-create is properly synchronized.

Q: Name two different flavors of proxy and what each one controls. A: A virtual proxy delays expensive construction; a caching proxy avoids repeating expensive work; a protection proxy checks permissions; a remote proxy hides a network call behind a normal-looking local method.

Want a visual for this concept?

Generate a diagram tailored to “Proxy Pattern” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Composite Pattern← Back to all Low-Level Design & Design Patterns chapters