Skip to content
Commitwise
Menu
Design Patterns

Proxy

Put a stand-in with the same interface in front of an object to load it lazily, check permissions, cache remote calls or hide that it lives on another machine, and avoid the classic proxy bugs, from cache keys that mix up users to proxies that frameworks silently bypass, in Swift, Kotlin, Java and TypeScript.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The document list shows 200 file names, and opening it takes eight seconds, because each Document object reads its whole file from disk the moment it’s created, even though the list only needs the titles. The fix doesn’t touch the list screen or the Document class: put a lightweight stand-in in front of each document that answers “what’s your title?” itself and loads the real file only when someone actually opens it.

That stand-in is a Proxy: an object with the same interface as the real one, which controls when and whether calls reach it.

New to some of these? Open what you need, or skip ahead.

Basics:What is an interface (or protocol)?

An interface (Kotlin, Java, TypeScript) or protocol (Swift) is a list of things a type promises it can do, without saying how. Code that only knows the interface can be given any class that implements it, so a proxy and the real object can be swapped without the caller noticing.

Basics:What is lazy loading?

Lazy loadingLazy loadingLoading related data only when it is first used, instead of together with the object that refers to it.Open in glossary → means not doing expensive work (reading a file, downloading an image, querying a database) until the result is actually needed. If it’s never needed, it never happens.

Part Not started

Standing in for the real thing

A celebrity’s assistant answers the phone with the same “Hello, this is Ana’s line” every time. Simple questions (“Is she in town next week?”) the assistant answers alone; requests from strangers never get through; only the important calls reach Ana herself. The caller talks to the same number either way.

A proxy works the same way. It has exactly the same methods as the real object, so code that uses it doesn’t know (or care) which one it has. Inside each method, the proxy decides: answer by itself, refuse, or pass the call on to the real object.

A Proxy provides a surrogate with the subject’s interface to control access to it. The common variants: virtual (defers creation of an expensive subject), protection (authorization checks), caching (memoizes results), remote (stub for an object in another process: gRPC stubs, Retrofit interfaces, RMI), and smart reference (reference counting, logging, locking). Structurally it’s identical to a Decorator; the intent differs: a decorator adds responsibilities, a proxy manages access to and the lifecycle of the subject, often creating it itself.

protocol Document {
    var title: String { get }
    func content() throws -> String
}

final class FileDocument: Document {               // the real, expensive object
    let title: String
    private let text: String
    init(url: URL) throws {
        text = try String(contentsOf: url, encoding: .utf8)   // reads the whole file
        title = url.deletingPathExtension().lastPathComponent
    }
    func content() throws -> String { text }
}

final class LazyDocument: Document {               // the proxy
    let title: String
    private let url: URL
    private var real: FileDocument?

    init(url: URL) {
        self.url = url
        title = url.deletingPathExtension().lastPathComponent   // cheap: no file access
    }

    func content() throws -> String {
        if real == nil { real = try FileDocument(url: url) }     // created on first use
        return try real!.content()
    }
}
interface Document {
    val title: String
    fun content(): String
}

class FileDocument(file: File) : Document {        // the real, expensive object
    private val text = file.readText()             // reads the whole file
    override val title = file.nameWithoutExtension
    override fun content() = text
}

class LazyDocument(private val file: File) : Document {   // the proxy
    override val title = file.nameWithoutExtension         // cheap: no file access
    private val real by lazy { FileDocument(file) }        // created on first use
    override fun content() = real.content()
}
public interface Document {
    String title();
    String content();
}

public final class FileDocument implements Document {     // the real, expensive object
    private final String title;
    private final String text;
    public FileDocument(Path path) throws IOException {
        this.text = Files.readString(path);                // reads the whole file
        this.title = path.getFileName().toString().replaceFirst("\\.[^.]+$", "");
    }
    public String title() { return title; }
    public String content() { return text; }
}

public final class LazyDocument implements Document {     // the proxy
    private final Path path;
    private FileDocument real;
    public LazyDocument(Path path) { this.path = path; }

