The Proxy Pattern in C#: A Complete Developer's Guide to Mastering Object Control and Performance

Proxy Pattern

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#.

Proxy Pattern UML

UML Class Diagram Analysis

The Proxy pattern follows a straightforward but powerful structure with four key participants:

  1. Subject Interface: Defines the common interface that both RealSubject and Proxy must implement. This ensures the proxy can be used wherever the real subject is expected.
  2. RealSubject: The actual object that performs the real work. This is often expensive to create or access, which is why we need a proxy.
  3. Proxy: The surrogate that controls access to the RealSubject. It implements the same interface and can add functionality like caching, logging, or access control.
  4. Client: The code that works with the Subject interface, 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<string, string> cache = new Dictionary<string, string>();
    
    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();
        }
    }
}
				
			
Usage Example

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
The Core Problem It Solves

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.

Essential Components

The pattern consists of three key players:

  1. Subject Interface: The common contract both proxy and real object implement
  2. Real Subject: The actual object doing the heavy lifting
  3. 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<string, IDatabase> _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<string, object> _cache = new();

// ✅ Thread-safe alternative
private readonly ConcurrentDictionary<string, object> _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<User> 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<T> QueryAsync<T>(string sql, object parameters = null)
    {
        EnsureConnection();
        
        var stopwatch = Stopwatch.StartNew();
        try
        {
            _logger.LogDebug("Executing query: {Query}", sql);
            return await _realConnection.QueryAsync<T>(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<string> 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:

  1. Use weak references for long-lived caches to allow garbage collection
  2. Implement cache eviction policies like LRU (Least Recently Used)
  3. Set reasonable cache size limits to prevent memory exhaustion
  4. 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:

  1. Use thread-safe collections like ConcurrentDictionary instead of Dictionary
  2. Minimize lock scope by using fine-grained locking strategies
  3. Consider lock-free algorithms for high-performance scenarios
  4. Test under load to identify race conditions and deadlocks

Async Considerations

When working with async operations:

  1. Don’t block async calls with .Result or .Wait()
  2. Use ConfigureAwait(false) in library code to avoid deadlocks
  3. Implement proper timeout handling for remote proxies
  4. 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<IUserService>();
    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:

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.