Proxy Pattern Best Practices in C#: Code Organization and Maintainability
You understand how the proxy pattern works. You've wrapped a service behind an interface, maybe added some caching or lazy initialization, and resolved it through dependency injection. But there's a gap between a working proxy and a well-structured one. Proxy pattern best practices in C# cover more than implementing a stand-in for another object -- they address how to keep proxy logic clean, how to integrate proxies into your DI container, how to handle concurrency, and how to avoid the subtle mistakes that erode maintainability over time.
This guide walks through the practical principles that separate solid proxy implementations from the ones that cause headaches six months later. We'll cover full interface compliance, separating proxy logic from business logic, thread-safe lazy initialization, cache invalidation strategies, DI registration patterns, testing in isolation, naming conventions, async proxies, chain depth, and common anti-patterns. If you're looking for guidance on related structural patterns, check out the Decorator Design Pattern in C#: Complete Guide -- the two patterns share similarities but serve different purposes.
Implement the Full Subject Interface
A proxy must implement every method on the subject interface. Skipping methods, throwing NotImplementedException, or leaving stubs that silently do nothing breaks the contract that consumers rely on. The entire point of a proxy is that callers don't know they're talking to one. If the proxy doesn't honor the full interface, that transparency is gone.
Here's an IDocumentRepository interface and what happens when a proxy takes shortcuts:
using System.Collections.Generic;
using System.Threading.Tasks;
public interface IDocumentRepository
{
Task<Document> GetByIdAsync(string id);
Task<IReadOnlyList<Document>> SearchAsync(
string query);
Task SaveAsync(Document document);
Task DeleteAsync(string id);
}
public sealed record Document(
string Id,
string Title,
string Content);
The anti-pattern -- a proxy that skips methods it doesn't care about:
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
// Bad: Incomplete interface implementation
public class CachingDocumentRepositoryProxy
: IDocumentRepository
{
private readonly IDocumentRepository _real;
public CachingDocumentRepositoryProxy(
IDocumentRepository real)
{
_real = real
?? throw new ArgumentNullException(nameof(real));
}
public async Task<Document> GetByIdAsync(string id)
{
// Caching logic here...
return await _real.GetByIdAsync(id);
}
public Task<IReadOnlyList<Document>> SearchAsync(
string query)
{
throw new NotImplementedException();
}
public Task SaveAsync(Document document)
{
throw new NotImplementedException();
}
public Task DeleteAsync(string id)
{
throw new NotImplementedException();
}
}
The correct approach -- every method delegates to the real subject, and the proxy only adds behavior where it's relevant:
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading.Tasks;
// Good: Full interface compliance
public class CachingDocumentRepositoryProxy
: IDocumentRepository
{
private readonly IDocumentRepository _real;
private readonly ConcurrentDictionary<string, Document>
_cache = new();
public CachingDocumentRepositoryProxy(
IDocumentRepository real)
{
_real = real
?? throw new ArgumentNullException(nameof(real));
}
public async Task<Document> GetByIdAsync(string id)
{
if (_cache.TryGetValue(id, out var cached))
{
return cached;
}
var document = await _real.GetByIdAsync(id);
if (document is not null)
{
_cache.TryAdd(id, document);
}
return document;
}
public Task<IReadOnlyList<Document>> SearchAsync(
string query)
{
return _real.SearchAsync(query);
}
public async Task SaveAsync(Document document)
{
await _real.SaveAsync(document);
_cache.AddOrUpdate(
document.Id,
document,
(_, _) => document);
}
public async Task DeleteAsync(string id)
{
await _real.DeleteAsync(id);
_cache.TryRemove(id, out _);
}
}
Methods that don't need proxy behavior still delegate directly to the real subject. No exceptions, no stubs -- just clean pass-through. This keeps callers safe regardless of which implementation they receive.
Keep Proxy Logic Separate from Business Logic
A proxy controls access to an object. It doesn't contain the business rules that the object implements. When proxy responsibilities -- like caching, authorization, or lazy loading -- get tangled with domain logic, the resulting class becomes difficult to test, reason about, and modify independently.
Consider a logging proxy. Its job is to record what's happening and delegate to the real subject:
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
// Good: Proxy logic only -- no business rules
public class LoggingDocumentRepositoryProxy
: IDocumentRepository
{
private readonly IDocumentRepository _real;
private readonly ILogger<LoggingDocumentRepositoryProxy>
_logger;
public LoggingDocumentRepositoryProxy(
IDocumentRepository real,
ILogger<LoggingDocumentRepositoryProxy> logger)
{
_real = real
?? throw new ArgumentNullException(nameof(real));
_logger = logger
?? throw new ArgumentNullException(nameof(logger));
}
public async Task<Document> GetByIdAsync(string id)
{
_logger.LogInformation(
"Retrieving document {DocumentId}", id);
var result = await _real.GetByIdAsync(id);
_logger.LogInformation(
"Retrieved document {DocumentId}: {Found}",
id,
result is not null);
return result;
}
// ... remaining methods delegate with logging
}
If you find your proxy validating document content, enforcing naming rules, or computing derived values, that logic belongs in the real subject or a separate service. The proxy should remain a thin coordination layer. This separation makes it straightforward to swap proxies in and out without touching business logic, and it keeps each class independently testable.
The same principle applies in reverse. Your real subject shouldn't contain caching, logging, or access control logic. Those cross-cutting concerns belong in proxies or middleware. This clean separation aligns with inversion of control principles -- dependencies flow inward, and infrastructure concerns wrap around domain logic rather than embedding inside it.
Thread-Safe Lazy Initialization
Virtual proxies that defer object creation until first use need thread-safe initialization. In a web application or background service, multiple threads may hit the proxy simultaneously. Without proper synchronization, you risk creating the expensive object multiple times or returning a partially constructed instance.
The simplest and most reliable approach is Lazy<T>:
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
// Good: Thread-safe lazy initialization with Lazy<T>
public class LazyDocumentRepositoryProxy
: IDocumentRepository
{
private readonly Lazy<IDocumentRepository> _real;
public LazyDocumentRepositoryProxy(
Func<IDocumentRepository> factory)
{
_real = new Lazy<IDocumentRepository>(
factory
?? throw new ArgumentNullException(
nameof(factory)));
}
public Task<Document> GetByIdAsync(string id)
{
return _real.Value.GetByIdAsync(id);
}
public Task<IReadOnlyList<Document>> SearchAsync(
string query)
{
return _real.Value.SearchAsync(query);
}
public Task SaveAsync(Document document)
{
return _real.Value.SaveAsync(document);
}
public Task DeleteAsync(string id)
{
return _real.Value.DeleteAsync(id);
}
}
Lazy<T> uses LazyThreadSafetyMode.ExecutionAndPublication by default, which guarantees exactly one initialization even under concurrent access. This is the right default for almost all proxy scenarios.
Here's the anti-pattern -- a hand-rolled approach with a race condition:
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
// Bad: Race condition in lazy initialization
public class UnsafeLazyDocumentRepositoryProxy
: IDocumentRepository
{
private readonly Func<IDocumentRepository> _factory;
private IDocumentRepository _real;
public UnsafeLazyDocumentRepositoryProxy(
Func<IDocumentRepository> factory)
{
_factory = factory;
}
private IDocumentRepository Real
{
get
{
// Two threads can both see _real as null
// and create two instances
if (_real is null)
{
_real = _factory();
}
return _real;
}
}
public Task<Document> GetByIdAsync(string id)
{
return Real.GetByIdAsync(id);
}
// ... other methods use Real property
}
If you need more control than Lazy<T> provides -- for example, if you need to reset the lazy value or handle initialization failures differently -- use SemaphoreSlim for async-compatible locking. But start with Lazy<T> and only move to a more complex approach when you have a specific reason.
Cache Invalidation Strategies for Caching Proxies
Caching proxies are one of the most common proxy pattern applications, but the cache is only useful if it stays in sync with the underlying data. A cache that serves stale results is worse than no cache at all because it creates bugs that are difficult to reproduce and diagnose.
Three practical strategies work well for caching proxies in C#:
Time-based expiration is the simplest. Use MemoryCache with explicit expiration policies:
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
public class TimedCachingDocumentRepositoryProxy
: IDocumentRepository
{
private readonly IDocumentRepository _real;
private readonly IMemoryCache _cache;
private readonly TimeSpan _expiration;
public TimedCachingDocumentRepositoryProxy(
IDocumentRepository real,
IMemoryCache cache,
TimeSpan expiration)
{
_real = real
?? throw new ArgumentNullException(nameof(real));
_cache = cache
?? throw new ArgumentNullException(nameof(cache));
_expiration = expiration;
}
public async Task<Document> GetByIdAsync(string id)
{
var cacheKey = $"doc:{id}";
if (_cache.TryGetValue(cacheKey, out Document cached))
{
return cached;
}
var document = await _real.GetByIdAsync(id);
if (document is not null)
{
_cache.Set(
cacheKey,
document,
_expiration);
}
return document;
}
public Task<IReadOnlyList<Document>> SearchAsync(
string query)
{
return _real.SearchAsync(query);
}
public async Task SaveAsync(Document document)
{
await _real.SaveAsync(document);
_cache.Remove($"doc:{document.Id}");
}
public async Task DeleteAsync(string id)
{
await _real.DeleteAsync(id);
_cache.Remove($"doc:{id}");
}
}
Write-through invalidation evicts or updates cache entries when mutating operations occur. Notice how SaveAsync and DeleteAsync in the example above remove stale entries. This is essential -- a caching proxy that only handles reads but ignores writes will serve stale data after any mutation.
Event-driven invalidation works for distributed scenarios. If another service modifies the underlying data, the proxy subscribes to change notifications and clears affected entries. You can combine this with the Observer Design Pattern in C#: Complete Guide for a clean notification mechanism.
Regardless of which strategy you use, always consider what happens on a cache miss and be explicit about cache key construction. Inconsistent key formatting is a common source of bugs in caching proxies.
DI Registration Patterns
Integrating proxies with dependency injection is where the pattern truly shines, but incorrect registration can lead to infinite recursion, missing proxies, or confusing resolution behavior. There are three reliable approaches for registering proxies with IServiceCollection in C#.
Scrutor's Decorate method is the cleanest option:
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddScoped<IDocumentRepository,
SqlDocumentRepository>();
services.Decorate<IDocumentRepository,
CachingDocumentRepositoryProxy>();
services.Decorate<IDocumentRepository,
LoggingDocumentRepositoryProxy>();
Scrutor handles the wrapping chain automatically. Each Decorate call wraps the previous registration, so the outermost proxy is the last one registered.
Manual decoration with factory delegates works when you don't want a third-party dependency:
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddScoped<SqlDocumentRepository>();
services.AddScoped<IDocumentRepository>(sp =>
{
var real = sp.GetRequiredService<
SqlDocumentRepository>();
var cached = new CachingDocumentRepositoryProxy(
real,
sp.GetRequiredService<
Microsoft.Extensions.Caching.Memory.IMemoryCache>(),
TimeSpan.FromMinutes(5));
return cached;
});
Factory delegates for lazy proxies let the DI container manage the deferred creation:
using System;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddScoped<SqlDocumentRepository>();
services.AddScoped<IDocumentRepository>(sp =>
new LazyDocumentRepositoryProxy(
() => sp.GetRequiredService<
SqlDocumentRepository>()));
The critical rule: never register a proxy as the same interface and have the proxy resolve that same interface from the container. That creates infinite recursion. Register the concrete real subject under its own type and have the proxy resolve that concrete type explicitly.
Testing Proxies in Isolation
A well-structured proxy depends on an interface for the real subject, which means you can test it by mocking that interface. This is one of the most valuable proxy pattern best practices in C# because it keeps tests fast, focused, and independent of external resources like databases or network services.
using System.Threading.Tasks;
using Moq;
using Xunit;
public class CachingDocumentRepositoryProxyTests
{
[Fact]
public async Task GetByIdAsync_ReturnsCachedValue_OnSecondCall()
{
// Arrange
var mockRepo = new Mock<IDocumentRepository>();
var document = new Document(
"doc-1", "Title", "Content");
mockRepo
.Setup(r => r.GetByIdAsync("doc-1"))
.ReturnsAsync(document);
var proxy = new CachingDocumentRepositoryProxy(
mockRepo.Object);
// Act
var first = await proxy.GetByIdAsync("doc-1");
var second = await proxy.GetByIdAsync("doc-1");
// Assert
Assert.Same(first, second);
mockRepo.Verify(
r => r.GetByIdAsync("doc-1"),
Times.Once);
}
[Fact]
public async Task SaveAsync_DelegatesToRealSubject()
{
// Arrange
var mockRepo = new Mock<IDocumentRepository>();
var document = new Document(
"doc-2", "Updated", "New content");
var proxy = new CachingDocumentRepositoryProxy(
mockRepo.Object);
// Act
await proxy.SaveAsync(document);
// Assert
mockRepo.Verify(
r => r.SaveAsync(document),
Times.Once);
}
[Fact]
public async Task DeleteAsync_InvalidatesCache()
{
// Arrange
var mockRepo = new Mock<IDocumentRepository>();
var document = new Document(
"doc-3", "Title", "Content");
mockRepo
.Setup(r => r.GetByIdAsync("doc-3"))
.ReturnsAsync(document);
var proxy = new CachingDocumentRepositoryProxy(
mockRepo.Object);
await proxy.GetByIdAsync("doc-3");
// Act
await proxy.DeleteAsync("doc-3");
await proxy.GetByIdAsync("doc-3");
// Assert -- should hit the real subject twice
mockRepo.Verify(
r => r.GetByIdAsync("doc-3"),
Times.Exactly(2));
}
}
Key testing principles for proxies:
- Verify delegation. Every proxy method should forward calls to the real subject. Confirm that the mock received the expected call with the expected arguments.
- Test proxy-specific behavior. For a caching proxy, verify cache hits and cache misses. For an authorization proxy, verify that unauthorized calls are rejected before reaching the real subject.
- Test invalidation paths. If a write operation should evict cache entries, verify that subsequent reads bypass the cache.
- Mock the real subject, not the proxy. The proxy is what you're testing. The real subject is the dependency you control through mocking.
This approach works cleanly with other patterns like the Command Design Pattern in C#: Complete Guide where command handlers can be proxied and tested the same way.
Naming Conventions
Consistent naming helps developers understand what a proxy does without opening the file. The convention that works best for proxy classes is {Concern}{Subject}Proxy:
CachingDocumentRepositoryProxy-- caches results from the document repositoryLazyDocumentRepositoryProxy-- defers creation of the document repositoryAuthorizingDocumentRepositoryProxy-- checks permissions before accessing documentsLoggingDocumentRepositoryProxy-- logs calls to the document repositoryRetryingPaymentGatewayProxy-- retries failed payment gateway calls
The concern prefix makes it easy to scan a file listing and understand what each proxy does. The Proxy suffix distinguishes these from decorators, adapters, and other wrappers. This distinction matters because a proxy controls access while a decorator adds behavior -- conflating the two in naming leads to confusion.
Avoid generic names like DocumentRepositoryWrapper, DocumentRepositoryHelper, or DocumentRepositoryManager. These names communicate nothing about the proxy's purpose and make it impossible to distinguish between proxies at a glance. Similarly, avoid naming a proxy after the real subject -- DocumentRepositoryV2 or EnhancedDocumentRepository obscures the fact that it's a proxy at all.
Avoid Proxy Chains That Are Too Deep
Stacking multiple proxies on the same interface is legitimate and sometimes necessary. But deep chains -- five, six, or more proxies wrapping each other -- create problems. Each layer adds a method invocation, an object allocation, and cognitive overhead when debugging. When a call fails somewhere in a seven-deep chain, tracing the issue through each layer is tedious and error-prone.
A practical limit is three to four proxies on a single interface. If you need more cross-cutting concerns, consider whether some of them belong at a different architectural layer:
// Manageable: 3 layers
IDocumentRepository repo = new SqlDocumentRepository(
connectionString);
repo = new CachingDocumentRepositoryProxy(
repo, cache, TimeSpan.FromMinutes(5));
repo = new LoggingDocumentRepositoryProxy(
repo, logger);
// Concerning: 6+ layers become hard to debug
// Consider whether some concerns belong in
// middleware, a mediator pipeline, or a
// different abstraction entirely
If you're applying the same cross-cutting concerns across many services -- logging, retry, metrics -- a mediator pipeline or aspect-oriented approach may be more appropriate than individual proxies on every interface. The Strategy Design Pattern in C#: Complete Guide can also help here by encapsulating interchangeable behaviors without deep wrapping chains.
Async Proxy Methods
Modern C# interfaces frequently return Task or Task<T>. Proxies that wrap these interfaces need to handle async correctly. The most common mistake is blocking on async code inside a proxy, which can cause deadlocks in synchronization-context-aware environments.
Here's the correct pattern for an async proxy:
using System;
using System.Threading.Tasks;
public class AuthorizingDocumentRepositoryProxy
: IDocumentRepository
{
private readonly IDocumentRepository _real;
private readonly IAuthorizationService _auth;
public AuthorizingDocumentRepositoryProxy(
IDocumentRepository real,
IAuthorizationService auth)
{
_real = real
?? throw new ArgumentNullException(nameof(real));
_auth = auth
?? throw new ArgumentNullException(nameof(auth));
}
public async Task<Document> GetByIdAsync(string id)
{
await _auth.EnsureCanReadAsync(id);
return await _real.GetByIdAsync(id);
}
public async Task SaveAsync(Document document)
{
await _auth.EnsureCanWriteAsync(document.Id);
await _real.SaveAsync(document);
}
// ... remaining methods follow the same pattern
}
public interface IAuthorizationService
{
Task EnsureCanReadAsync(string resourceId);
Task EnsureCanWriteAsync(string resourceId);
}
For proxy methods that don't add async behavior -- pure delegation -- return the task directly without async/await to avoid unnecessary state machine overhead:
// Good: Direct task return for pure delegation
public Task<IReadOnlyList<Document>> SearchAsync(
string query)
{
return _real.SearchAsync(query);
}
// Unnecessary overhead: async/await just to delegate
public async Task<IReadOnlyList<Document>> SearchAsync(
string query)
{
return await _real.SearchAsync(query);
}
The rule is simple: use async/await when the proxy adds behavior before or after the awaited call. Return the task directly when the proxy is just passing through. Never use .Result or .Wait() inside a proxy -- those block the calling thread and invite deadlocks.
Common Anti-Patterns
Proxy Doing Too Much
A proxy that contains business logic, validation rules, data transformation, and access control all in one class has lost the plot. The proxy pattern exists to add a single layer of indirection for a specific purpose. If your proxy class is 300 lines long and handles five different concerns, split it into focused proxies or move non-proxy logic into the real subject.
using System;
using System.Threading.Tasks;
// Bad: Proxy doing too much
public class OverloadedDocumentRepositoryProxy
: IDocumentRepository
{
private readonly IDocumentRepository _real;
public OverloadedDocumentRepositoryProxy(
IDocumentRepository real)
{
_real = real;
}
public async Task SaveAsync(Document document)
{
// Authorization -- should be its own proxy
if (!HasPermission())
{
throw new UnauthorizedAccessException();
}
// Validation -- should be in the domain layer
if (document.Title.Length > 200)
{
throw new ArgumentException("Title too long");
}
// Logging -- should be its own proxy
Console.WriteLine("Saving document...");
await _real.SaveAsync(document);
// Notification -- not a proxy concern at all
SendEmailNotification(document);
}
// ...
}
Each concern should be a separate proxy or handled at the appropriate layer.
Hiding Errors
A proxy that catches exceptions from the real subject and returns default values or null without any indication of failure creates silent data loss. Callers assume the operation succeeded when it didn't:
using System;
using System.Threading.Tasks;
// Bad: Silently hiding errors
public class FaultyDocumentRepositoryProxy
: IDocumentRepository
{
private readonly IDocumentRepository _real;
public FaultyDocumentRepositoryProxy(
IDocumentRepository real)
{
_real = real;
}
public async Task<Document> GetByIdAsync(string id)
{
try
{
return await _real.GetByIdAsync(id);
}
catch
{
return null;
}
}
// ...
}
If the proxy needs to handle errors -- for example, a retry proxy or a circuit-breaker proxy -- it should do so explicitly and transparently. Log the failure, rethrow after exhausting retries, or surface the error through a well-defined mechanism. Never silently swallow exceptions.
Leaking the Real Subject
If your proxy exposes a public property or method that returns the underlying real subject, callers can bypass the proxy entirely. This defeats the purpose of the pattern:
// Bad: Leaking the real subject
public class LeakyProxy : IDocumentRepository
{
public IDocumentRepository RealRepository { get; }
public LeakyProxy(IDocumentRepository real)
{
RealRepository = real;
}
// ...
}
The real subject should remain an internal implementation detail. Callers interact exclusively through the interface.
Frequently Asked Questions
How do I choose between a proxy and a decorator in C#?
A proxy controls access to an object -- it may defer creation, enforce permissions, or cache results. A decorator adds behavior to an object's existing methods. In practice, the implementation looks similar: both wrap an interface. The distinction is intent. If you're controlling when or whether the real object is accessed, that's a proxy. If you're augmenting what the operation does, that's a decorator. The Decorator Design Pattern in C#: Complete Guide covers the decorator side in depth.
Should caching proxies use IMemoryCache or ConcurrentDictionary?
Use IMemoryCache when you need expiration policies, size limits, or eviction callbacks. Use ConcurrentDictionary when you need a simple thread-safe lookup with no expiration -- like a session-scoped cache that lives as long as the proxy instance. For most production scenarios, IMemoryCache is the better choice because stale cache entries without expiration are a common source of bugs.
How do I register a proxy with Scrutor without causing infinite recursion?
Scrutor's Decorate<TInterface, TDecorator>() method is designed to avoid infinite recursion -- it resolves the existing registration for the interface and passes it as the constructor argument to the proxy. The key is to register the concrete real subject first, then call Decorate. Never register the proxy itself as the implementation and also have the proxy constructor resolve the same interface from the container.
Can I use source generators or DispatchProxy for proxy creation?
Yes. DispatchProxy is a built-in .NET mechanism for creating dynamic proxies at runtime. It's useful for cross-cutting concerns like logging or metrics where writing individual proxy classes for every interface would be tedious. Source generators can produce proxy boilerplate at compile time, which avoids reflection overhead. Both approaches trade explicit control for reduced boilerplate. Start with hand-written proxies for clarity, and move to dynamic proxies only when the repetition becomes unmanageable.
What's the performance impact of stacking multiple proxies?
Each proxy adds a virtual method dispatch and a small object allocation. For I/O-bound operations -- database calls, HTTP requests, file access -- this overhead is negligible. For CPU-bound hot paths called millions of times per second, even small per-call overhead compounds. Profile before optimizing, but as a guideline, keep proxy chains to three or four layers and avoid proxying methods on tight loops.
How do I test an authorization proxy without a real auth service?
Mock the authorization service interface just like you mock the real subject. Pass a mock IAuthorizationService that either permits or denies access, and verify that the proxy correctly delegates to the real subject on success or throws on denial. This isolates the proxy's authorization logic from the authentication infrastructure entirely.
Should proxies be sealed classes?
Yes, in most cases. A concrete proxy like CachingDocumentRepositoryProxy represents a specific access-control concern and shouldn't be extended through inheritance. If you need a variation, create a separate proxy class. Sealing prevents accidental inheritance hierarchies that undermine the clean composition the proxy pattern provides. This aligns with the broader C# convention of preferring composition over inheritance.
Wrapping Up Proxy Pattern Best Practices
Applying these proxy pattern best practices in C# keeps your proxies focused, testable, and easy to maintain. The core principles are straightforward: implement the full subject interface, keep proxy logic separate from business logic, use Lazy<T> for thread-safe deferred creation, invalidate caches on writes, register proxies cleanly through DI, and test each proxy in isolation by mocking the real subject.
The most effective proxies are small classes with a single purpose. A caching proxy caches. A logging proxy logs. An authorization proxy checks permissions. When each proxy handles one concern, you can compose them independently, test them in isolation, and swap them out without ripple effects. Name them clearly with the {Concern}{Subject}Proxy convention, keep chains shallow, and handle async operations correctly.
Watch for the common traps: proxies that try to do too much, proxies that silently swallow errors, and proxies that leak their underlying subjects. These anti-patterns erode the very transparency that makes the proxy pattern valuable. Start simple, add proxies as the need becomes clear, and refactor when a proxy outgrows its single responsibility. The goal isn't maximum abstraction -- it's a codebase where access control, caching, and lazy initialization are cleanly separated from the business logic they protect.