    public String title() { return path.getFileName().toString().replaceFirst("\\.[^.]+$", ""); }

    public synchronized String content() {
        if (real == null) {
            try { real = new FileDocument(path); }          // created on first use
            catch (IOException e) { throw new UncheckedIOException(e); }
        }
        return real.content();
    }
}
interface Document {
  readonly title: string;
  content(): Promise<string>;
}

class FileDocument implements Document {          // the real, expensive object
  private constructor(readonly title: string, private readonly text: string) {}
  static async load(path: string): Promise<FileDocument> {
    const text = await fs.readFile(path, "utf8");   // reads the whole file
    return new FileDocument(basename(path, extname(path)), text);
  }
  async content(): Promise<string> { return this.text; }
}

class LazyDocument implements Document {          // the proxy
  readonly title: string;
  private real?: Promise<FileDocument>;
  constructor(private readonly path: string) {
    this.title = basename(path, extname(path));   // cheap: no file access
  }
  async content(): Promise<string> {
    this.real ??= FileDocument.load(this.path);   // created on first use
    return (await this.real).content();
  }
}
  • Same interface: the list screen works with Document, and gets LazyDocuments. It shows 200 titles instantly, because titles come from the file name, not the file.
  • On first use of content(), the proxy creates the real FileDocument and passes the call on. Later calls reuse it.
  • Nothing else changed: neither the screen nor FileDocument knows the proxy exists.

Proxies you already use

  • ORM lazy associations: order.customer in Hibernate or JPA is a generated subclass that loads the row on first access.
  • Remote stubs: a Retrofit interface, a gRPC client stub or a Ktor/OpenAPI-generated client makes a network call look like a method call.
  • Spring AOP: @Transactional, @Cacheable and @Async beans are proxies that run logic around your method.
  • JavaScript Proxy, Swift @dynamicMemberLookup wrappers and Kotlin’s by delegation make generic proxies possible without writing each method.

Predict the output

The list creates 200 LazyDocument objects and shows their titles. The user opens one document and reads it twice. How many files are read from disk?

  • Same interface as the subject; controls access and lifecycle. Variants: virtual (lazy creation), protection (authz), caching, remote (stubs), smart reference.
  • Structurally a Decorator; intent differs (access and lifecycle vs added responsibilities).
  • In the wild: ORM lazy associations, Retrofit/gRPC stubs, Spring AOP proxies, JS Proxy, Kotlin by delegation.

Check this part

  1. What does a proxy have in common with the real object?

  2. What does a virtual (lazy) proxy do?

  3. Proxy and Decorator have the same structure. What differs?

A proxy implements the same interface as the real object and decides what happens before a call reaches it: create the object only when needed (virtual proxy), check permissions (protection proxy), cache results (caching proxy), or forward the call over the network (remote proxy). Callers can't tell the difference.

Part Not started

Protection and caching proxies

Two more jobs a proxy is good at:

  • Protection: before passing a call on, check that the caller is allowed to make it. The real class stays simple; the rules live in one place.
  • Caching: remember the answer to a slow call (usually over the network) and give it back next time without asking again.

Both keep the same interface, so the rest of the app doesn’t change when you add one.

Protection proxies centralize authorization at a boundary but must enforce it on every path to the subject (a public getter that returns the unwrapped subject defeats it). Caching proxies need a correct key (everything the result depends on, including the user and locale), a freshness policy (TTL, invalidation on writes through the same proxy), negative caching decisions, and request coalescing so concurrent misses don’t stampede the backend.

protocol ProductCatalog {
    func product(id: String) async throws -> Product
}

actor CachedCatalog: ProductCatalog {
    private let remote: ProductCatalog
    private var cache: [String: (product: Product, storedAt: Date)] = [:]
    private let maxAge: TimeInterval = 300

    init(remote: ProductCatalog) { self.remote = remote }

    func product(id: String) async throws -> Product {
        if let hit = cache[id], Date().timeIntervalSince(hit.storedAt) < maxAge { return hit.product }
        let product = try await remote.product(id: id)
        cache[id] = (product, Date())
        return product
    }
}
interface ProductCatalog {
    suspend fun product(id: String): Product
}

