Java design pattern deep dive
Proxy Pattern in Java: 100 interview questions with professional answers.
Learn how the Proxy pattern controls access to an object through a stand-in that implements the exact same interface, when to reach for virtual, protection, remote, or caching proxies, how java.lang.reflect.Proxy and Spring AOP generate proxies at runtime, and how to avoid the bugs that make a proxy leak or stop being substitutable for the real thing.
What makes a good Proxy answer?
Interviewers want to see that you understand access control, not just wrapping syntax: the proxy must expose the identical interface as the real subject, add only the specific control it exists for, and stay substitutable everywhere the real subject is expected.
| Approach | Use when | Watch out for |
|---|---|---|
| Virtual proxy | Constructing the real subject is expensive (large image, JPA association, parsed template) and might never be needed. | Thread-safety of the lazy-init check; a naive if-null test races under concurrent access. |
| Protection proxy | Different callers should see different permission levels on the same object without duplicating checks in every caller. | A proxy that fails open on an unexpected exception instead of denying by default. |
| Remote proxy | The real subject lives in another process or machine and callers should not hand-roll network code. | Hiding partial failure and latency so well that callers forget the call can fail or block. |
| Caching proxy | The same call is repeated with the same arguments and the underlying data does not change on every read. | Stale results after the source changes, and unbounded cache growth without eviction. |
| Dynamic proxy (JDK reflection or CGLIB) | You need the same cross-cutting control (logging, transactions, retries) applied uniformly across many interfaces or classes. | JDK proxies need an interface; CGLIB can't proxy final classes/methods, and self-invocation bypasses both. |
Topics
Interview questions and answers
Each answer gives the implementation direction, the trade-off to mention, and the production concern that makes the answer stronger.
1. Explain the Proxy design pattern in Java: describe the Subject, RealSubject, and Proxy roles, and the real-world problem it solves by controlling access to an object rather than translating its interface.
The Proxy pattern introduces a stand-in object, the Proxy, that implements the same Subject interface as the real object it represents, the RealSubject, and controls access to it: deciding whether, when, and how the real call actually happens. Client code depends only on the Subject interface, so it can hold either a RealSubject or a Proxy without knowing or caring which one it has.
The problem it solves is access control in the broad sense: creating an expensive object lazily, checking permissions before a call, hiding that an object lives on another machine, or caching results, all without changing a single line of the calling code or the RealSubject's own implementation.
interface AccountService {
Balance getBalance(String accountId);
}
class RealAccountService implements AccountService {
public Balance getBalance(String accountId) {
// expensive or sensitive real work
return new Balance(accountId, 1000);
}
}
class AccountServiceProxy implements AccountService {
private final RealAccountService real = new RealAccountService();
@Override
public Balance getBalance(String accountId) {
// control point: logging, checks, caching, etc.
return real.getBalance(accountId);
}
}
2. Explain precisely why a Proxy must implement the same interface as its RealSubject, whereas an Adapter deliberately implements a different interface than its Adaptee, and why confusing the two leads to a design mistake.
Proxy and Adapter are structurally similar, both hold a reference to another object and forward calls, but their intents are opposite. A Proxy exists so client code written against the Subject interface never has to change; it is a drop-in replacement for the RealSubject, so it must expose exactly the same methods with exactly the same signatures. An Adapter exists precisely because the client's expected interface and the wrapped object's interface are different, and its whole job is bridging that gap.
Confusing the two produces a proxy that quietly changes shape or behavior, breaking the substitutability callers rely on, or an adapter that pointlessly duplicates the adaptee's interface instead of translating it, missing the reason to adapt in the first place.
3. List and describe the four classic proxy categories from the Gang of Four: virtual proxy, protection proxy, remote proxy, and smart reference, with one line summarizing each one's control point.
A virtual proxy defers creation of an expensive RealSubject until it is actually needed, standing in as a lightweight placeholder until then. A protection proxy checks the caller's permissions before allowing a call through to the real subject, denying or restricting calls the caller is not authorized to make. A remote proxy represents an object that lives in a different address space, typically another process or machine, hiding the network call behind what looks like an ordinary local method call. A smart reference proxy performs additional bookkeeping around access, such as counting references, enforcing a lock, or triggering a side effect, whenever the real object is accessed.
Modern Java code adds two more categories in practice, though both are variations on these four: caching proxies (a specialization of smart reference) and cross-cutting AOP proxies generated dynamically (a specialization used heavily by frameworks like Spring).
4. Compare static (hand-written or compile-time) proxies with dynamic (runtime-generated) proxies in Java, and explain the trade-offs between writing one proxy class per interface versus generating proxies at runtime.
A static proxy is an ordinary class, written by hand, that implements a specific interface and forwards to a specific RealSubject type. A dynamic proxy is generated at runtime, typically via java.lang.reflect.Proxy or a bytecode library like CGLIB, and dispatches every call through a single generic handler rather than through hand-written methods.
Static proxies are simple to read, debug, and step through, and the compiler catches signature mismatches immediately, but they do not scale well when the same cross-cutting behavior, such as logging or transactions, must apply identically across dozens of unrelated interfaces. Dynamic proxies eliminate that duplication at the cost of reflection overhead, weaker compile-time safety, and stack traces that are harder to read because every call passes through the same generic invocation path.
5. Write a complete hand-written Proxy implementation in Java for a ReportGenerator interface, showing the Subject interface, the RealSubject class, and a Proxy class that adds a simple control point before delegating.
The proxy implements ReportGenerator exactly like the real class does, holds a reference to the real generator, and wraps the delegated call with whatever control point it exists to add, here a simple timing measurement, before returning the identical result type the client already expects.
interface ReportGenerator {
Report generate(ReportRequest request);
}
class RealReportGenerator implements ReportGenerator {
@Override
public Report generate(ReportRequest request) {
// expensive: queries a warehouse, renders a PDF, etc.
return new Report(request.id(), "...rendered content...");
}
}
class LoggingReportGeneratorProxy implements ReportGenerator {
private final ReportGenerator real;
LoggingReportGeneratorProxy(ReportGenerator real) { this.real = real; }
@Override
public Report generate(ReportRequest request) {
long start = System.nanoTime();
Report result = real.generate(request);
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
System.out.println("Report " + request.id() + " generated in " + elapsedMs + "ms");
return result;
}
}
6. Explain the difference between the Proxy pattern and the Adapter pattern with a concrete example showing when each is the correct choice.
Proxy keeps the client's existing interface unchanged and controls access to an object that already satisfies it; Adapter changes an incompatible interface into the one the client already expects. Both wrap another object and delegate, but the reason for wrapping is different: Proxy is about the same contract, guarded or optimized; Adapter is about a different contract, translated.
Use Proxy when a PaymentProcessor client already exists and you want a stand-in, such as a caching or access-checking wrapper, around that exact same PaymentProcessor interface. Use Adapter when the client expects PaymentProcessor but the object you have is a third-party class exposing a completely different, incompatible interface that needs its calls translated first.
7. Explain the difference between the Proxy pattern and the Decorator pattern, since both implement the same interface as the object they wrap, and clarify how their differing intents lead to different design decisions even though their code often looks nearly identical.
Structurally, Proxy and Decorator are nearly indistinguishable: both implement the same interface as the wrapped object and hold a reference to it. The difference is intent. A Decorator's job is to add new behavior or responsibility, and it is designed to be stacked, multiple decorators wrapping each other, each contributing independent behavior. A Proxy's job is to control access, and it is usually a single layer that decides whether, when, or how a call reaches the real subject, not to pile on unrelated features.
This shows up in construction too: a Decorator typically wraps whatever instance the caller hands it at the call site, chosen freely and stacked; a Proxy typically owns or creates its RealSubject itself, because part of its job, like lazy creation, is to control exactly when that real instance comes into existence.
// Decorator: adds behavior, meant to be stacked by the caller
Coffee order = new MilkDecorator(new SugarDecorator(new SimpleCoffee()));
// Proxy: controls access, usually owns/creates the real subject itself
Image thumbnail = new VirtualImageProxy("photo.jpg"); // decides *when* RealImage is built
8. Explain the difference between the Proxy pattern and the Facade pattern, given both sit in front of another object, and describe a scenario where each is the correct architectural choice.
A Proxy stands in for a single object behind the exact same interface that object already exposes; the client cannot tell it isn't talking to the real thing directly. A Facade introduces a brand-new, simplified interface in front of an entire subsystem of many classes, deliberately reducing the surface the client has to learn; it never pretends to be any one of the underlying classes.
Use Proxy when you have one ImageLoader interface and want to lazily load or cache behind it. Use Facade when you have a dozen classes for checkout, inventory, and shipping and want one CheckoutFacade.completeOrder() entry point that hides all of them.
9. Produce a single comparison summarizing Proxy, Adapter, Decorator, and Facade side by side: whether the interface changes, whether stacking is expected, and each pattern's core intent.
All four are "wrapper" patterns in the loose sense, but interviewers expect you to place them precisely by two axes: does the public interface change, and is the pattern designed to be composed in layers.
| Pattern | Interface vs wrapped object | Core intent |
|---|---|---|
| Proxy | Identical interface | Control access (lazy, protected, remote, cached) |
| Adapter | Different interface | Translate an incompatible interface into an expected one |
| Decorator | Identical interface, designed to stack | Add behavior/responsibility around existing calls |
| Facade | New, simplified interface over many classes | Reduce subsystem complexity for callers |
10. Explain how the Liskov Substitution Principle applies to the Proxy pattern, and describe a concrete bug that occurs when a proxy is not truly substitutable for its real subject.
Liskov's principle says client code written against a supertype must work correctly no matter which subtype it actually receives. Applied to Proxy, this means any code written against the Subject interface must behave the same whether it holds a RealSubject or a Proxy; the proxy must never surprise the caller with different exceptions, different nullability, or different side-effect ordering than the real subject would produce.
// Bug: caller special-cases on concrete type, breaking substitutability
if (service instanceof RealAccountService) {
// works
} else {
// silently takes a different, buggy path when given a proxy
}
instanceof, casting, or divergent behavior, whether they are holding the real object or a stand-in.11. Explain the virtual proxy category of the Proxy pattern in detail: what "virtual" means here, and why lazy initialization is the defining characteristic rather than an incidental optimization.
"Virtual" refers to the RealSubject not yet existing, virtually present through the proxy, until the moment it is genuinely needed. The proxy implements the full Subject interface immediately and cheaply, while the expensive construction, disk reads, network calls, or heavy parsing, is deferred until the first real method call actually requires the result.
This is the defining characteristic, not a bonus, because a virtual proxy that eagerly builds the real subject in its constructor is no longer solving the problem it exists for; the whole reason to introduce the proxy class instead of just constructing the real object directly is to control precisely when construction happens.
12. Implement a virtual proxy in Java for an expensive-to-construct ImageLoader class that only loads the actual image bytes from disk the first time render() is called.
The proxy stores only the lightweight path string at construction time and creates the real, expensive RealImage only inside render(), and only on the first call; every subsequent call reuses the already-loaded instance.
interface Image {
void render();
}
class RealImage implements Image {
private final String path;
RealImage(String path) {
this.path = path;
loadFromDisk(); // expensive
}
private void loadFromDisk() { /* read bytes into memory */ }
public void render() { System.out.println("Rendering " + path); }
}
class VirtualImageProxy implements Image {
private final String path;
private RealImage real;
VirtualImageProxy(String path) { this.path = path; }
@Override
public void render() {
if (real == null) {
real = new RealImage(path); // created only when first needed
}
real.render();
}
}
13. Explain how Hibernate and JPA implement lazy loading of entity associations using dynamically generated virtual proxy classes, and what the generated proxy actually looks like at runtime.
When an entity association is mapped as lazy, Hibernate does not return the real target entity from a getter; it returns a bytecode-generated subclass, produced at startup via ByteBuddy (or historically Javassist/CGLIB), that extends the entity class and overrides its accessor methods. That generated proxy holds a reference back to the persistence session and the entity's identifier but no other loaded state.
The first time any method other than the identifier getter is called on that proxy, it triggers hibernateLazyInitializer.initialize(), which issues the actual SELECT and populates the proxy's delegate target, after which every call forwards transparently to the now-loaded real entity.
14. Explain the N+1 query problem that arises from misusing Hibernate's lazy-loaded association proxies, with an example, and describe at least two ways to fix it.
N+1 happens when one query loads N parent entities, and then, because an association is lazy, accessing that association inside a loop triggers one additional query per parent, for a total of N+1 round trips instead of one or two.
List<Order> orders = orderRepository.findAll(); // 1 query
for (Order order : orders) {
order.getLineItems().size(); // N additional queries, one lazy proxy initialization each
}
Fix it either by fetching eagerly up front for this specific query with JOIN FETCH, or by configuring @BatchSize so Hibernate initializes several proxies in one batched IN-clause query instead of one query per entity.
15. What causes a LazyInitializationException when accessing a Hibernate lazy-proxy association, and how would you fix it properly rather than just switching the association to eager?
The proxy's initialize() logic needs an open persistence session to run its SELECT. If the entity is detached, typically because the transactional session that loaded it has already closed by the time a view layer or a serializer touches the lazy association, the proxy has no session to initialize itself with, and Hibernate throws LazyInitializationException instead of silently returning incomplete data.
@Transactional
public OrderDto getOrder(Long id) {
Order order = orderRepository.findById(id).orElseThrow();
return OrderDto.from(order); // access lazy fields here, while the session is still open
}
The correct fix is to access every lazy association you need while still inside the transactional boundary, typically by mapping to a DTO before the method returns, rather than widening the transaction's scope or defaulting every mapping to eager.
16. Describe how to make the lazy-initialization check inside a virtual proxy thread-safe, and explain the race condition that a naive if (real == null) check has under concurrent access.
Without synchronization, two threads can both observe real == null, both construct their own RealImage, and one of the two writes gets lost or, worse, both partially-constructed writes race on the same field with no happens-before guarantee, so a second thread might observe a non-null but incompletely initialized reference.
class ThreadSafeVirtualProxy implements Image {
private final String path;
private volatile RealImage real;
ThreadSafeVirtualProxy(String path) { this.path = path; }
@Override
public void render() {
RealImage local = real;
if (local == null) {
synchronized (this) {
local = real;
if (local == null) {
real = local = new RealImage(path); // double-checked locking, volatile required
}
}
}
local.render();
}
}
17. Design a virtual proxy for an image gallery application where each thumbnail's full-resolution image is loaded from disk or network only when the user actually opens it.
Each gallery cell holds a lightweight ThumbnailProxy, cheap to construct in bulk for hundreds of items, that implements the same Photo interface as the real full-resolution photo. Only when the user clicks to open one photo does that proxy load, decode, and cache the actual full-resolution bytes; every unopened photo in the gallery costs almost nothing in memory.
18. Design a virtual proxy for a reporting engine so that a large underlying dataset is only queried and loaded into memory the first time a report actually needs it, rather than eagerly for every report request.
A ReportDataSetProxy implements the same DataSet interface the report renderer expects, holding only the query parameters at construction. The first call to a data-access method, such as rows(), triggers the actual warehouse query and caches the result set on the proxy instance for the remainder of that report's rendering, so a report that only needs summary counts never pays for loading full row data.
19. Explain why formalizing lazy initialization as a Proxy class, implementing the same interface as the real object, is preferable to simply adding an if-null check and a lazy field directly inside a getter method on the class itself.
An inline lazy getter mixes two responsibilities into one class: doing the real work and deciding when to create it, so every consumer of that class is forced to depend on the concrete class even if all they need is the interface. A virtual proxy separates these cleanly: the real class stays a plain, eagerly-constructible implementation, and laziness becomes an orthogonal, swappable concern that can be added, removed, or tested independently, and that other code can depend on purely through the Subject interface.
20. Explain how a virtual proxy can be combined with CompletableFuture to kick off asynchronous, non-blocking loading of the real subject as soon as the proxy is created, rather than blocking the first caller.
Instead of waiting for the first call to trigger construction, the proxy starts the expensive load eagerly but asynchronously in its constructor, storing a CompletableFuture; any method call then joins that future, which is instant if loading already finished by the time it's needed, or blocks only the remaining time if it's still in flight.
class AsyncVirtualProxy implements Image {
private final CompletableFuture<RealImage> future;
AsyncVirtualProxy(String path) {
this.future = CompletableFuture.supplyAsync(() -> new RealImage(path));
}
@Override
public void render() {
future.join().render(); // instant if already loaded, otherwise waits out the remainder
}
}
.join() still blocks the calling thread if loading isn't finished; expose an async-friendly method too if callers can tolerate a genuinely non-blocking API.21. Explain the protection proxy category in detail: how it enforces access control decisions in front of a real subject, and how this differs from scattering permission checks throughout business logic.
A protection proxy implements the same Subject interface as the real object and, before forwarding any call, evaluates whether the current caller is authorized to make it, throwing or denying if not. The real subject itself stays completely unaware of authorization; it simply does its job whenever it is called, trusting that any call reaching it has already been cleared.
This differs from scattering if (hasPermission(...)) checks inside the real class's own methods because the check is centralized in exactly one place, applies uniformly no matter how many methods the interface has, and can be swapped, tested, or disabled independently of the business logic it protects.
22. Implement a protection proxy in Java for a secured AdminOperations interface that only allows a call through to the real subject when the current user has the ADMIN role.
The proxy checks the caller's role against a SecurityContext before delegating; if the check fails, it throws immediately and the real subject's method body is never reached.
interface AdminOperations {
void deleteUser(String userId);
}
class RealAdminOperations implements AdminOperations {
public void deleteUser(String userId) { /* actually deletes */ }
}
class ProtectedAdminOperations implements AdminOperations {
private final AdminOperations real;
private final SecurityContext security;
ProtectedAdminOperations(AdminOperations real, SecurityContext security) {
this.real = real;
this.security = security;
}
@Override
public void deleteUser(String userId) {
if (!security.currentUser().hasRole("ADMIN")) {
throw new AccessDeniedException("ADMIN role required");
}
real.deleteUser(userId);
}
}
23. Discuss how the Proxy pattern relates to Java's historical java.security.AccessController and SecurityManager mechanisms for enforcing permission checks around sensitive operations.
SecurityManager and AccessController acted as a systemic, JVM-wide protection proxy: sensitive operations, such as file access or reflection, routed through a permission check before the real operation executed, conceptually identical to a hand-written protection proxy but enforced platform-wide rather than per interface.
SecurityManager was deprecated for removal starting with Java 17 and removed entirely in later JDK releases, so modern code relies on explicit, application-level protection proxies (or a framework's security layer, such as Spring Security's method-security proxies) rather than the JVM-wide mechanism.
24. Design a feature-flagged proxy that transparently routes calls to either a new implementation or the legacy real subject depending on whether a feature flag is enabled for the current request.
The proxy implements the same PricingEngine interface as both candidate implementations and, on each call, consults a feature-flag service to decide which one to delegate to, letting the rollout be toggled per-tenant or per-percentage without any change to calling code.
class FeatureFlagPricingProxy implements PricingEngine {
private final PricingEngine legacy;
private final PricingEngine experimental;
private final FeatureFlags flags;
FeatureFlagPricingProxy(PricingEngine legacy, PricingEngine experimental, FeatureFlags flags) {
this.legacy = legacy;
this.experimental = experimental;
this.flags = flags;
}
@Override
public Price quote(Order order) {
return flags.isEnabled("new-pricing", order.customerId())
? experimental.quote(order)
: legacy.quote(order);
}
}
25. Describe the security implications of a poorly implemented protection proxy, including a concrete example where a caller could bypass the check entirely, and how you would redesign the proxy to prevent it.
A protection proxy is only as strong as it is the sole path to the real subject. If the real subject is reachable through any other route, a public getter, a shared field, a factory that hands out the unwrapped instance, the check is decorative rather than enforced.
// Bad: exposes the unwrapped real subject alongside the protected one
class Container {
public final AdminOperations protectedOps = new ProtectedAdminOperations(real, security);
public final AdminOperations rawOps = real; // bypasses every check!
}
26. Compare using a protection proxy versus scattering an if (hasPermission(...)) check inline in every method of the business logic class, and explain when the proxy approach is worth the extra class.
Inline checks couple authorization logic to business logic in every method, forcing every future method to remember to add its own check and making it hard to see, audit, or unit test the authorization rules in isolation. A protection proxy consolidates every check into one class, visible in one place, and lets business logic be tested completely unaware of permissions.
The proxy is worth the extra class whenever the interface is stable and shared, or when authorization rules are non-trivial enough to want isolated testing; for a single throwaway internal method, an inline check may genuinely be simpler.
27. Describe how to combine a protection proxy with an audit-logging concern so that every denied access attempt is recorded, without duplicating the permission-check logic in two places.
Wrap the single permission check with a try/catch (or an early-return) that logs the denial before rethrowing, keeping the authorization decision itself in exactly one branch rather than duplicating the check once to decide and again to log.
@Override
public void deleteUser(String userId) {
try {
if (!security.currentUser().hasRole("ADMIN")) {
throw new AccessDeniedException("ADMIN role required");
}
real.deleteUser(userId);
} catch (AccessDeniedException ex) {
auditLog.recordDenied(security.currentUser().id(), "deleteUser", userId);
throw ex;
}
}
28. Design a protection proxy for a multi-tenant SaaS application that ensures every call is scoped to the caller's own tenant, preventing cross-tenant data access even if the caller supplies another tenant's identifier.
The proxy compares the tenant identifier embedded in the current request context against the tenant identifier the caller is asking about, rejecting the call outright on any mismatch, rather than trusting a tenant identifier supplied directly in the request payload.
@Override
public Invoice getInvoice(String tenantId, String invoiceId) {
if (!tenantId.equals(requestContext.currentTenantId())) {
throw new AccessDeniedException("Cross-tenant access denied");
}
return real.getInvoice(tenantId, invoiceId);
}
29. Implement a protection proxy in front of a FileStore interface that restricts which file paths a given caller is allowed to read or write, rejecting any path outside an allow-listed directory.
The proxy normalizes the requested path first, resolving any .. segments, then checks that the normalized, absolute path still starts with the allowed root directory before delegating; checking the raw, unnormalized string is not enough to stop a path-traversal attempt.
@Override
public byte[] read(String requestedPath) {
Path resolved = allowedRoot.resolve(requestedPath).normalize();
if (!resolved.startsWith(allowedRoot)) {
throw new AccessDeniedException("Path escapes allowed root: " + requestedPath);
}
return real.read(resolved.toString());
}
normalize() before the startsWith check; comparing the raw string lets a request like ../../etc/passwd slip through undetected.30. Describe how to unit test a protection proxy to verify it both correctly denies unauthorized callers and correctly allows authorized callers through to the real subject, without needing a real security infrastructure.
Mock the security context to return each role scenario, then assert both outcomes: that an unauthorized call throws and never reaches the real subject (verified via verify(real, never())), and that an authorized call succeeds and does reach it.
@Test
void deniesNonAdminAndNeverCallsRealSubject() {
AdminOperations real = mock(AdminOperations.class);
SecurityContext security = mock(SecurityContext.class);
when(security.currentUser()).thenReturn(new User("bob", Set.of("USER")));
ProtectedAdminOperations proxy = new ProtectedAdminOperations(real, security);
assertThrows(AccessDeniedException.class, () -> proxy.deleteUser("u1"));
verify(real, never()).deleteUser(any());
}
31. Explain the remote proxy category of the Proxy pattern: how it hides the fact that the real subject lives in a different process or machine, and what responsibilities the proxy takes on that a purely local proxy does not.
A remote proxy implements the same interface the real subject would expose if it were local, but every method internally performs network communication, serializing arguments, sending them to the remote process, and deserializing the response, all hidden from the caller behind an ordinary-looking method call.
Beyond a local proxy's responsibilities, a remote proxy must also handle concerns that only exist because a network sits between caller and callee: connection management, serialization format, timeouts, retries, and partial failure, none of which a purely in-process proxy needs to think about.
32. Describe the classic Java RMI stub-and-skeleton architecture and explain precisely how the client-side stub functions as a remote proxy implementing the same remote interface as the server-side object.
In RMI, both the client's stub and the server's actual implementation implement the same Remote interface. The client only ever holds the stub, generated to match that interface, and calling a method on it serializes the arguments, sends them over a socket to the server, and blocks waiting for the response, which the stub deserializes and returns as if the call had been local.
The server-side skeleton (largely folded into the runtime in later RMI versions) receives the incoming call, deserializes the arguments, invokes the real implementation object, and serializes the result back, mirroring the client stub's responsibilities on the other side of the wire.
33. Explain how a gRPC-generated client stub is a modern instance of the remote proxy pattern, and compare it to the older RMI stub in terms of how the interface is defined and how the proxy code is produced.
A gRPC client stub, generated from a .proto service definition, plays exactly the same remote proxy role as an RMI stub: the caller invokes what looks like an ordinary method, and the generated stub handles Protobuf serialization and an HTTP/2 request under the hood. The key difference is that the contract is defined language-neutrally in the .proto file rather than as a Java Remote interface, so the same service can generate proxy stubs for many languages, not just Java.
RMI's stub generation was Java-specific and tied to Java serialization; gRPC's code generation is cross-language and uses a compact binary wire format, but conceptually both are the exact same remote proxy idea: a generated class implementing the service's interface that hides the network call.
34. Implement a hand-written remote proxy in Java that implements a local-looking InventoryService interface but internally makes an HTTP call to a remote inventory microservice.
The proxy exposes the exact same domain method signature the rest of the application already calls locally, and internally builds the HTTP request, sends it, and maps the JSON response back onto the domain type, translating any transport-level failure into the domain's own exception type.
class HttpInventoryServiceProxy implements InventoryService {
private final HttpClient client;
private final URI baseUri;
HttpInventoryServiceProxy(HttpClient client, URI baseUri) {
this.client = client;
this.baseUri = baseUri;
}
@Override
public StockLevel getStock(String sku) {
HttpRequest request = HttpRequest.newBuilder(baseUri.resolve("/stock/" + sku)).GET().build();
try {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
return StockLevel.fromJson(response.body());
} catch (IOException | InterruptedException ex) {
throw new InventoryLookupException(sku, ex);
}
}
}
35. Describe how a remote proxy should handle network failures, timeouts, and retries so that transient issues are absorbed transparently without hiding failures the caller genuinely needs to know about.
Attach an explicit timeout to every outbound call so a hung remote never blocks the caller indefinitely, and retry only failures known to be transient and safe to repeat, such as connection timeouts on an idempotent read, never a write whose effect on the server is unknown.
HttpRequest request = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(3)).GET().build();
36. Explain what marshalling and unmarshalling responsibilities a remote proxy takes on, and why keeping this logic entirely inside the proxy is important for the rest of the codebase.
Marshalling converts domain-typed method arguments into whatever wire format the remote call requires, JSON, Protobuf, or a Java-serialized stream, and unmarshalling reverses that on the response. Keeping this entirely inside the proxy means the domain classes never need to know anything about the wire format, so switching from JSON over HTTP to Protobuf over gRPC later only touches the proxy, not any caller.
37. Discuss how a remote proxy's inherent latency and possibility of partial failure differ fundamentally from a purely in-process local proxy, and what this means for how callers should be coded even though the interface looks identical.
A local call essentially always succeeds and returns in nanoseconds; a remote call can be slow, can fail halfway through (the request might have been received and processed even though the response never arrived), and can fail in ways a local call structurally cannot, such as a network partition. The Subject interface looking identical is exactly what makes this dangerous: it tempts callers into treating a remote proxy call as if it carries the same reliability guarantees as a local one.
38. Describe how to wrap a circuit breaker around the network call inside a remote proxy so that a flaky downstream dependency fails fast instead of exhausting caller threads.
The circuit breaker lives entirely inside the proxy, tracking recent failure rates for the wrapped call; once failures cross a configured threshold it opens, and further calls fail immediately with a domain exception instead of waiting on a slow or repeatedly failing remote, until a cool-down period allows a trial call through again.
class ResilientInventoryProxy implements InventoryService {
private final CircuitBreaker breaker = CircuitBreaker.ofDefaults("inventory-service");
private final InventoryService remote;
ResilientInventoryProxy(InventoryService remote) { this.remote = remote; }
@Override
public StockLevel getStock(String sku) {
try {
return breaker.executeSupplier(() -> remote.getStock(sku));
} catch (CallNotPermittedException ex) {
throw new InventoryUnavailableException(sku, ex); // breaker is open
}
}
}
39. Design a database connection pooling proxy, similar to what HikariCP provides, that implements the same java.sql.Connection interface but transparently returns the physical connection to the pool instead of actually closing it when close() is called.
Application code calls connection.close() exactly as it always would, but the pooled proxy intercepts that one method and releases the underlying physical connection back into the pool instead of tearing down the socket; every other method delegates straight through to the physical connection unchanged.
class PooledConnectionProxy implements Connection {
private final Connection physical;
private final ConnectionPool pool;
PooledConnectionProxy(Connection physical, ConnectionPool pool) {
this.physical = physical;
this.pool = pool;
}
@Override
public void close() {
pool.release(physical); // intercepted: returned to the pool, not physically closed
}
@Override
public Statement createStatement() throws SQLException {
return physical.createStatement(); // everything else delegates straight through
}
// ... remaining Connection methods delegate to `physical` unchanged
}
40. Compare the classic RMI remote proxy model to a modern service mesh sidecar proxy such as an Envoy sidecar, and explain what role, if any, the GoF Proxy pattern still plays at that infrastructure layer.
RMI's remote proxy lives inside the calling process as a generated Java object implementing the same interface as the remote service. A service mesh sidecar instead runs as a separate process alongside each service instance, intercepting network traffic transparently at the infrastructure layer rather than the object layer, with no generated client-side class required at all.
The GoF Proxy pattern's intent, standing in for another object and controlling access to it, still applies conceptually: the sidecar controls access to the real destination service (retries, circuit breaking, mTLS, routing) exactly as a remote proxy would, just implemented at the network layer instead of as an in-process object implementing a Java interface.
41. Explain the caching proxy as a specialization of the smart reference proxy category: how it decides whether to forward a call to the real subject at all, rather than merely how it forwards the call.
A caching proxy stores previously computed results keyed by call arguments and, before delegating, checks whether a fresh-enough result already exists; if it does, the proxy returns the cached value without ever touching the real subject at all. This is what sets it apart from most other proxy categories: the real call can be skipped entirely, not merely wrapped.
This also distinguishes it from a lazy virtual proxy, which defers only the first construction of the real subject; a caching proxy can potentially skip the real call on every subsequent invocation with matching arguments, for as long as the cached entry stays valid.
interface PriceLookup {
Price lookup(String sku);
}
class CachingPriceLookupProxy implements PriceLookup {
private final PriceLookup real;
private final Map<String, Price> cache = new ConcurrentHashMap<>();
CachingPriceLookupProxy(PriceLookup real) { this.real = real; }
@Override
public Price lookup(String sku) {
return cache.computeIfAbsent(sku, real::lookup);
}
}
42. Implement a generic memoizing proxy in Java for a pure, deterministic computation, and explain why memoization is only safe when the wrapped call has no side effects and always returns the same result for the same arguments.
Memoization is caching applied specifically to pure functions: because the computation is deterministic and free of side effects, caching its result for a given input is always correct no matter how many times, or in what order, callers invoke it.
interface Calculator {
long factorial(int n);
}
class RealCalculator implements Calculator {
public long factorial(int n) {
long result = 1;
for (int i = 2; i <= n; i++) result *= i;
return result;
}
}
class MemoizingCalculatorProxy implements Calculator {
private final Calculator real;
private final Map<Integer, Long> cache = new ConcurrentHashMap<>();
MemoizingCalculatorProxy(Calculator real) { this.real = real; }
@Override
public long factorial(int n) {
return cache.computeIfAbsent(n, real::factorial);
}
}
43. Discuss the cache invalidation edge cases a caching proxy must handle: time-based expiry, explicit invalidation on writes, and the risk of serving stale data indefinitely if neither is implemented.
A caching proxy with no expiry and no explicit invalidation path simply keeps returning whatever it first computed, forever, even after the underlying data changes. The two main strategies are time-based expiry (each entry carries a timestamp and is considered stale after a fixed TTL) and explicit invalidation (the write path that changes the underlying data also tells the cache to drop or refresh the affected key).
class TtlCachingProxy implements PriceLookup {
private record Entry(Price value, Instant expiresAt) {}
private final PriceLookup real;
private final Duration ttl;
private final Map<String, Entry> cache = new ConcurrentHashMap<>();
TtlCachingProxy(PriceLookup real, Duration ttl) {
this.real = real;
this.ttl = ttl;
}
@Override
public Price lookup(String sku) {
Entry entry = cache.get(sku);
if (entry != null && Instant.now().isBefore(entry.expiresAt())) {
return entry.value();
}
Price fresh = real.lookup(sku);
cache.put(sku, new Entry(fresh, Instant.now().plus(ttl)));
return fresh;
}
}
Edge cases worth naming explicitly: invalidating a key while another thread is mid-read of it, a write path that lives in a different service and has no way to reach this proxy's cache at all (cross-service invalidation lag), and derived or aggregate cache entries that need invalidating whenever any of several underlying keys changes.
44. Explain how to make a caching proxy safe under concurrent access without serializing every call behind a single lock, and describe the correctness/performance trade-off with naive synchronization.
Synchronizing the entire lookup method serializes every caller, including cache hits, which defeats much of the point of caching under load: the exact moment the cache is working best is the moment you least want every thread queued behind one lock.
ConcurrentHashMap.computeIfAbsent is a better fit because it locks only the affected bucket, not the whole map, so unrelated keys never contend. The catch is that its mapping function must never itself try to update the same map, since a reentrant call into computeIfAbsent for the same map from inside the mapping function is undefined behavior and can throw or effectively deadlock in some JDK versions.
// Dangerous: mapping function mutates the same map it's computing into
cache.computeIfAbsent(sku, k -> {
cache.put(relatedKey, precomputeRelated(k)); // re-entrant modification, avoid this
return real.lookup(k);
});
45. Explain the cache stampede (thundering herd) problem that occurs when a hot cache entry expires and many concurrent requests recompute it simultaneously, and describe at least two mitigations a caching proxy can implement.
The moment a popular entry expires, many concurrent callers can all observe a miss at once and all call through to the real subject simultaneously, overwhelming exactly the expensive resource the cache existed to protect, with a burst of duplicate work computing the identical result.
class CoalescingCachingProxy implements PriceLookup {
private final PriceLookup real;
private final Map<String, CompletableFuture<Price>> inFlight = new ConcurrentHashMap<>();
@Override
public Price lookup(String sku) {
CompletableFuture<Price> future = inFlight.computeIfAbsent(sku,
k -> CompletableFuture.supplyAsync(() -> real.lookup(k)));
try {
return future.get();
} finally {
inFlight.remove(sku, future); // clear once the shared computation finishes
}
}
}
Two mitigations: request coalescing, where every concurrent caller for the same key awaits a single in-flight computation instead of starting their own, and stale-while-revalidate, where the just-expired value is served immediately while a refresh happens asynchronously in the background.
46. Is a caching wrapper more accurately classified as a Proxy or a Decorator, given the classic interviewer gotcha framing? Justify your answer using each pattern's defining intent rather than its structural shape.
A caching wrapper is a Proxy, not a Decorator. Its defining behavior is that it can skip the real subject entirely on a cache hit, it controls whether the real call happens at all, which is exactly Proxy's defining trait. A Decorator's defining trait is that it always forwards to the wrapped object and adds behavior around that unavoidable call; it never has the option of simply not calling through.
Structurally the two are identical, both implement the same interface and hold a reference to another instance of it, so the classification has to come from behavior and intent rather than code shape. A caching wrapper that always calls through anyway, for example one that also logs timing on every call regardless of hit or miss, starts to blur toward Decorator-flavored behavior, and acknowledging that nuance out loud is a stronger interview answer than reciting a rule mechanically.
47. Implement a bounded caching proxy using an LRU (least-recently-used) eviction policy so unbounded cache growth doesn't quietly turn a caching proxy into a memory leak.
A LinkedHashMap constructed in access-order mode, with removeEldestEntry overridden to enforce a maximum size, is the simplest correct LRU implementation available in the standard library.
class LruCachingProxy implements PriceLookup {
private final PriceLookup real;
private final Map<String, Price> cache;
LruCachingProxy(PriceLookup real, int maxEntries) {
this.real = real;
this.cache = Collections.synchronizedMap(new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Price> eldest) {
return size() > maxEntries;
}
});
}
@Override
public Price lookup(String sku) {
Price cached = cache.get(sku);
if (cached != null) return cached;
Price fresh = real.lookup(sku);
cache.put(sku, fresh);
return fresh;
}
}
In production, a purpose-built library such as Caffeine is generally preferable, it handles size- or weight-based eviction, TTL, and concurrent access far more efficiently than a synchronized LinkedHashMap.
48. Design a caching proxy backed by a distributed cache such as Redis instead of an in-process map, and explain what changes when the cache is shared across multiple JVM instances.
Instead of a local Map, the proxy issues a GET against Redis before delegating, and a SET on a miss; the benefit is one shared, consistent cache across every horizontally scaled instance of a service, instead of each instance keeping its own duplicate, independently-warming copy.
@Override
public Price lookup(String sku) {
String cached = redis.get(cacheKey(sku));
if (cached != null) return Price.fromJson(cached);
Price fresh = real.lookup(sku);
try {
redis.setex(cacheKey(sku), ttlSeconds, fresh.toJson());
} catch (RedisConnectionException ex) {
log.warn("Cache unavailable, skipping cache write for {}", sku, ex);
}
return fresh;
}
49. Describe common cache-key design mistakes in a caching proxy: keys that are too broad, too narrow, built from mutable objects, or that accidentally leak sensitive data, and explain the effect of each mistake.
A key that is too broad, one that omits a parameter that actually affects the result, causes two logically different requests to collide and return the wrong cached answer for one of them. A key that is too narrow, one that includes an irrelevant parameter such as a request id or timestamp, means every call produces a unique key, so the cache never hits at all and silently does nothing.
// Too narrow: requestId makes every key unique, defeating the cache entirely
Object[] badKey = { sku, requestId, Instant.now() };
// Correct: key contains exactly what determines the result, nothing else
String goodKey = sku;
Using a mutable object as a key without stable equals/hashCode is another trap: if the object mutates after being used as a key, a lookup with an "equal" but different instance may miss entirely, and mutating an object already stored as a key can corrupt the map's internal bucket structure outright. Finally, never bake sensitive values such as auth tokens directly into a cache key that might end up in logs or metrics tags.
50. Explain how Spring's @Cacheable annotation is implemented under the hood: how the framework turns a plain annotated method into caching-proxy behavior without you writing a proxy class yourself.
Spring wraps the annotated bean in a generated proxy, a JDK dynamic proxy if the bean implements an interface, or a CGLIB/ByteBuddy subclass proxy otherwise. The interceptor woven into that proxy checks the configured Cache abstraction for an entry keyed by the method's arguments before invoking the real method, and stores the result on a miss. Functionally this is exactly the caching proxy from Q41 and Q43, just generated by the framework instead of hand-written.
@Service
public class PriceService {
@Cacheable("prices")
public Price lookup(String sku) {
return warehouse.queryPrice(sku); // only runs on a cache miss
}
}
51. Implement a logging proxy that records the arguments and return value of every call to a PaymentGateway interface without modifying PaymentGateway's real implementation at all.
The proxy implements the exact same interface, logs before and after delegating, and returns whatever the real subject returned unchanged, so calling code cannot tell logging is happening at all beyond the log output itself.
class LoggingPaymentGatewayProxy implements PaymentGateway {
private final PaymentGateway real;
LoggingPaymentGatewayProxy(PaymentGateway real) { this.real = real; }
@Override
public Receipt charge(String customerId, BigDecimal amount) {
System.out.println("Charging " + customerId + " for " + amount);
Receipt receipt = real.charge(customerId, amount);
System.out.println("Result: " + receipt);
return receipt;
}
}
52. Design a metrics-collecting proxy that records call latency and success/failure counts for every method call on a wrapped interface, suitable for exporting to a monitoring system such as Micrometer.
The proxy starts a timer sample before delegating and, in a finally block, records elapsed time and a success/failure tag, so metrics are captured consistently whether the real call succeeds, throws, or is slow, without the real subject needing any monitoring code of its own.
class MetricsPaymentGatewayProxy implements PaymentGateway {
private final PaymentGateway real;
private final MeterRegistry registry;
@Override
public Receipt charge(String customerId, BigDecimal amount) {
Timer.Sample sample = Timer.start(registry);
String outcome = "success";
try {
return real.charge(customerId, amount);
} catch (RuntimeException ex) {
outcome = "failure";
throw ex;
} finally {
sample.stop(registry.timer("payment.charge", "outcome", outcome));
}
}
}
53. Implement a smart-reference proxy that reference-counts how many active clients are using a shared, expensive resource, and releases the underlying resource only once the count drops to zero.
Every client acquires through the proxy rather than holding the real resource directly; the proxy increments a counter on acquisition and decrements it on release, only actually closing the shared resource once the last client releases it.
class RefCountedResourceProxy implements SharedResource {
private final SharedResource real;
private final AtomicInteger refCount = new AtomicInteger(0);
@Override
public void acquire() {
refCount.incrementAndGet();
}
@Override
public void release() {
if (refCount.decrementAndGet() == 0) {
real.close(); // only the last releaser actually tears it down
}
}
}
54. Explain the copy-on-write smart-reference proxy variant: how it lets multiple owners cheaply share a read-only view of the same underlying data and only pays the cost of duplicating it the moment one of them attempts to mutate it.
The proxy initially hands out the same shared underlying instance for every read operation, so many owners can share one copy at essentially no extra cost. The first mutating operation triggers an actual deep copy so that mutation never affects any other owner still holding the proxy; after that copy, the proxy transparently forwards further calls to its now-private copy instead of the originally shared instance.
class CopyOnWriteDocumentProxy implements Document {
private Document target; // initially shared
private boolean owned = false;
@Override
public void append(String text) {
if (!owned) {
target = target.copy(); // deep copy deferred until the first write
owned = true;
}
target.append(text);
}
@Override
public String read() {
return target.read(); // reads never trigger a copy
}
}
55. Design an audit-logging proxy for a compliance-sensitive BankAccountOperations interface that records who performed which mutating operation and when, distinct from the earlier denial-only audit logging on a protection proxy.
Where a protection proxy's audit logging (Q27) records only denied attempts, a compliance audit log needs a complete record of every successful mutating call as well, since regulators care about what actually happened, not merely what was blocked.
@Override
public void withdraw(String accountId, BigDecimal amount) {
real.withdraw(accountId, amount);
auditLog.record(new AuditEntry(security.currentUser().id(), "withdraw", accountId, amount, Instant.now()));
}
56. Implement a rate-limiting proxy using a token-bucket algorithm that rejects calls to a real subject once a caller exceeds an allowed request rate.
The proxy holds a bucket of tokens that refills at a fixed rate; each call attempts to consume one token before delegating, and if none remain, the call is rejected immediately rather than reaching the real subject, protecting it from bursts that exceed its capacity.
class RateLimitedApiProxy implements ExternalApi {
private final ExternalApi real;
private final Bucket bucket; // e.g. Bucket4j token bucket
@Override
public ApiResponse call(ApiRequest request) {
if (!bucket.tryConsume(1)) {
throw new RateLimitExceededException("Too many requests");
}
return real.call(request);
}
}
57. Explain how multiple proxies, such as logging, caching, and rate-limiting, can be composed around a single real subject, and why the order in which they are layered changes the resulting behavior.
Because every proxy in the stack implements the same Subject interface and holds a reference to another instance of it, proxies can be nested arbitrarily deep: outer wraps middle wraps inner wraps the real subject. Since each layer only ever sees what the layer inside it returns (or throws), the order in which layers are stacked changes what actually happens on each call.
PaymentGateway gateway = new LoggingProxy(
new RateLimitingProxy(
new CachingProxy(realGateway)));
Placing the caching proxy outside the rate-limiting proxy means cache hits never consume a rate-limit token; placing it inside means every call, hit or miss, is rate-limited. Placing logging outermost logs every call attempt including ones rejected by an inner layer; placing it innermost logs only calls that survive every layer above it.
58. Compare the Null Object pattern to a Proxy that always denies access, and explain why they solve different problems even though both can stand in for a real subject a caller cannot actually use.
Null Object provides a harmless, do-nothing implementation of an interface so callers never need a null check; it does not wrap or communicate with any real subject at all, it is simply a safe default for the case where there is nothing real to use. A denying protection proxy, by contrast, does wrap a genuine real subject and actively decides, per call, whether a particular caller may reach it, typically throwing rather than quietly doing nothing.
A protection proxy that returns a harmless default instead of throwing for a denied caller starts to resemble a Null Object in effect, but the underlying intent stays distinct: Null Object exists to eliminate null-checking noise; Proxy exists for access control, which just as often needs to fail loudly and audibly rather than silently.
59. Implement a proxy that automatically retries a flaky remote call with exponential backoff before giving up and propagating the failure to the caller.
The proxy loops up to a maximum attempt count, sleeping for an increasing delay between attempts, and only lets the underlying exception escape to the caller once every retry has been exhausted.
class RetryingInventoryProxy implements InventoryService {
private final InventoryService real;
private final int maxAttempts;
@Override
public StockLevel getStock(String sku) {
long delayMs = 100;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return real.getStock(sku);
} catch (InventoryLookupException ex) {
if (attempt == maxAttempts) throw ex;
sleepQuietly(delayMs);
delayMs *= 2; // exponential backoff
}
}
throw new IllegalStateException("unreachable");
}
}
60. Design a proxy that consistently routes each user to either a control or experimental implementation of a RecommendationEngine interface for an A/B test, ensuring the same user always lands in the same variant across repeated calls.
Unlike the boolean feature-flag proxy in Q24, an A/B test needs each user's assignment to stay consistent across many calls and sessions so the resulting measurements are statistically valid. This is achieved by hashing a stable user identifier into a fixed bucket rather than re-deciding randomly on every call.
@Override
public List<Recommendation> recommend(String userId) {
int bucket = Math.floorMod(userId.hashCode(), 100);
return bucket < rolloutPercentage
? experimental.recommend(userId)
: control.recommend(userId);
}
61. Explain in detail how java.lang.reflect.Proxy.newProxyInstance works: what class it generates at runtime, what that generated class extends and implements, and how every method call is routed to your InvocationHandler.
At runtime, Proxy.newProxyInstance generates (and caches for reuse) a new class that extends java.lang.reflect.Proxy and implements every interface passed to it. Every method declared by those interfaces is overridden in the generated class to do exactly one thing: package up the Method, its arguments, and the proxy instance itself, and forward them to invocationHandler.invoke(proxy, method, args), whose return value becomes the method's own return value.
AccountService proxy = (AccountService) Proxy.newProxyInstance(
AccountService.class.getClassLoader(),
new Class<?>[] { AccountService.class },
(proxyObj, method, args) -> {
System.out.println("Calling " + method.getName());
return method.invoke(real, args);
});
62. Implement a single, reusable InvocationHandler that adds caching behavior to any interface, keyed by the method called and its arguments, without writing a separate caching proxy class per interface.
Because every call already arrives at one generic invoke method with the Method and its arguments available, a single handler can key a cache on the method name plus its argument list and apply to any interface at all, not just one hand-picked type.
class CachingInvocationHandler implements InvocationHandler {
private final Object real;
private final Map<List<Object>, Object> cache = new ConcurrentHashMap<>();
CachingInvocationHandler(Object real) { this.real = real; }
@Override
public Object invoke(Object proxy, Method method, Object[] args) {
List<Object> key = List.of(method.getName(), args == null ? List.of() : Arrays.asList(args));
return cache.computeIfAbsent(key, k -> {
try {
return method.invoke(real, args);
} catch (Exception ex) {
throw new RuntimeException(ex);
}
});
}
}
63. Explain precisely why java.lang.reflect.Proxy can only proxy interfaces and not concrete classes, tracing the limitation back to single inheritance in the JVM.
The generated proxy class must itself extend java.lang.reflect.Proxy to inherit the dispatch machinery that holds the reference to your InvocationHandler. Since Java classes support only single inheritance, that one superclass slot is already spent, leaving the generated class free only to implement additional interfaces, never to extend another concrete class as well.
This is exactly why CGLIB and ByteBuddy exist: they proxy a concrete class by generating a subclass of that class directly, which sidesteps the need to also extend java.lang.reflect.Proxy.
64. Compare how java.lang.reflect.Proxy dispatches calls (via reflection) to how CGLIB/ByteBuddy-generated proxies dispatch calls (via generated bytecode), and explain the performance implication of each approach.
A JDK dynamic proxy's overridden methods still call back through reflective Method.invoke on the InvocationHandler side, paying reflection overhead, boxing arguments into an Object[], and making it harder for the JIT to inline across that reflective boundary. CGLIB and ByteBuddy instead generate an actual bytecode subclass whose overridden methods call directly into a MethodInterceptor, producing dispatch that is closer to an ordinary virtual call and avoiding boxing in many cases.
In practice this difference is usually dwarfed by whatever genuine I/O the intercepted method actually performs, and only becomes measurable in tight loops of otherwise trivial calls.
65. Explain how equals(), hashCode(), and toString() calls on a JDK dynamic proxy instance are actually handled, and describe a bug this causes when comparing a proxy instance to its real subject.
The generated proxy class overrides Object's equals, hashCode, and toString just like every other method, routing them through the same InvocationHandler.invoke. Unless your handler special-cases them, they typically default to identity-based behavior on the proxy instance itself, not the real subject it wraps.
@Override
public Object invoke(Object proxy, Method method, Object[] args) {
if (method.getName().equals("equals") && args.length == 1) {
return proxy == args[0]; // typical default: identity only, never equal to the real subject
}
// ... forward everything else
return null;
}
== its real subject and, unless equals() is explicitly forwarded, is never equals() to it either, so code expecting the two to collide in a Set or Map silently ends up with two distinct entries.66. How would you measure whether the reflective dispatch overhead of a JDK dynamic proxy is actually significant in a given production code path, rather than assuming it is a bottleneck?
Profile the actual code path under realistic load with a proper tool such as async-profiler or Java Flight Recorder rather than guessing; reflection overhead is measured in nanoseconds per call and is dwarfed by almost any I/O, JSON parsing, or database work the intercepted method typically performs. It matters only in call-heavy inner loops doing genuinely trivial per-call work.
If profiling actually shows it matters, options are switching that path to a CGLIB/ByteBuddy generated proxy, hand-writing a static proxy for just that hot path, or confirming your own code isn't repeating a Method lookup on every call (the JDK already caches this internally).
67. Show how a single java.lang.reflect.Proxy instance can implement multiple unrelated interfaces at once, and describe a realistic use case for doing so.
Proxy.newProxyInstance accepts an array of interfaces, and the single generated class implements all of them; the resulting object can then be cast to whichever interface a particular piece of code needs.
Object proxy = Proxy.newProxyInstance(
loader,
new Class<?>[] { AccountService.class, java.io.Closeable.class },
handler);
AccountService svc = (AccountService) proxy;
Closeable closeable = (Closeable) proxy;
A realistic use case is a resource-like proxy that needs to satisfy the domain interface callers already depend on while also implementing Closeable/AutoCloseable so it can participate in try-with-resources, without the real subject itself needing to implement both.
68. Explain what causes java.lang.reflect.UndeclaredThrowableException when using a JDK dynamic proxy, and how to avoid triggering it.
Each interface method declares a specific set of checked exceptions it is allowed to throw. If your InvocationHandler.invoke lets a checked exception escape that isn't declared on the interface method being called (and isn't a RuntimeException or Error), the generated proxy can't legally propagate it unchanged, so it wraps it in an unchecked UndeclaredThrowableException instead.
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
try {
return method.invoke(real, args);
} catch (InvocationTargetException ex) {
throw ex.getCause(); // unwrap so the *original* declared exception propagates correctly
}
}
InvocationTargetException.getCause() and letting the wrapper exception itself escape is the single most common source of this exception.69. Describe how to unit test code that constructs a JDK dynamic proxy or a generic InvocationHandler, verifying the cross-cutting behavior it adds without re-testing the real subject's business logic.
Test against the interface exactly like any other consumer would, supplying a mock or stub real subject, and assert only the added behavior, that a call was cached, denied, retried, or logged, using interaction verification rather than re-asserting business logic that belongs to the real subject's own tests.
@Test
void cachesRepeatedCallsToSameArgument() {
AccountService real = mock(AccountService.class);
when(real.getBalance("a1")).thenReturn(new Balance("a1", 100));
AccountService proxy = cachingProxy(AccountService.class, real);
proxy.getBalance("a1");
proxy.getBalance("a1");
verify(real, times(1)).getBalance("a1"); // second call served from cache
}
70. Design a small, reusable proxy factory utility that can wrap any interface with a chosen cross-cutting InvocationHandler, so new caching, logging, or retry behavior can be added to any service interface without writing a new proxy class each time.
A single generic factory method captures the boilerplate of calling Proxy.newProxyInstance, leaving only the interface type and the desired handler as call-site concerns.
final class ProxyFactory {
private ProxyFactory() {}
@SuppressWarnings("unchecked")
static <T> T wrap(Class<T> iface, InvocationHandler handler) {
return (T) Proxy.newProxyInstance(iface.getClassLoader(), new Class<?>[] { iface }, handler);
}
}
// usage: same factory, any cross-cutting behavior
AccountService logged = ProxyFactory.wrap(AccountService.class, (p, m, a) -> {
System.out.println("Calling " + m.getName());
return m.invoke(real, a);
});
71. Explain how CGLIB (and its modern successor ByteBuddy) implement proxying for concrete classes without an interface, by generating a runtime subclass, and show a minimal example.
Instead of implementing an interface like the JDK approach, CGLIB and ByteBuddy generate an actual subclass of the target class at runtime, overriding its non-final, accessible methods to route through a MethodInterceptor (CGLIB) or an equivalent callback (ByteBuddy), typically with the option to still call the original implementation via a generated method proxy.
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(RealReportGenerator.class);
enhancer.setCallback((MethodInterceptor) (obj, method, args, methodProxy) -> {
System.out.println("Calling " + method.getName());
return methodProxy.invokeSuper(obj, args);
});
ReportGenerator proxyInstance = (ReportGenerator) enhancer.create();
72. Explain why CGLIB and ByteBuddy cannot proxy a final class, and why a final method on an otherwise proxyable class also cannot be intercepted, tracing both back to the subclassing mechanism.
Since CGLIB/ByteBuddy work by generating a subclass of the target, a final class cannot be extended at all, so proxy creation fails outright with an error at proxy-creation time. A final method, even on a non-final class, cannot be overridden by the generated subclass either, so calls to that specific method reach the original implementation directly, silently bypassing interception, no error is raised for the method itself; it simply never gets intercepted.
final class ImmutableConfig { /* CGLIB: cannot proxy this class at all */ }
class ReportService {
public final void save() { /* CGLIB: this method is silently never intercepted */ }
}
73. Explain how Spring AOP decides, for a given bean, whether to generate a JDK dynamic proxy or a CGLIB/ByteBuddy subclass proxy, and how to force one or the other.
By default, Spring generates a JDK dynamic proxy if the target bean implements at least one interface, and falls back to a CGLIB/ByteBuddy subclass proxy only if it doesn't. Setting proxyTargetClass=true (Spring Boot's default) forces subclassing even when interfaces exist, commonly done so that autowiring by concrete class type keeps working.
@EnableAspectJAutoProxy(proxyTargetClass = true)
public class AppConfig { }
74. Explain exactly how Spring implements @Transactional using a proxy: what the generated proxy does before and after the annotated method executes, and what happens if the method throws.
The proxy wrapping the bean begins a transaction (or joins an existing one, per the configured propagation) before calling through to the real method, commits it if the method returns normally, and rolls it back if the method throws an unchecked exception. Checked exceptions do not trigger a rollback by default unless explicitly configured to.
@Transactional
public void transferFunds(String from, String to, BigDecimal amount) {
debit(from, amount);
credit(to, amount); // if this throws (unchecked), the proxy rolls back the whole transaction
}
@Transactional(rollbackFor = ...) to roll back on a checked exception too.75. Explain the classic Spring self-invocation bug: why calling another @Transactional or @Cacheable method on this from within the same class silently bypasses the proxy's behavior entirely.
Spring's proxy wraps the bean from the outside; interception only happens for calls that arrive through that proxy. Calling another method via this from inside the same class is a plain, direct Java call on the real object, it never passes through the surrounding proxy, so no interception logic runs at all, silently.
@Service
class OrderService {
public void placeOrder(Order order) {
this.chargeCustomer(order); // BUG: direct call, bypasses the @Transactional proxy entirely
}
@Transactional
public void chargeCustomer(Order order) { /* ... */ }
}
76. Describe at least two concrete ways to fix the Spring self-invocation bug so a proxied method's cross-cutting behavior actually applies when called from within the same class.
One fix is injecting the bean into itself, typically via @Lazy self-autowiring, and calling the annotated method through that injected proxy reference instead of this. Another is moving the annotated method into a separate collaborator bean and calling it through that bean's own proxy from the original caller. A less common option, AopContext.currentProxy() with exposeProxy=true, works but is generally considered a code smell.
@Service
class OrderService {
@Lazy @Autowired private OrderService self; // the proxy, injected into itself
public void placeOrder(Order order) {
self.chargeCustomer(order); // now correctly goes through the proxy
}
@Transactional
public void chargeCustomer(Order order) { /* ... */ }
}
77. A teammate marks a @Transactional method final for defensive reasons and is confused when transactions silently stop working. Diagnose the bug and explain the fix.
Because this bean lacks an interface here, Spring is generating a CGLIB/ByteBuddy subclass proxy, which cannot override a final method. The annotation is still parsed, but the interception never actually wraps that method, so it runs entirely unproxied, silently, the same failure mode as Q72 and Q75 but triggered by finality instead of self-invocation.
@Service
class OrderService {
@Transactional
public final void chargeCustomer(Order order) { /* silently never gets a transaction */ }
}
The fix is to remove final, or to extract the method behind an interface so Spring uses a JDK proxy instead of subclassing (interface methods can't be final anyway), or to move the method into a genuinely separate, non-final bean.
78. Produce a single comparison of JDK dynamic proxies, CGLIB, and ByteBuddy covering what each requires of the target, how each dispatches calls, and their current status in the Java ecosystem.
| Approach | Requires of the target | Dispatch mechanism | Ecosystem status |
|---|---|---|---|
| JDK dynamic proxy | Must implement at least one interface | Reflective dispatch to InvocationHandler.invoke | Part of the JDK itself, no dependency needed |
| CGLIB | Non-final class, accessible (or Objenesis-bypassed) constructor | Generated bytecode calling MethodInterceptor | Largely unmaintained upstream; still bundled/shaded by some frameworks for compatibility |
| ByteBuddy | Non-final class, same constructor caveat as CGLIB | Generated bytecode via a more modern, actively maintained API | Actively maintained; Spring's default class-proxying mechanism since replacing CGLIB |
79. Explain the bug that occurs when code casts a Spring-managed bean directly to its concrete implementation class, and why this cast sometimes works and sometimes throws ClassCastException depending on which proxy type Spring chose.
If Spring generated a JDK dynamic proxy, the proxy object only implements the declared interfaces, not the concrete class, so casting it to the concrete implementation class throws ClassCastException. If Spring instead generated a CGLIB/ByteBuddy subclass proxy, the cast happens to work because the proxy genuinely is a subclass of the concrete class, but depending on that is fragile, since it silently breaks the moment the proxy strategy changes, for example when an interface is added or a configuration flag flips.
AccountServiceImpl impl = (AccountServiceImpl) context.getBean(AccountService.class);
// works only if Spring happened to generate a CGLIB/ByteBuddy proxy for this bean
80. Explain how Spring AOP's advice types, before, after, after-returning, after-throwing, and around, map onto the underlying proxy's method interception mechanism.
Every advice type is really a different slice of the same single interception point: the call arriving at the proxy before it reaches (or instead of) the real target's method. Around advice gets the fullest control, receiving a ProceedingJoinPoint and deciding for itself whether and when to call proceed(); before, after, after-returning, and after-throwing are conveniences layered on top that run their own logic at a fixed point relative to that same underlying proceed() call.
@Around("execution(* com.example.AccountService.*(..))")
public Object timeIt(ProceedingJoinPoint pjp) throws Throwable {
long start = System.nanoTime();
try {
return pjp.proceed(); // the same "call the real subject" step every proxy has
} finally {
System.out.println((System.nanoTime() - start) / 1_000_000 + "ms");
}
}
81. Beyond the constructor-level leak discussed for protection proxies (Q25), describe how a Spring application can accidentally leak the unwrapped real bean around a proxy, and why this defeats @Transactional, @Cacheable, or security advice entirely for the leaked reference.
A common route is a bean capturing this into a static field or a manually-populated registry inside @PostConstruct, before the container has necessarily finished wrapping it, or code that obtains the raw target through reflection in a way that escapes the container's own bean-management abstraction. Any caller that later reads that captured reference bypasses every proxy-based concern for it, silently, the exact same underlying failure as self-invocation, just reached through a different escape route.
@Component
class Registry {
static OrderService instance; // captured raw, potentially before proxying finishes
}
@Service
class OrderServiceImpl implements OrderService {
@PostConstruct
void register() {
Registry.instance = this; // leaks the unwrapped bean, bypassing @Transactional forever
}
}
82. Beyond a caller instanceof-checking a proxy (Q10), describe how a dynamic or AOP proxy can break substitutability more subtly, by changing which exceptions propagate or how results are represented compared to the real subject.
A caching InvocationHandler that catches every exception from the real call and returns a cached-null sentinel instead of rethrowing changes the contract callers rely on. A retry proxy that ultimately propagates a wrapped, generic failure type where the real subject would have thrown its own specific exception type breaks catch blocks written against that original type.
// Bad: swallows the real exception and silently returns null instead
try {
return real.getBalance(accountId);
} catch (Exception ex) {
return null; // caller now sees a different contract than the real subject ever had
}
83. In a hand-written, non-dynamic proxy class, what bug occurs if the proxy forgets to override equals() and hashCode(), and it is later placed into a HashSet or used as a Map key expecting equality with its real subject?
Without an override, the proxy inherits Object's identity-based equals/hashCode, so two separate proxy instances wrapping the exact same real subject are never equals() to each other, and neither is equals() to the real subject itself. Code that expects "logically the same account" to collide in a Set silently ends up storing duplicate entries instead.
Set<AccountService> seen = new HashSet<>();
seen.add(realAccountService);
seen.add(new AccountServiceProxy(realAccountService));
// seen.size() == 2, not 1, because neither equals() nor hashCode() was overridden
Forward equals/hashCode to the real subject (or to a stable identifier) explicitly whenever identity-based equality isn't the semantic you actually want.
84. Explain what happens when you attempt to serialize a JDK dynamic proxy instance with standard Java serialization, and what is required for it to actually work, versus attempting to serialize a CGLIB/ByteBuddy subclass proxy.
A JDK dynamic proxy can be serialized only if every interface it implements is Serializable and its InvocationHandler is also Serializable; if either condition fails, serialization throws NotSerializableException at the point it reaches the offending part. A CGLIB/ByteBuddy subclass proxy is generally much harder to serialize reliably, since the generated subclass, its intercepted fields, and its callback wiring are runtime artifacts not designed to survive serialization and deserialization reliably across JVMs, or even across separate runs where the generated class is regenerated differently.
85. Describe why stack traces through several layered dynamic or AOP proxies become hard to read, filled with generated proxy classes and reflective dispatch frames, and how to mitigate this during debugging.
Each layer contributes its own generated proxy class frame plus its InvocationHandler.invoke or MethodInterceptor.intercept frame, and JDK proxies add a reflective Method.invoke frame on top of that, so a call passing through three stacked proxies (Q57) before reaching the real subject can add a dozen or more noisy frames between the caller and the actual failure.
Mitigations: name InvocationHandler/interceptor implementations descriptively rather than as anonymous lambdas so they're identifiable in a trace, keep translation logic in each layer minimal so exceptions aren't needlessly re-wrapped and adding yet more frames, and use the IDE's framework-frame-collapsing feature while stepping through a stack.
86. Describe a deadlock scenario where a real subject, invoked through a synchronized caching proxy, calls back into the same proxy from a different thread, and explain why this differs from the reentrant-safe behavior of Java's own synchronized keyword.
Java's synchronized is reentrant: a thread re-entering a lock it already holds never deadlocks itself. The danger appears when the real subject's work is dispatched to another thread, for example submitted to a fixed-size executor, and that other thread's task needs to call back into the same synchronized proxy method to read a value currently being computed: if the executor has no free thread to run that callback, and the original call is meanwhile blocked waiting on the callback's result, the two calls deadlock waiting on each other, even though no single thread ever tries to re-acquire a lock it already holds.
ExecutorService singleThreadPool = Executors.newFixedThreadPool(1);
// Submitting work from within the pool's own only thread, then blocking on its result, deadlocks
singleThreadPool.submit(() -> {
Future<String> inner = singleThreadPool.submit(() -> "value");
return inner.get(); // never runs: no free thread left to service it
});
87. A caching proxy that seemed fine in testing causes an OutOfMemoryError in production after running for days. Diagnose the likely cause and the fix, building on the eviction policy discussed for LRU caches.
An unbounded Map used as a cache, one with no TTL, no LRU eviction, and no size cap, accumulates one entry per distinct key ever seen. In production, with far more distinct arguments over days than any test run exercises, this map grows without bound until the heap is exhausted. The fix is exactly the bounded LRU cache (Q47) or TTL-based expiry (Q43) that a quick prototype often skips entirely, or adopting a purpose-built library like Caffeine that enforces a maximum size or weight by default.
88. Explain the risk of interface/implementation version skew for a remote proxy, such as a generated RMI stub or a gRPC client stub, when the server-side contract evolves independently of when every client regenerates its stub.
A generated client stub is compiled against a specific version of the shared contract, the Remote interface or the .proto file. If the server adds a field or method and redeploys before every client regenerates and redeploys its own stub, older stubs keep calling against the old contract shape; depending on the framework's compatibility rules this either works transparently (additive, backward-compatible Protobuf field additions), fails outright at the transport layer, or silently loses information the new server tries to communicate that the old stub was never built to read.
89. Explain why CGLIB (and, historically, some ByteBuddy usages) required the proxied class to have an accessible no-argument constructor, and how tools like Objenesis worked around this requirement.
Since CGLIB creates a proxy by instantiating a generated subclass of the target class, and constructing any Java subclass instance normally requires running some constructor of its superclass, CGLIB by default needs an accessible no-arg constructor on the class it is subclassing in order to actually create an instance of the generated subclass.
Objenesis, used internally by frameworks such as Spring and Mockito, sidesteps this entirely by allocating the object's memory directly through JVM-internal APIs, without running any constructor at all, which is why proxies (and mocks) can be created even for classes whose only constructor requires arguments or does non-trivial work in its body.
90. Explain the fail-open versus fail-closed distinction for a protection proxy's error handling, and why a proxy that defaults to allowing access when an unexpected error occurs during the permission check is a serious security bug.
Fail-closed means any error, exception, timeout, or ambiguous result during the authorization check is treated as denied by default; fail-open means the same error is treated as allowed by default. A protection proxy should always fail closed, because the entire reason it exists is that unauthorized access must never slip through, and an unexpected error is exactly the situation where the check is least trustworthy.
try {
if (security.currentUser().hasRole("ADMIN")) {
real.deleteUser(userId);
}
} catch (Exception ex) {
real.deleteUser(userId); // BUG: fails open on any error, including a broken security context
}
91. Summarize an overall testing strategy for a codebase that uses several kinds of proxies, virtual, protection, remote, caching, and dynamic/AOP, describing what to test at each layer and what not to duplicate.
Test the real subject's business logic in isolation, with no proxy involved at all. Test each proxy's specific added behavior, lazy init happens once, denial actually denies and never reaches the real subject, cache hits skip the real subject, retries stop after a bounded number of attempts, against a mocked or faked real subject, verifying interaction counts rather than re-testing business logic that already has its own tests.
Reserve a small number of true integration tests to confirm the fully composed stack, for example Spring's actual generated proxy plus real beans, behaves correctly end-to-end, since isolated unit tests around individual proxies cannot catch composition-order bugs (Q57) or framework wiring mistakes like self-invocation or final methods (Q75, Q77).
92. Go deeper than a Connection.close() override (Q39): describe how a production-grade connection-pooling proxy such as HikariCP's also wraps Statement and ResultSet objects, and why that additional wrapping matters for leak detection.
A production pool's Connection proxy doesn't just intercept close(); it also wraps every Statement/PreparedStatement it creates in its own proxy, tracking which statements a given pooled connection currently has open. When the connection is returned to the pool, any statement the caller forgot to close can be force-closed and logged as a leak, rather than silently held open against a connection that's already back in the pool and handed to a different caller.
93. Distinguish Hibernate's lazy-collection proxies (such as PersistentBag or PersistentSet wrapping a one-to-many association) from the lazy entity proxies discussed earlier (Q13), and describe fixes specific to collection associations.
A lazy entity association proxy (Q13) stands in for a single related entity and initializes by loading exactly that one row. A lazy collection proxy wraps an entire associated collection and, when first touched, triggers a query loading every row in that one-to-many relationship, a different and often much larger cost profile than a single-entity proxy.
List<Order> orders = entityManager.createQuery(
"select o from Order o join fetch o.lineItems where o.status = :status", Order.class)
.setParameter("status", "OPEN")
.getResultList(); // one query; lineItems is already initialized, no further per-order query
Fixes specific to collections: @BatchSize on the collection mapping to batch-initialize several parents' collections in one IN-clause query instead of one per parent, an entity graph or JOIN FETCH for exactly the collections a given query needs, and avoiding the classic mistake of eagerly fetching two or more separate collections in a single JOIN FETCH query, which multiplies rows in a Cartesian-product fashion.
94. Describe how the Proxy pattern can be combined with the Observer pattern so that intercepting a call through a proxy also publishes a notification to interested listeners whenever the real subject's state changes.
The proxy sits in front of every mutating call, so it is a natural place to fire notification events: after delegating a mutating method to the real subject, it walks its list of registered observers and notifies them, without the real subject's own code needing to know anything about the observer mechanism at all.
class ObservableAccountProxy implements AccountService {
private final AccountService real;
private final List<BalanceListener> listeners = new CopyOnWriteArrayList<>();
@Override
public void withdraw(String accountId, BigDecimal amount) {
real.withdraw(accountId, amount);
Balance updated = real.getBalance(accountId);
listeners.forEach(l -> l.onBalanceChanged(accountId, updated));
}
}
95. Describe how the Proxy pattern can be combined with the Command pattern to implement a write-behind proxy that queues mutating calls as Command objects for asynchronous, batched execution instead of applying them immediately.
Instead of forwarding a mutating call straight to the real subject, the proxy wraps the intended operation and its arguments into a queued command and appends it to an internal queue, returning to the caller immediately; a background worker later drains that queue, replaying each command against the real subject, batching several together where the real subject supports it.
class WriteBehindOrderProxy implements OrderRepository {
private final BlockingQueue<Runnable> pending = new LinkedBlockingQueue<>();
private final OrderRepository real;
@Override
public void save(Order order) {
pending.offer(() -> real.save(order)); // queued command, applied later by a worker thread
}
}
96. Describe the internal architecture of a service mesh sidecar proxy such as Envoy: how traffic is transparently redirected into it, and how it decides where and how to route each intercepted call.
Each service instance runs its own sidecar proxy process alongside it, and outbound and inbound traffic is transparently redirected into that sidecar, typically via iptables rules or an equivalent traffic-interception mechanism, rather than any change to the application's own code. The application never explicitly calls the sidecar; it calls the destination address as it always would, and the network layer routes that call through the sidecar first.
The sidecar's routing, retry, timeout, and mTLS behavior is itself dynamically configured by a control plane pushing configuration over an API such as Envoy's xDS, so what the sidecar does with an intercepted call, which real destination it forwards to, what to retry, what to encrypt, can change live without redeploying the application at all, unlike a compiled-in client stub whose behavior is fixed at build time.
97. An interviewer pushes back: "Isn't every wrapper class technically a proxy, since it forwards calls to another object?" How would you respond precisely, distinguishing the colloquial and the GoF-specific senses of the word?
Colloquially, yes: any object that holds and forwards to another object is a "wrapper" in the loose sense, and Adapter, Decorator, Facade, and Proxy are all wrappers under that loose definition. But the GoF Proxy pattern is a specific, narrower claim: the same interface as the thing wrapped, and the wrapping exists specifically to control access, not to translate an interface (Adapter), add stacked behavior (Decorator), or simplify a whole subsystem (Facade).
A strong answer acknowledges the structural overlap the interviewer is pointing at, then draws the line on intent: structurally, yes, they're all wrappers; the GoF names distinguish them by why the wrapping exists, and calling every wrapper a "proxy" loses exactly the information the pattern name is supposed to communicate to the next developer who reads the code.
98. Describe how to correctly benchmark the overhead of a JDK dynamic proxy versus a CGLIB/ByteBuddy proxy versus a direct call using JMH, and the common measurement mistakes that produce misleading results.
Use a proper microbenchmarking harness such as JMH rather than a hand-rolled loop with System.nanoTime(). Naive loops are vulnerable to JIT warmup not being accounted for, dead-code elimination (the JIT discovering the benchmarked call's result is never used and optimizing the whole call away), and constant-folding when arguments never vary between iterations.
@Benchmark
public Balance directCall(BenchmarkState state) {
return state.real.getBalance("acct-1");
}
@Benchmark
public Balance jdkProxyCall(BenchmarkState state) {
return state.jdkProxy.getBalance("acct-1");
}
99. Describe a strangler-fig style legacy migration where a Facade unifies the old and new subsystem's interface for callers, while an internal Proxy decides, per call, whether to route to the legacy implementation or the new one.
The Facade gives every caller one stable, simplified entry point so they never need to know a migration is happening at all. Behind that facade, a routing proxy implementing the same fine-grained interface as both the legacy and new subsystems decides per call, per feature flag, per tenant, or per gradually increasing rollout percentage, which underlying implementation actually handles the request; this is exactly the feature-flagged proxy from Q24, now sitting behind a Facade instead of being called directly.
class OrderFacade {
private final OrderOperations routingProxy; // Proxy: decides legacy vs. new per call
public OrderSummary placeOrder(OrderRequest request) {
return routingProxy.place(request).toSummary(); // Facade: one simple entry point for callers
}
}
100. Summarize, as a final decision checklist, when the Proxy pattern specifically is the correct choice over Decorator, Adapter, or Facade, and give the single question that most reliably distinguishes it from all three.
Reach for Proxy when the interface the client already depends on does not need to change, when the point of wrapping is controlling whether, when, or how a call reaches the real subject rather than adding stacked behavior, translating an incompatible contract, or simplifying a whole subsystem, and when the client must never be able to tell, through behavior, exceptions, or type-checking, that it's holding a stand-in rather than the real thing.
The single most reliable distinguishing question: does this wrapper ever legitimately decide not to call through to the real object, or to change when or how it does? If yes, that's Proxy's defining trait. If it always forwards and only adds independent, stackable behavior around the call, that's Decorator; if it exists because the interfaces don't match, that's Adapter; if it exists to hide a whole subsystem behind one simpler entry point, that's Facade.
Post a Comment
Add