The Proxy Pattern in C#: A Complete Developer's Guide to Mastering Object Control and Performance
Ever found yourself staring at a piece of code, wondering how to add functionality without breaking existing implementations? Or perhaps you’ve needed to control access to expensive resources without rewriting your entire application? The Proxy pattern might just be the solution you’ve been looking for.
The Proxy pattern is one of those design patterns that sounds simple in theory but often trips up developers in practice. It’s not just about wrapping objects—it’s about smart control, lazy loading, and adding cross-cutting concerns without cluttering your core business logic.
In this comprehensive guide, we’ll explore the Proxy pattern through real-world C# examples, tackle the most common implementation challenges, and show you how to avoid the pitfalls that even experienced developers encounter.
Table of Contents
Understanding the Proxy Pattern
The Proxy pattern acts as a stand-in or placeholder for another object. Think of it like a personal assistant who handles certain tasks for their boss—they can answer simple questions directly, but for complex matters, they’ll consult with the actual decision-maker.
UML Structure and Implementation Blueprint
o fully grasp the Proxy pattern, let’s examine its structure through a UML class diagram and then implement it step by step in C#.
UML Class Diagram Analysis
The Proxy pattern follows a straightforward but powerful structure with four key participants:
- Subject Interface: Defines the common interface that both
RealSubjectandProxymust implement. This ensures the proxy can be used wherever the real subject is expected. - RealSubject: The actual object that performs the real work. This is often expensive to create or access, which is why we need a proxy.
- Proxy: The surrogate that controls access to the
RealSubject. It implements the same interface and can add functionality like caching, logging, or access control. - Client: The code that works with the
Subjectinterface, unaware of whether it’s dealing with the real object or a proxy.
The beauty of this pattern lies in its transparency—the client code doesn’t need to know whether it’s working with a proxy or the real object. Both implement the same interface, making them interchangeable.
Key Relationships in the Pattern
The UML diagram reveals three critical relationships:
Implementation Relationship: Both Proxy and RealSubject implement the Subject interface. This is shown with dashed arrows and ensures polymorphic behavior.
Composition Relationship: The Proxy contains a reference to the RealSubject. This solid arrow with a diamond indicates that the proxy controls the lifecycle and access to the real object.
Dependency Relationship: The Client depends on the Subject interface. The dashed arrow shows that the client uses the interface without knowing the concrete implementation.
Step-by-Step Implementation
Let’s implement the pattern exactly as shown in the UML diagram:
// Step 1: Define the Subject interface
public interface ISubject
{
void Request();
string GetInfo();
}
// Step 2: Implement the RealSubject
public class RealSubject : ISubject
{
private string data;
private bool initialized = false;
public RealSubject()
{
LoadData(); // Expensive operation
}
public void Request()
{
if (!initialized)
{
LoadData();
}
Console.WriteLine("RealSubject: Handling request");
}
public string GetInfo()
{
return $"RealSubject data: {data}";
}
private void LoadData()
{
Console.WriteLine("RealSubject: Loading expensive data...");
Thread.Sleep(2000); // Simulate expensive operation
data = "Loaded from database";
initialized = true;
}
}
// Step 3: Implement the Proxy
public class Proxy : ISubject
{
private RealSubject realSubject;
private Dictionary cache = new Dictionary();
public void Request()
{
if (CheckAccess())
{
CreateRealSubjectIfNeeded();
realSubject.Request();
}
else
{
Console.WriteLine("Proxy: Access denied");
}
}
public string GetInfo()
{
const string cacheKey = "info";
if (cache.ContainsKey(cacheKey))
{
Console.WriteLine("Proxy: Returning cached info");
return cache[cacheKey];
}
CreateRealSubjectIfNeeded();
var info = realSubject.GetInfo();
cache[cacheKey] = info;
return info;
}
private bool CheckAccess()
{
Console.WriteLine("Proxy: Checking access permissions");
return true; // Simplified access check
}
private void CreateRealSubjectIfNeeded()
{
if (realSubject == null)
{
Console.WriteLine("Proxy: Creating RealSubject");
realSubject = new RealSubject();
}
}
}
// Step 4: Client code
public class Client
{
public void DoWork(ISubject subject)
{
subject.Request();
Console.WriteLine(subject.GetInfo());
}
public void ProcessData(ISubject subject)
{
// Client doesn't know if it's working with proxy or real subject
for (int i = 0; i < 3; i++)
{
Console.WriteLine($"--- Call {i + 1} ---");
subject.Request();
}
}
}
Here’s how the pattern works in practice:
var client = new Client();
var proxy = new Proxy();
// Client works with proxy transparently
client.DoWork(proxy);
client.ProcessData(proxy);
This implementation demonstrates all the key aspects shown in the UML diagram:
- Interface compliance: Both classes implement
ISubject - Controlled access: The proxy manages when and how the real subject is created
- Added functionality: The proxy adds caching and access control
- Transparency: The client code works the same way regardless of implementation
Imagine you’re building an e-commerce application that displays product images. Loading high-resolution images for every product on a category page would be slow and wasteful. The Proxy pattern allows you to show placeholders initially and only load the actual images when users interact with them.
The pattern consists of three key players:
- Subject Interface: The common contract both proxy and real object implement
- Real Subject: The actual object doing the heavy lifting
- Proxy: The intelligent middleman that controls access
Here’s a practical example that demonstrates lazy loading:
public interface IProductImage
{
void Display();
string GetImageDetails();
}
public class HighResolutionProductImage : IProductImage
{
private readonly string _imageUrl;
private byte[] _imageData;
public HighResolutionProductImage(string imageUrl)
{
_imageUrl = imageUrl;
LoadImageData(); // This is expensive!
}
private void LoadImageData()
{
// Simulate expensive operation
Console.WriteLine($"Loading high-res image from {_imageUrl}...");
Thread.Sleep(2000);
_imageData = new byte[1024 * 1024]; // 1MB image
}
public void Display() => Console.WriteLine($"Displaying image: {_imageUrl}");
public string GetImageDetails() => $"High-res image: {_imageData.Length} bytes";
}
public class ProductImageProxy : IProductImage
{
private readonly string _imageUrl;
private HighResolutionProductImage _realImage;
public ProductImageProxy(string imageUrl)
{
_imageUrl = imageUrl;
// Note: We don't load the real image here!
}
public void Display()
{
// Only load when actually needed
if (_realImage == null)
{
_realImage = new HighResolutionProductImage(_imageUrl);
}
_realImage.Display();
}
public string GetImageDetails()
{
// Can provide basic info without loading
return $"Proxy for image: {_imageUrl}";
}
}
This simple example shows the power of the proxy pattern—the expensive image loading only happens when absolutely necessary, not during object creation.
Why Developers Struggle with Proxies
After mentoring hundreds of developers, I’ve identified the most common challenges when implementing the Proxy pattern:
1. Pattern Confusion
The biggest stumbling block is confusing Proxy with similar patterns, especially the Decorator pattern. Here’s the key distinction:
- Proxy Pattern: Controls access to an object (when, how, or if you can use it)
- Decorator Pattern: Adds new behavior to an object (extends what it can do)
Think of a proxy as a bouncer at a club—they control who gets in. A decorator is like adding accessories to an outfit—they enhance what’s already there.
2. Over-Engineering Simple Solutions
Many developers create complex proxy hierarchies when a simple wrapper would suffice. The Proxy pattern shines when you need:
- Lazy initialization for expensive objects
- Access control based on permissions or state
- Caching for frequently accessed data
- Logging or monitoring for debugging purposes
If you’re just adding a single method call, you probably don’t need a full proxy implementation.
3. Memory and Resource Management Issues
A critical mistake is creating memory leaks through improper proxy management. Consider this problematic approach:
// ❌ Problematic: Static cache that never cleans up
public class DatabaseProxy
{
private static readonly Dictionary _connections = new();
public static IDatabase GetConnection(string connectionString)
{
if (!_connections.ContainsKey(connectionString))
{
_connections[connectionString] = new ExpensiveDatabase(connectionString);
}
return _connections[connectionString]; // These never get disposed!
}
}
The issue here is that database connections accumulate in the static dictionary and are never properly disposed of, leading to resource exhaustion.
4. Thread Safety Oversights
Another common pitfall is forgetting about concurrent access. If your proxy maintains state (like a cache), it must handle multiple threads accessing it simultaneously:
// ❌ Not thread-safe
private readonly Dictionary _cache = new();
// ✅ Thread-safe alternative
private readonly ConcurrentDictionary _cache = new();
Types of Proxies and Their Use Cases
Understanding when to use each type of proxy is crucial for effective implementation. Let’s explore the four main categories:
1. Virtual Proxy (Lazy Loading)
Best for: Expensive object creation that you want to delay until absolutely necessary.
Virtual proxies are perfect for scenarios like loading large datasets, connecting to remote services, or initializing complex objects. The key is that the proxy acts as a lightweight placeholder until the real functionality is needed.
Real-world scenario: An application that processes large Excel files. Instead of loading all data immediately, the proxy only loads data when specific sheets or ranges are accessed.
2. Protection Proxy (Access Control)
Best for: Implementing security, permissions, or conditional access to objects.
Protection proxies act as security guards, checking credentials or permissions before allowing access to the underlying object. They’re essential in systems where different users have different access levels.
Real-world scenario: A document management system where users can only access documents based on their role and clearance level.
3. Caching Proxy
Best for: Expensive operations that are called frequently with the same parameters.
Caching proxies store results of expensive operations and return cached values for subsequent identical requests. This is particularly valuable for database queries, API calls, or complex calculations.
Real-world scenario: An API client that caches user profile data to avoid repeated network calls during a user session.
Here’s a practical caching proxy example:
public class CachingUserServiceProxy : IUserService
{
private readonly IUserService _realService;
private readonly IMemoryCache _cache;
private readonly TimeSpan _cacheExpiration = TimeSpan.FromMinutes(15);
public CachingUserServiceProxy(IUserService realService, IMemoryCache cache)
{
_realService = realService;
_cache = cache;
}
public async Task GetUserAsync(int userId)
{
var cacheKey = $"user_{userId}";
if (_cache.TryGetValue(cacheKey, out User cachedUser))
{
return cachedUser;
}
var user = await _realService.GetUserAsync(userId);
_cache.Set(cacheKey, user, _cacheExpiration);
return user;
}
}
4. Remote Proxy
Best for: Representing objects that exist in different address spaces (different processes, machines, or networks).
Remote proxies handle the complexity of network communication, serialization, and error handling when working with distributed systems. They make remote objects appear as if they’re local.
Real-world scenario: A microservices architecture where one service needs to call methods on objects in another service.
Real-World Implementation Strategies
Strategy 1: Database Connection Management
One of the most practical applications of the Proxy pattern is managing expensive database connections. Here’s how to implement a robust database proxy:
public class DatabaseConnectionProxy : IDatabaseConnection
{
private readonly string _connectionString;
private readonly ILogger _logger;
private IDatabaseConnection _realConnection;
private readonly object _lockObject = new();
public DatabaseConnectionProxy(string connectionString, ILogger logger)
{
_connectionString = connectionString;
_logger = logger;
}
public async Task QueryAsync(string sql, object parameters = null)
{
EnsureConnection();
var stopwatch = Stopwatch.StartNew();
try
{
_logger.LogDebug("Executing query: {Query}", sql);
return await _realConnection.QueryAsync(sql, parameters);
}
finally
{
_logger.LogDebug("Query completed in {Ms}ms", stopwatch.ElapsedMilliseconds);
}
}
private void EnsureConnection()
{
if (_realConnection == null)
{
lock (_lockObject)
{
if (_realConnection == null)
{
_logger.LogInformation("Creating database connection");
_realConnection = new SqlConnection(_connectionString);
}
}
}
}
}
This proxy provides several benefits:
- Lazy connection creation: Only connects when needed
- Automatic logging: Tracks query performance
- Thread safety: Handles concurrent access properly
- Resource management: Ensures proper cleanup
Strategy 2: File System Security Proxy
Another common use case is adding security and validation to file operations:
public class SecureFileSystemProxy : IFileSystem
{
private readonly IFileSystem _realFileSystem;
private readonly string _allowedBasePath;
private readonly ILogger _logger;
public SecureFileSystemProxy(IFileSystem realFileSystem, string allowedBasePath, ILogger logger)
{
_realFileSystem = realFileSystem;
_allowedBasePath = Path.GetFullPath(allowedBasePath);
_logger = logger;
}
public async Task ReadFileAsync(string path)
{
ValidatePath(path);
_logger.LogInformation("Reading file: {Path}", path);
try
{
return await _realFileSystem.ReadFileAsync(path);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to read file: {Path}", path);
throw;
}
}
private void ValidatePath(string path)
{
var fullPath = Path.GetFullPath(path);
if (!fullPath.StartsWith(_allowedBasePath))
{
throw new UnauthorizedAccessException(
$"Access denied: Path outside allowed directory");
}
if (Path.GetExtension(path).ToLower() == ".exe")
{
throw new ArgumentException("Executable files are not allowed");
}
}
}
This security proxy prevents common attacks like directory traversal while adding comprehensive logging for audit purposes.
Common Mistakes and Solutions
Mistake 1: Creating Proxies Without Purpose
The Problem: Adding proxy layers that don’t provide any real value.
The Solution: Before creating a proxy, ask yourself: “What specific cross-cutting concern am I addressing?” If the answer is “none,” you probably don’t need a proxy.
Mistake 2: Ignoring Exception Transparency
The Problem: Proxies that swallow exceptions or change exception types unexpectedly.
The Solution: Maintain exception transparency—callers should receive the same exceptions they would get from the real object, unless you’re specifically handling certain error scenarios.
Mistake 3: Poor Performance Optimization
The Problem: Creating proxies that are slower than direct access to the real object.
The Solution: Profile your proxy implementations. If a caching proxy has cache misses 90% of the time, it’s probably hurting more than helping.
Mistake 4: Inadequate Testing
The Problem: Testing only the proxy behavior without ensuring it correctly delegates to the real object.
The Solution: Create comprehensive tests that verify both proxy-specific behavior and correct delegation to the underlying object.
The Problem: Proxies that swallow exceptions or change exception types unexpectedly.
The Solution: Maintain exception transparency—callers should receive the same exceptions they would get from the real object, unless you’re specifically handling certain error scenarios.
Performance and Best Practices
Memory Efficiency
When implementing caching proxies, be mindful of memory usage:
- Use weak references for long-lived caches to allow garbage collection
- Implement cache eviction policies like LRU (Least Recently Used)
- Set reasonable cache size limits to prevent memory exhaustion
- Monitor cache hit rates to ensure the proxy is actually improving performance
Thread Safety Guidelines
For proxies that will be used in multi-threaded environments:
- Use thread-safe collections like
ConcurrentDictionaryinstead ofDictionary - Minimize lock scope by using fine-grained locking strategies
- Consider lock-free algorithms for high-performance scenarios
- Test under load to identify race conditions and deadlocks
Async Considerations
When working with async operations:
- Don’t block async calls with
.Resultor.Wait() - Use
ConfigureAwait(false)in library code to avoid deadlocks - Implement proper timeout handling for remote proxies
- Consider circuit breaker patterns for fault tolerance
Testing Proxy Objects
Testing proxies requires a dual approach—testing both the proxy-specific functionality and the delegation to the real object:
[Test]
public async Task CachingProxy_ShouldReturnCachedValue_OnSecondCall()
{
// Arrange
var mockService = new Mock();
var cache = new MemoryCache(new MemoryCacheOptions());
var proxy = new CachingUserServiceProxy(mockService.Object, cache);
var user = new User { Id = 1, Name = "John" };
mockService.Setup(s => s.GetUserAsync(1)).ReturnsAsync(user);
// Act
var firstResult = await proxy.GetUserAsync(1);
var secondResult = await proxy.GetUserAsync(1);
// Assert
Assert.AreEqual(user, firstResult);
Assert.AreEqual(user, secondResult);
mockService.Verify(s => s.GetUserAsync(1), Times.Once); // Only called once
}
Testing Strategies
- Test proxy behavior independently: Verify caching, logging, security, etc.
- Test delegation correctness: Ensure the proxy correctly calls the real object
- Test error scenarios: Verify exception handling and error propagation
- Test thread safety: Use concurrent test scenarios for multi-threaded proxies
When NOT to Use Proxies
The Proxy pattern isn’t always the right solution. Avoid proxies when:
1. Simple Pass-Through Operations
If your proxy only forwards calls without adding any value, it’s unnecessary overhead:
// ❌ Unnecessary proxy
public class PointlessProxy : ICalculator
{
private readonly ICalculator _calculator;
public int Add(int a, int b) => _calculator.Add(a, b);
public int Subtract(int a, int b) => _calculator.Subtract(a, b);
}
2. High-Performance Critical Paths
In performance-critical code where every microsecond matters, the overhead of proxy delegation might be unacceptable.
3. Simple Applications
For small applications with straightforward requirements, the complexity of proxy patterns might outweigh their benefits.
4. When Composition is Clearer
Sometimes simple composition or dependency injection provides clearer code than proxy patterns:
// Often clearer than a proxy
public class UserController
{
private readonly IUserService _userService;
private readonly ILogger _logger;
private readonly ICache _cache;
public UserController(IUserService userService, ILogger logger, ICache cache)
{
_userService = userService;
_logger = logger;
_cache = cache;
}
}
Advanced Proxy Techniques
Dynamic Proxies with Castle DynamicProxy
For more advanced scenarios, consider using libraries like Castle DynamicProxy to create proxies at runtime:
public class LoggingInterceptor : IInterceptor
{
private readonly ILogger _logger;
public LoggingInterceptor(ILogger logger)
{
_logger = logger;
}
public void Intercept(IInvocation invocation)
{
_logger.LogInformation("Calling {Method}", invocation.Method.Name);
try
{
invocation.Proceed();
_logger.LogInformation("Completed {Method}", invocation.Method.Name);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in {Method}", invocation.Method.Name);
throw;
}
}
}
This approach allows you to create proxies for any interface without writing boilerplate code.
Proxy Composition
You can chain multiple proxies together to combine concerns:
var dataService = new DatabaseService();
var cachedService = new CachingProxy(dataService);
var loggedService = new LoggingProxy(cachedService);
var secureService = new SecurityProxy(loggedService);
This creates a pipeline where each proxy adds its own concern while maintaining the same interface.
Conclusion and Next Steps
The Proxy pattern is a powerful tool for controlling object access, improving performance, and adding cross-cutting concerns to your applications. When implemented correctly, it provides clean separation of concerns and makes your code more maintainable and testable.
Key Takeaways
- Use proxies purposefully: Only implement proxies when they solve specific problems like lazy loading, caching, or access control
- Keep it simple: Don’t over-engineer proxy solutions for straightforward requirements
- Mind the performance: Ensure your proxies actually improve performance rather than hindering it
- Test thoroughly: Verify both proxy behavior and correct delegation to real objects
- Consider alternatives: Sometimes simple composition or dependency injection is clearer than proxy patterns
What's Next?
Now that you understand the Proxy pattern, consider exploring related patterns that work well together:
- Decorator Pattern: For adding behavior to objects
- Adapter Pattern: For interface compatibility
- Facade Pattern: For simplifying complex subsystems
- Observer Pattern: For event-driven architectures
The Proxy pattern is just one tool in your design pattern toolkit. Master it, but remember that the best pattern is the one that solves your specific problem with the least complexity.
Ready to implement your first proxy? Start with a simple caching proxy for an expensive operation in your current project. You’ll be surprised how much it can improve both performance and code organization.