class CachedCatalog(
    private val remote: ProductCatalog,
    private val clock: Clock = Clock.systemUTC(),
) : ProductCatalog {
    private data class Entry(val product: Product, val storedAt: Instant)
    private val cache = ConcurrentHashMap<String, Entry>()
    private val maxAge = Duration.ofMinutes(5)

    override suspend fun product(id: String): Product {
        cache[id]?.takeIf { Duration.between(it.storedAt, clock.instant()) < maxAge }?.let { return it.product }
        return remote.product(id).also { cache[id] = Entry(it, clock.instant()) }
    }
}
public interface ProductCatalog {
    Product product(String id);
}

public final class CachedCatalog implements ProductCatalog {
    private record Entry(Product product, Instant storedAt) {}
    private final ProductCatalog remote;
    private final Map<String, Entry> cache = new ConcurrentHashMap<>();
    private final Duration maxAge = Duration.ofMinutes(5);

    public CachedCatalog(ProductCatalog remote) { this.remote = remote; }

    public Product product(String id) {
        Entry hit = cache.get(id);
        if (hit != null && Duration.between(hit.storedAt(), Instant.now()).compareTo(maxAge) < 0) {
            return hit.product();
        }
        Product product = remote.product(id);
        cache.put(id, new Entry(product, Instant.now()));
        return product;
    }
}
interface ProductCatalog {
  product(id: string): Promise<Product>;
}

class CachedCatalog implements ProductCatalog {
  private cache = new Map<string, { product: Product; storedAt: number }>();
  private readonly maxAgeMs = 5 * 60_000;

  constructor(private readonly remote: ProductCatalog) {}

  async product(id: string): Promise<Product> {
    const hit = this.cache.get(id);
    if (hit && Date.now() - hit.storedAt < this.maxAgeMs) return hit.product;
    const product = await this.remote.product(id);
    this.cache.set(id, { product, storedAt: Date.now() });
    return product;
  }
}
  • Same interface ProductCatalog: screens get CachedCatalog(remote: ApiCatalog()) and don’t know about the cache.
  • Freshness: a cached product is used for 5 minutes, then fetched again, so prices don’t stay stale forever.
  • In Swift, the cache is an actor so two tasks can’t change the dictionary at the same time.
Spot the bugSomeone else's prices

Tap the line with the bug.

actor CachedPricing: Pricing {
    private let remote: Pricing
    private var cache: [String: Price] = [:]
    init(remote: Pricing) { self.remote = remote }

    func price(productId: String, for user: User) async throws -> Price {
        if let hit = cache[productId] { return hit }
        let price = try await remote.price(productId: productId, for: user)
        cache[productId] = price
        return price
    }
}
class CachedPricing(private val remote: Pricing) : Pricing {
    private val cache = ConcurrentHashMap<String, Price>()

    override suspend fun price(productId: String, user: User): Price {
        cache[productId]?.let { return it }
        return remote.price(productId, user).also { cache[productId] = it }
    }
}
public final class CachedPricing implements Pricing {
    private final Pricing remote;
    private final Map<String, Price> cache = new ConcurrentHashMap<>();
    public CachedPricing(Pricing remote) { this.remote = remote; }

    public Price price(String productId, User user) {
        return cache.computeIfAbsent(productId, id -> remote.price(id, user));
    }
}
class CachedPricing implements Pricing {
  private cache = new Map<string, Price>();
  constructor(private readonly remote: Pricing) {}

  async price(productId: string, user: User): Promise<Price> {
    const hit = this.cache.get(productId);
    if (hit) return hit;
    const price = await this.remote.price(productId, user);
    this.cache.set(productId, price);
    return price;
  }
}

Exerciseeasy

A protection proxy

OrderService has cancel(orderId:) and refund(orderId:amount:). Support agents may cancel orders; only supervisors may refund. Write a proxy with the same interface that checks the current user’s role and throws PermissionDenied otherwise.

›

