Why Proxies Control What Others Can't
It's tempting to give every part of your app direct access to expensive resources like databases or external APIs. It works until you face slow startup times, security breaches, or uncontrolled resource usage that crashes your application under load.
The Proxy pattern acts as a gatekeeper between your code and the real object. It controls when objects get created, who can access them, and what happens before and after each method call. You'll find this useful for lazy loading heavy resources, enforcing security checks, or adding logging without changing existing code.
You'll build a virtual proxy that delays expensive object creation, then expand it to handle access control and caching. By the end, you'll know when to use proxies versus simpler alternatives like Lazy<T>.
The Proxy Pattern Structure
A proxy implements the same interface as the real object it wraps. Clients interact with the proxy thinking it's the real thing. The proxy forwards requests to the real object while adding its own behavior before or after the call.
This pattern needs three parts: an interface both the proxy and real object share, the real object that does actual work, and the proxy that controls access to it. The shared interface keeps clients unaware they're talking to a proxy instead of the real implementation.
namespace ProxyDemo;
public interface IDocument
{
string GetContent();
void Display();
int GetPageCount();
}
namespace ProxyDemo;
public class RealDocument : IDocument
{
private readonly string _filename;
private string _content;
public RealDocument(string filename)
{
_filename = filename;
Console.WriteLine($"Loading document from disk: {filename}");
Thread.Sleep(2000); // Simulate expensive disk I/O
_content = $"Content of {filename} (loaded from disk)";
}
public string GetContent() => _content;
public void Display()
{
Console.WriteLine($"Displaying: {_content}");
}
public int GetPageCount()
{
return _content.Length / 100;
}
}
The RealDocument simulates loading a file from disk, which takes time. If you create multiple instances at startup, your app will freeze while waiting for all documents to load. A proxy can defer this work until you actually need each document.
Building a Virtual Proxy for Lazy Loading
A virtual proxy delays creating the expensive real object until someone actually calls a method on it. The first method call triggers creation and stores the instance for future calls. This pattern cuts startup time and memory usage when you have many potentially unused objects.
namespace ProxyDemo;
public class DocumentProxy : IDocument
{
private readonly string _filename;
private RealDocument? _realDocument;
public DocumentProxy(string filename)
{
_filename = filename;
Console.WriteLine($"Proxy created for {filename} (not loaded yet)");
}
private RealDocument EnsureLoaded()
{
if (_realDocument == null)
{
Console.WriteLine("First access - loading real document now...");
_realDocument = new RealDocument(_filename);
}
return _realDocument;
}
public string GetContent()
{
return EnsureLoaded().GetContent();
}
public void Display()
{
EnsureLoaded().Display();
}
public int GetPageCount()
{
return EnsureLoaded().GetPageCount();
}
}
The proxy creates instantly while the real document waits. When you call any method, EnsureLoaded checks if the real document exists and creates it only once. Subsequent calls reuse the same instance. This lets you create dozens of proxies at startup without paying the cost until you actually use each one.
Adding Access Control with a Protection Proxy
A protection proxy checks permissions before forwarding calls to the real object. This centralizes security logic in one place instead of scattering authorization checks throughout your codebase. You can deny access, log attempts, or modify behavior based on user roles.
namespace ProxyDemo;
public class SecureDocumentProxy : IDocument
{
private readonly IDocument _realDocument;
private readonly string _currentUserRole;
private readonly HashSet<string> _allowedRoles;
public SecureDocumentProxy(
IDocument realDocument,
string currentUserRole,
params string[] allowedRoles)
{
_realDocument = realDocument;
_currentUserRole = currentUserRole;
_allowedRoles = new HashSet<string>(allowedRoles);
}
private void CheckAccess(string operation)
{
if (!_allowedRoles.Contains(_currentUserRole))
{
Console.WriteLine(
$"Access denied: {_currentUserRole} cannot {operation}");
throw new UnauthorizedAccessException(
$"Role {_currentUserRole} not authorized for {operation}");
}
Console.WriteLine($"Access granted: {_currentUserRole} can {operation}");
}
public string GetContent()
{
CheckAccess("read content");
return _realDocument.GetContent();
}
public void Display()
{
CheckAccess("display document");
_realDocument.Display();
}
public int GetPageCount()
{
// Page count doesn't need authorization
return _realDocument.GetPageCount();
}
}
This proxy wraps any IDocument and checks the user's role before allowing access. You can combine it with the virtual proxy by passing a DocumentProxy as the real document. GetPageCount intentionally skips the check since counting pages doesn't expose sensitive data. This shows how proxies can selectively control access at the method level.
Creating a Smart Proxy with Caching
A smart proxy adds extra functionality like reference counting, caching, or logging. It goes beyond simple forwarding to enhance what the real object does. Here's a caching proxy that remembers expensive method results.
namespace ProxyDemo;
public class CachingDocumentProxy : IDocument
{
private readonly IDocument _realDocument;
private string? _cachedContent;
private int? _cachedPageCount;
private int _accessCount;
public CachingDocumentProxy(IDocument realDocument)
{
_realDocument = realDocument;
}
public string GetContent()
{
_accessCount++;
if (_cachedContent == null)
{
Console.WriteLine("Cache miss - fetching content from real document");
_cachedContent = _realDocument.GetContent();
}
else
{
Console.WriteLine($"Cache hit - returning cached content (access #{_accessCount})");
}
return _cachedContent;
}
public void Display()
{
_realDocument.Display();
}
public int GetPageCount()
{
if (_cachedPageCount == null)
{
Console.WriteLine("Calculating page count...");
_cachedPageCount = _realDocument.GetPageCount();
}
return _cachedPageCount.Value;
}
}
The first call to GetContent hits the real document and stores the result. Future calls return the cached value instantly. The proxy also tracks how many times content was accessed, which helps with usage analytics or debugging. This kind of transparent optimization keeps client code simple while improving performance.
Choosing the Right Proxy Approach
Choose a virtual proxy when initialization is expensive and you can't predict which objects you'll actually use. It defers work until necessary and keeps startup fast. If you just need simple lazy loading without extra logic, Lazy<T> from the framework does this with less code.
Choose a protection proxy when access control belongs to the object itself rather than scattered in calling code. This centralizes security decisions and makes them easier to audit. If authorization logic varies by context or needs complex rules, consider using attributes or middleware instead.
Choose a smart proxy when you need to augment behavior without modifying the real class. Caching, logging, and metrics collection work well here. If you need to add functionality rather than control access, the Decorator pattern might fit better since it focuses on enhancement over restriction.
You can chain proxies together. Wrap a DocumentProxy in a SecureDocumentProxy, then wrap that in a CachingDocumentProxy. Each layer adds its own behavior. Just watch the order since security checks should happen before caching to avoid leaking cached data to unauthorized users.
Try It Yourself
This example demonstrates all three proxy types working together. You'll see how creation defers, access control applies, and caching optimizes repeated calls.
Steps
- Run
dotnet new console -n ProxyPatternDemo
- Navigate with
cd ProxyPatternDemo
- Replace the Program.cs contents with the code below
- Update ProxyPatternDemo.csproj with the configuration shown
- Execute
dotnet run
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
namespace ProxyDemo;
// Copy all interface and class definitions from above sections
class Program
{
static void Main()
{
Console.WriteLine("=== Virtual Proxy Demo ===\n");
IDocument doc1 = new DocumentProxy("report.pdf");
IDocument doc2 = new DocumentProxy("invoice.pdf");
Console.WriteLine("Proxies created instantly\n");
Console.WriteLine("Accessing doc1 for the first time:");
doc1.Display();
Console.WriteLine("\n=== Protection Proxy Demo ===\n");
var adminDoc = new DocumentProxy("admin-data.txt");
var secureDoc = new SecureDocumentProxy(
adminDoc,
"Admin",
"Admin", "Manager");
Console.WriteLine("Admin accessing document:");
secureDoc.Display();
var guestDoc = new SecureDocumentProxy(
new DocumentProxy("secret.txt"),
"Guest",
"Admin");
try
{
Console.WriteLine("\nGuest accessing restricted document:");
guestDoc.Display();
}
catch (UnauthorizedAccessException ex)
{
Console.WriteLine($"Exception: {ex.Message}");
}
Console.WriteLine("\n=== Caching Proxy Demo ===\n");
var cachedDoc = new CachingDocumentProxy(
new DocumentProxy("large-file.pdf"));
Console.WriteLine("First call:");
cachedDoc.GetContent();
Console.WriteLine("\nSecond call:");
cachedDoc.GetContent();
Console.WriteLine("\nThird call:");
cachedDoc.GetContent();
}
}
Output
=== Virtual Proxy Demo ===
Proxy created for report.pdf (not loaded yet)
Proxy created for invoice.pdf (not loaded yet)
Proxies created instantly
Accessing doc1 for the first time:
First access - loading real document now...
Loading document from disk: report.pdf
Displaying: Content of report.pdf (loaded from disk)
=== Protection Proxy Demo ===
Proxy created for admin-data.txt (not loaded yet)
Admin accessing document:
Access granted: Admin can display document
First access - loading real document now...
Loading document from disk: admin-data.txt
Displaying: Content of admin-data.txt (loaded from disk)
Guest accessing restricted document:
Access denied: Guest cannot display document
Exception: Role Guest not authorized for display document
=== Caching Proxy Demo ===
First call:
Cache miss - fetching content from real document
Second call:
Cache hit - returning cached content (access #2)
Third call:
Cache hit - returning cached content (access #3)
Knowing the Limits
Skip proxies when you're just delaying initialization without adding control logic. The framework's Lazy<T> handles simple lazy loading with less code and built-in thread safety. It's perfect when you don't need to intercept method calls or track access.
Avoid proxies for adding new features to objects. The Decorator pattern fits better when you want to extend functionality rather than control access. Proxies work best as gatekeepers, not enhancers, even though the line blurs with smart proxies.
Don't proxy everything hoping to optimize later. Each proxy adds indirection and maintenance cost. Profile first and identify actual bottlenecks before adding lazy loading or caching. Premature abstraction makes code harder to debug without measurable benefits.
Consider alternatives when you need runtime interception across many classes. Aspect-oriented programming libraries or dynamic proxies from Castle.Core can generate proxies automatically for cross-cutting concerns like logging or transactions. Hand-coding proxies for dozens of interfaces becomes tedious fast.