Why these two methods in particular
Lazy loading through the proxy pattern works by interception. Callers invoke methods on the proxy, and the proxy decides whether to load the real subject and delegate. That only covers methods the proxy can override, and getClass() is declared final on Object. No proxy, generated or hand-written, can make getClass() report the subject's class. A subclass-based proxy reports its generated subclass. An interface-based proxy from java.lang.reflect.Proxy reports a generated class that implements the interfaces and extends nothing of yours.
equals is overridable, and both kinds of proxy do route it. JDK dynamic proxies send equals, hashCode and toString through the invocation handler. The breakage comes from the other side of the comparison, because equals takes an argument, and the argument may be a proxy the method does not expect.
Four ways equality breaks
Take the typical equals an IDE generates for an entity loaded through an ORM, and imagine Customer as a lazily loaded association.
The getClass() != o.getClass() guard rejects a proxy outright. With Hibernate, which proxies lazy associations as generated subclasses of the entity, the result is asymmetric. Calling equals on the proxy delegates to the loaded entity, which compares two real objects and returns true. Calling it on the real entity with the proxy as argument hits the class check and returns false. Set membership then depends on which object was inserted and which was looked up, and that is the kind of defect nobody reproduces twice.
Field access on the argument bypasses the proxy. this.id.equals(other.id) reads the id field of whatever other is. If other is a subclass-based proxy, that field belongs to the proxy instance and was never populated, because the proxy keeps the real data in its target. Only a method call such as other.getId() is intercepted. The same applies to final methods, which a subclass proxy cannot override and therefore cannot delegate.
Identity checks see two objects. ==, IdentityHashMap and any cache keyed by reference treat the proxy and the subject as different, even when they represent the same thing.
Equality can trigger loading. An equals or toString that touches the subject's data initialises the proxy, which costs a query. If the session or connection is gone, it throws. Debuggers call toString to render variables, so the failure can appear the moment you inspect a value.
Designing a proxy that survives
The fix is to make equality a property of the abstraction rather than of either class. java.util.List is the precedent: its contract defines equality by content, so an ArrayList equals a LinkedList with the same elements. Do the same with identity. Put an id on the interface, make sure the proxy can answer it without loading, and have every implementation compare through instanceof on the interface and the accessor.
Both classes are final, so a further subclass cannot add state to equals and break symmetry. Equality, hashing and toString never touch the expensive data. Callers that need to branch on the kind of document use the interface or instanceof, never getClass().
The ORM version you will meet
With JPA entities you cannot make the class final, because the proxy is a subclass of it. The same rules apply in a different shape. Compare with o instanceof Customer other, read other.getId() rather than other.id, and treat a null id as unequal to everything except itself. Return a constant per type from hashCode, such as Customer.class.hashCode(), because the id is often assigned on insert and an id-based hash would move the object to a different bucket in any set holding it. When you truly need the real class or object, Hibernate.getClass and Hibernate.unproxy return it, initialising the proxy as a side effect.
When the proxy pattern earns its cost
A virtual proxy for lazy loading is worth it when loading is expensive, often unnecessary, and callers should not have to care. That last condition is also its weakness. A proxy that impersonates the subject hides a database call behind what looks like a getter, which is how N+1 queries and closed-session errors arrive. When callers can reasonably know that something is deferred, an explicit holder such as a Supplier<Document> or a small Lazy<T> type is more honest. Nobody compares it to a real document, and nobody is surprised that calling it costs something. Reach for the proxy pattern when transparency is the requirement, and pay for it with an equality contract designed up front.
Equality on a proxied type has to be defined by the interface and a cheap identity key, compared through instanceof and accessors. Anything that inspects the class or the fields is inspecting the proxy.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).