The proxy needs the real service and a way to get the current user. Each method checks first, then forwards.

SolutionTry it yourself first
struct GuardedOrderService: OrderService {
    let real: OrderService
    let currentUser: () -> User

    func cancel(orderId: String) async throws {
        guard currentUser().role >= .agent else { throw PermissionDenied() }
        try await real.cancel(orderId: orderId)
    }

    func refund(orderId: String, amount: Money) async throws {
        guard currentUser().role >= .supervisor else { throw PermissionDenied() }
        try await real.refund(orderId: orderId, amount: amount)
    }
}
class GuardedOrderService(
    private val real: OrderService,
    private val currentUser: () -> User,
) : OrderService {
    override suspend fun cancel(orderId: String) {
        if (currentUser().role < Role.AGENT) throw PermissionDenied()
        real.cancel(orderId)
    }

    override suspend fun refund(orderId: String, amount: Money) {
        if (currentUser().role < Role.SUPERVISOR) throw PermissionDenied()
        real.refund(orderId, amount)
    }
}
public final class GuardedOrderService implements OrderService {
    private final OrderService real;
    private final Supplier<User> currentUser;

    public GuardedOrderService(OrderService real, Supplier<User> currentUser) {
        this.real = real; this.currentUser = currentUser;
    }

    public void cancel(String orderId) {
        if (currentUser.get().role().compareTo(Role.AGENT) < 0) throw new PermissionDenied();
        real.cancel(orderId);
    }

    public void refund(String orderId, Money amount) {
        if (currentUser.get().role().compareTo(Role.SUPERVISOR) < 0) throw new PermissionDenied();
        real.refund(orderId, amount);
    }
}
class GuardedOrderService implements OrderService {
  constructor(private readonly real: OrderService, private readonly currentUser: () => User) {}

  async cancel(orderId: string): Promise<void> {
    if (rank(this.currentUser().role) < rank("agent")) throw new PermissionDenied();
    await this.real.cancel(orderId);
  }

  async refund(orderId: string, amount: Money): Promise<void> {
    if (rank(this.currentUser().role) < rank("supervisor")) throw new PermissionDenied();
    await this.real.refund(orderId, amount);
  }
}

Reading the current user on every call (not once in the constructor) means a role change or a different signed-in user is respected immediately. In a client app, such a proxy is for a good user experience only; the server must enforce the same rules, since anyone can call the API directly.

Production puzzles

Spot the bugFifty requests for one product

Tap the line with the bug.

func product(id: String) async throws -> Product {
    if let hit = cache[id], hit.isFresh { return hit.product }
    let product = try await remote.product(id: id)
    cache[id] = Entry(product)
    return product
}
override suspend fun product(id: String): Product {
    cache[id]?.takeIf { it.isFresh }?.let { return it.product }
    val product = remote.product(id)
    cache[id] = Entry(product)
    return product
}
public Product product(String id) {
    Entry hit = cache.get(id);
    if (hit != null && hit.isFresh()) return hit.product();
    Product product = remote.product(id);
    cache.put(id, new Entry(product));
    return product;
}
async product(id: string): Promise<Product> {
  const hit = this.cache.get(id);
  if (hit?.isFresh) return hit.product;
  const product = await this.remote.product(id);
  this.cache.set(id, new Entry(product));
  return product;
}

  • Protection proxies: enforce on every path to the subject; client-side checks are UX, the server enforces.
  • Caching proxies: key = everything the result depends on (user group, locale, version); TTL or write-through invalidation; decide on negative caching.
  • Coalesce concurrent misses (store the in-flight task/future/promise); actors don’t prevent it across await. Prefer Caffeine async caches, @Cacheable(sync = true), DataLoader.

Check this part

  1. A caching proxy keys prices by product id only, but prices depend on the user. What goes wrong?

  2. Is a permission-checking proxy in a mobile app enough to protect refunds?

  3. How do you stop 50 concurrent cache misses from making 50 remote calls?

Protection proxies check permissions before forwarding a call; caching proxies return stored results. Both live or die by their keys and their scope: cache per user when results depend on the user, deduplicate concurrent misses, and decide how long a cached answer may be used.

