Proxy — Find the Bug¶
Each section presents a Proxy that looks fine but is broken. Find the bug yourself, then check.
Table of Contents¶
- Bug 1: Non-thread-safe lazy init
- Bug 2: Volatile missing in DCL
- Bug 3: Cache stampede
- Bug 4: Cache never invalidated
- Bug 5: Spring AOP self-invocation
- Bug 6: Protection proxy doesn't check on read
- Bug 7: Remote proxy without timeout
- Bug 8: Exception type leaks across proxy
- Bug 9: Returning a proxy where caller expected real subject
- Bug 10: Smart-pointer double release
- Bug 11: Cache holds stale data after write
- Bug 12: Dynamic proxy doesn't intercept dunder methods (Python)
- Practice Tips
Bug 1: Non-thread-safe lazy init¶
public final class LazyProxy implements Service {
private Service real;
private final Supplier<Service> supplier;
public Service real() {
if (real == null) {
real = supplier.get(); // race
}
return real;
}
public Result call(Request req) { return real().call(req); }
}
Under concurrent load, sometimes the supplier is called twice. Sometimes call operates on a half-constructed instance.
Reveal
**Bug:** No synchronization. Two threads can both see `real == null` and both invoke `supplier.get()`. Worse, without `volatile`, the second thread can see a partially-constructed `real` (memory reorder). **Fix:** double-checked locking with volatile. **Lesson:** Lazy init in concurrent code requires explicit synchronization. The compiler won't save you.Bug 2: Volatile missing in DCL¶
private Service real; // not volatile
public Service real() {
if (real == null) {
synchronized (lock) {
if (real == null) real = new Service(...);
}
}
return real;
}
The supplier is called once correctly. But intermittent NullPointerExceptions appear in production.
Reveal
**Bug:** Without `volatile`, the JVM can reorder operations. A reader can observe `real != null` but the constructor assignments haven't propagated yet — leading to a partially-constructed object. **Fix:** add `volatile`. **Lesson:** Double-checked locking without `volatile` was famously broken in Java 1.4. Java 5+ requires `volatile` for safe publication.Bug 3: Cache stampede¶
class CachingProxy:
def get(self, key):
if key in self._cache: return self._cache[key]
v = self._inner.get(key) # 100 threads can all reach here
self._cache[key] = v
return v
Backend is overloaded after a cache flush; 100 concurrent users requesting the same key cause 100 backend hits.
Reveal
**Bug:** No protection against concurrent misses. All threads trigger the inner call simultaneously. **Fix:** single-flight or lock per key.import threading
class CachingProxy:
def __init__(self, inner):
self._inner = inner
self._cache = {}
self._inflight = {}
self._lock = threading.Lock()
def get(self, key):
with self._lock:
if key in self._cache: return self._cache[key]
if key in self._inflight:
event = self._inflight[key]
else:
event = threading.Event()
self._inflight[key] = event
# this thread is the leader
if event in self._inflight.values():
# leader: do the work
v = self._inner.get(key)
with self._lock:
self._cache[key] = v
del self._inflight[key]
event.set()
return v
else:
event.wait()
return self._cache[key]
Bug 4: Cache never invalidated¶
class CachingProxy:
def get(self, key): return self._cache.setdefault(key, self._inner.get(key))
def update(self, key, value): return self._inner.update(key, value) # no eviction!
Test: proxy.update("alice", "new") then proxy.get("alice") returns the old value.
Reveal
**Bug:** `update` doesn't evict the cache. The cache holds stale data forever. **Fix:** evict on writes. **Lesson:** Caching proxies must intercept writes too — otherwise reads serve stale data.Bug 5: Spring AOP self-invocation¶
@Service
public class UserService {
@Transactional
public void registerWithVerify(User u) {
save(u);
verify(u); // calls saveAuditLog inside
}
@Transactional(propagation = REQUIRES_NEW)
public void verify(User u) {
saveAuditLog(u);
}
}
When registerWithVerify is called, verify's REQUIRES_NEW doesn't take effect — both run in the same transaction.
Reveal
**Bug:** Self-invocation in Spring AOP. `this.verify(u)` bypasses the proxy; the `@Transactional(propagation = REQUIRES_NEW)` is ignored. **Fix:** inject self via `ApplicationContext` (workaround), or split `verify` into a separate bean, or use AspectJ. Or restructure: **Lesson:** Spring AOP doesn't proxy in-class calls. Aware engineers structure code to avoid the trap.Bug 6: Protection proxy doesn't check on read¶
public class ProtectionProxy implements Document {
public String content() { return inner.content(); } // ← anyone reads
public void update(String text) {
if (!user.hasRole("editor")) throw new SecurityException();
inner.update(text);
}
}
A confidential document is leaked to all users because the proxy didn't check read access.
Reveal
**Bug:** Read access wasn't gated. The proxy assumed reads were always public. **Fix:** gate reads too if the requirement is read access control. **Lesson:** Verify the access policy explicitly. "Anyone can read" should be a deliberate decision, not an oversight.Bug 7: Remote proxy without timeout¶
class RemoteUserService:
def get_user(self, id: str):
r = self._sess.get(f"{self._base}/users/{id}") # no timeout
return r.json()
The remote service hangs; the calling thread waits forever; under load, every thread parks.
Reveal
**Bug:** No timeout. A hung remote service kills the calling service. **Fix:** always set timeouts. **Lesson:** Every remote call needs an explicit timeout. "Default forever" is choosing failure.Bug 8: Exception type leaks across proxy¶
public class StripePaymentProxy implements PaymentService {
public Receipt charge(...) {
return stripeClient.charges().create(...);
// throws StripeException — vendor type
}
}
Callers wrote catch (StripeException ...). Migration to Adyen requires touching every catch block.
Reveal
**Bug:** Vendor exception leaks past the proxy. Callers depend on Stripe's type. **Fix:** translate at the proxy boundary. **Lesson:** Proxies that wrap third-party services should translate exceptions to domain types — preserves substitutability.Bug 9: Returning a proxy where caller expected real subject¶
@Repository
public class UserRepo {
public User findById(String id) { return em.find(User.class, id); } // returns Hibernate proxy
}
User u = repo.findById("alice");
if (u instanceof RealUser real) { // false! it's a proxy class
...
}
The instanceof check fails because Hibernate returned a generated proxy class, not RealUser.
Reveal
**Bug:** Caller expected the real class. The ORM's proxy class isn't `RealUser`; type-based branching breaks. **Fix:** don't type-check across proxy boundaries. Use behavior (interfaces) or `Hibernate.unproxy(u)` to get the real instance. Or restructure to not depend on concrete types. **Lesson:** Proxies break `instanceof` and reflection checks. Avoid relying on concrete types when proxies may be involved.Bug 10: Smart-pointer double release¶
class SharedRef {
T* raw;
int* count;
~SharedRef() {
if (--(*count) == 0) delete raw;
}
};
SharedRef a(new T());
SharedRef b = a; // shallow copy: b shares raw and count
// destructors fire: a decrements, b decrements, both reach 0
A copy constructor is missing; the default copies pointers without incrementing refcount. Double-free crashes the program.
Reveal
**Bug:** No proper copy constructor. Both `a` and `b` think they own the resource; both delete. **Fix:** implement copy constructor that increments refcount. (Or use `std::shared_ptr`.) **Lesson:** Smart references need careful copy/move semantics. Refcount must be atomic in multi-threaded contexts.Bug 11: Cache holds stale data after write¶
public class CachingUserProxy implements UserRepository {
public User find(String id) {
return cache.computeIfAbsent(id, k -> inner.find(k));
}
public void save(User u) {
inner.save(u); // forgot to invalidate cache!
}
}
repo.find("alice"); // caches alice v1
repo.save(alice.withEmail("new")); // saves to DB; cache untouched
repo.find("alice"); // returns cached alice v1 (old email)
Reveal
**Bug:** Writes don't invalidate the cache. Subsequent reads return stale data. **Fix:** evict on writes. Or write-through: **Lesson:** Caching proxies that intercept reads must also intercept writes — to invalidate or update.Bug 12: Dynamic proxy doesn't intercept dunder methods (Python)¶
class Proxy:
def __init__(self, inner):
self._inner = inner
def __getattr__(self, name):
return getattr(self._inner, name)
real = Number(5)
proxy = Proxy(real)
print(real + 3) # works (Number defines __add__)
print(proxy + 3) # TypeError: unsupported operand
Reveal
**Bug:** Python's special method lookup bypasses `__getattr__`. `proxy + 3` looks up `Proxy.__add__` (not defined); it doesn't fall back to `__getattr__`. **Fix:** explicitly define dunder methods that forward. **Lesson:** `__getattr__` doesn't handle dunders. Dynamic proxies in Python need explicit dunder forwarding.Practice Tips¶
- Read each snippet, stop, predict the failure mode.
- For each bug, ask: "what's the worst production outcome?" Many proxy bugs are silent (stale cache, missing auth, lazy init race).
- After fixing, write a test that would have caught the bug. If it's awkward, the fix is incomplete.
- Repeat in a week. Proxy bugs cluster: thread safety, cache invalidation, exception handling, identity.
← Back to Proxy folder · ↑ Structural Patterns · ↑↑ Roadmap Home
Next: Proxy — Optimize