A java.lang.reflect.Proxy routes interface calls through an InvocationHandler, including equals, hashCode, and toString declared by Object.
Java dynamic proxies: define Object-method behavior explicitly
Operational contract
The adapter counts receipt reads and delegates the business call. It implements identity-based equals and hashCode for the proxy itself, and a fixed diagnostic toString. That avoids accidentally forwarding Object calls to an unrelated delegate, recursing through the proxy, or returning a value with the wrong type. A dynamic proxy only implements interfaces; a concrete class cannot be proxied this way. The method handler runs on the caller's thread, so a blocking delegate still blocks that caller.
Failure case
A metrics registry inserts a receipt-reader proxy into a hash map. If hashCode returns null or changes with a read count, lookup breaks even though the read method works. This adapter preserves stable identity semantics.
Java code
import java.lang.reflect.Proxy;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicLong;
public class ReceiptReaderProxy {
public interface Reader { String read(long receiptId); }
public static Reader counting(Reader delegate, AtomicLong reads) {
Objects.requireNonNull(delegate);
Objects.requireNonNull(reads);
return (Reader) Proxy.newProxyInstance(
Reader.class.getClassLoader(), new Class<?>[] { Reader.class },
(proxy, method, arguments) -> {
if (method.getDeclaringClass() == Object.class) {
return switch (method.getName()) {
case "equals" -> proxy == arguments[0];
case "hashCode" -> System.identityHashCode(proxy);
case "toString" -> "ReceiptReaderProxy";
default -> throw new UnsupportedOperationException(method.getName());
};
}
if (!method.getName().equals("read"))
throw new UnsupportedOperationException(method.getName());
reads.incrementAndGet();
return delegate.read((long) arguments[0]);
});
}
}Performance and ownership cost
Each call adds proxy dispatch and an atomic increment before the delegate's work. Storage is O(1) per proxy. A hot call path can make the shared counter contended, so measure it before placing a single counter on every high-volume invocation.
Common Mistakes
- Do not forget equals, hashCode, and toString can reach the handler.
- Do not return null for primitive-returning methods such as hashCode.
- Do not expect Proxy to subclass a concrete class or make a blocking delegate asynchronous.
Connected lessons
- Java reflection: inspect types without breaking contracts
- Java ThreadLocal: restore request context when a pooled thread is reused
- Java ServiceLoader: treat provider discovery as fallible startup work
- Java Class.forName: inspect an allowlisted class without initializing it
- Java runtime discovery contracts quiz
- Advanced Java