Part Not started

When frameworks make the proxy

Many frameworks quietly create proxies for you. When you call a method on a Spring service with @Transactional, you’re actually calling a proxy that opens a transaction and then calls your method. When you read order.customer from Hibernate, you may get a proxy that loads the customer from the database the first time you touch it.

This is convenient, but there’s one rule to remember: the proxy only works when the call goes through the proxy.

Generated proxies come in two flavors: interface-based (JDK dynamic proxies, Retrofit, gRPC stubs) and subclass-based (CGLIB/ByteBuddy for Spring and Hibernate; Kotlin needs the allopen/spring plugins because classes are final by default). Consequences: self-invocation (this.method()) bypasses the proxy; final or private methods can’t be intercepted; the runtime class is a generated subclass (getClass(), equals with class checks and serialization surprises); and lazy proxies outlive their session (LazyInitializationException). In JavaScript, Proxy objects intercept property access generically, which is how Vue’s reactivity and MobX observe state.

Production puzzles

Predict the output

A Spring service has a public method importAll() that loops and calls this.importOne(row) for each row. importOne is annotated @Transactional(propagation = REQUIRES_NEW). One row fails halfway. What is committed?

Other proxy surprises

  • Hibernate lazy proxies: order.getCustomer().getClass() is a generated subclass, so equals implementations using getClass() != o.getClass() say a customer isn’t equal to itself; use instanceof and compare ids through getters. Touching a lazy proxy after the session closed throws LazyInitializationException.
  • Kotlin + Spring: without the kotlin-spring (allopen) plugin, @Transactional on a final class or method silently does nothing or fails at startup.
  • Remote proxies hide latency: api.user(id) looks like a cheap call. In loops, it’s N network round trips; batch endpoints or DataLoader-style batching fix the N+1.
  • Vue/MobX reactivity works through JS Proxy; destructuring a reactive object copies values out of the proxy, so updates stop reaching the UI.

Practice: spot the proxies

  1. In a Spring app, set a breakpoint in a @Transactional method and look at the call stack: you’ll see CglibAopProxy and TransactionInterceptor between the caller and your method.
  2. In a Hibernate app, print order.getCustomer().getClass() for a lazy association: the name ends in something like $HibernateProxy$….
  3. In an Android app, retrofit.create(ShopApi::class.java).javaClass is a $Proxy class generated at runtime.

Knowing that a proxy is there explains most “the annotation doesn’t work” and “this object isn’t what I expected” moments.

  • Interface-based (JDK proxies, Retrofit, gRPC) vs subclass-based (CGLIB/ByteBuddy: Spring, Hibernate).
  • Self-invocation, final/private methods bypass interception; Kotlin needs allopen/kotlin-spring.
  • Lazy ORM proxies: generated runtime class (instanceof, id-based equals), LazyInitializationException outside the session.
  • Remote stubs hide latency (N+1 calls); JS Proxy powers reactivity (don’t destructure reactive objects).

Check this part

  1. Why does @Transactional on a method do nothing when called as this.method() from the same class?

  2. Why can getClass()-based equals fail for Hibernate entities?

Frameworks generate proxies for you: ORM lazy associations, Spring's @Transactional and @Cacheable, remote client stubs. They work only for calls that go through the proxy, and the proxy isn't exactly the real class, which explains a family of surprising bugs.

Key takeaways

  • A proxy has the same interface as the real object and decides when and whether calls reach it.
  • Virtual proxies create expensive objects only when needed; protection proxies check permissions; caching proxies remember answers.
  • A cache key must include everything the answer depends on.
  • Framework proxies (Spring, Hibernate) only work when the call goes through them.
  • Proxy vs Decorator: same structure, different intent (access and lifecycle vs added behavior).
  • Caching proxies need correct keys, freshness and request coalescing.
  • Authorization in client proxies is UX; enforce on the server.
  • Know the generated-proxy traps: self-invocation, final classes, runtime subclasses, lazy loading outside sessions.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.