Learnixo
Back to blog
Backend Systemsintermediate

Proxy — Control Access to Another Object

The Proxy pattern in C#: wrap an object to add caching, access control, logging, or lazy initialisation. Virtual proxy, protection proxy, and caching proxy with real-world examples.

Asma Hafeez KhanMay 24, 20263 min read
csharpdesign-patternsproxystructuraldotnetcaching
Share:𝕏

Proxy — Control Access to Another Object

A Proxy wraps a real object and controls access to it, adding behaviour before or after forwarding to the real object. It implements the same interface as the real subject.


Types of Proxy

Virtual Proxy:     delays expensive creation until first access (lazy initialisation)
Protection Proxy:  controls access based on permissions
Caching Proxy:     caches results to avoid repeated expensive calls
Remote Proxy:      represents an object in another address space (gRPC client stubs)
Logging Proxy:     records all interactions for debugging/auditing

Caching Proxy (Most Common in Web APIs)

C#
public interface IProductRepository
{
    Task<Product?> GetByIdAsync(int id);
    Task<IReadOnlyList<Product>> GetAllAsync();
}

// Real implementation — hits the database
public class ProductRepository(AppDbContext db) : IProductRepository
{
    public Task<Product?> GetByIdAsync(int id)
        => db.Products.FindAsync(id).AsTask();

    public Task<IReadOnlyList<Product>> GetAllAsync()
        => db.Products.ToListAsync()
            .ContinueWith(t => (IReadOnlyList<Product>)t.Result);
}

// Caching proxy — transparent to callers
public class CachedProductRepository(
    IProductRepository inner,
    IMemoryCache cache) : IProductRepository
{
    private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);

    public async Task<Product?> GetByIdAsync(int id)
    {
        string key = $"product:{id}";
        if (cache.TryGetValue(key, out Product? cached))
            return cached;

        var product = await inner.GetByIdAsync(id);
        if (product is not null)
            cache.Set(key, product, CacheDuration);

        return product;
    }

    public async Task<IReadOnlyList<Product>> GetAllAsync()
    {
        const string key = "products:all";
        if (cache.TryGetValue(key, out IReadOnlyList<Product>? cached))
            return cached!;

        var products = await inner.GetAllAsync();
        cache.Set(key, products, CacheDuration);
        return products;
    }
}

// Register proxy in DI — callers get the proxy, not the real class
builder.Services.AddScoped<ProductRepository>();
builder.Services.AddScoped<IProductRepository>(sp =>
    new CachedProductRepository(
        sp.GetRequiredService<ProductRepository>(),
        sp.GetRequiredService<IMemoryCache>()
    ));

Protection Proxy

C#
public interface IDocumentService
{
    Task<Document> GetAsync(int documentId);
    Task DeleteAsync(int documentId);
}

public class DocumentService(AppDbContext db) : IDocumentService
{
    public Task<Document> GetAsync(int id) => db.Documents.FindAsync(id).AsTask()!;
    public async Task DeleteAsync(int id)
    {
        var doc = await db.Documents.FindAsync(id);
        if (doc is not null) { db.Documents.Remove(doc); await db.SaveChangesAsync(); }
    }
}

// Protection proxy — enforces access control
public class SecureDocumentService(
    IDocumentService inner,
    ICurrentUserService currentUser) : IDocumentService
{
    public async Task<Document> GetAsync(int documentId)
    {
        var doc = await inner.GetAsync(documentId);
        if (doc.OwnerId != currentUser.UserId && !currentUser.IsAdmin)
            throw new UnauthorizedAccessException("You do not have access to this document");
        return doc;
    }

    public async Task DeleteAsync(int documentId)
    {
        if (!currentUser.IsAdmin)
            throw new UnauthorizedAccessException("Only admins can delete documents");
        await inner.DeleteAsync(documentId);
    }
}

Virtual Proxy (Lazy Loading)

C#
public interface IExpensiveService
{
    string GetReport();
}

// Real service — expensive to initialise (e.g., connects to a slow external system)
public class ExpensiveReportService : IExpensiveService
{
    public ExpensiveReportService()
    {
        // Slow initialisation: loading config, warming up connections
        Thread.Sleep(2000);
    }

    public string GetReport() => "Report data...";
}

// Virtual proxy — delays creation until GetReport() is first called
public class LazyReportServiceProxy : IExpensiveService
{
    private readonly Lazy<IExpensiveService> _inner =
        new(() => new ExpensiveReportService());

    public string GetReport() => _inner.Value.GetReport();
}

Proxy via Scrutor (Decorator Registration)

C#
// Scrutor makes wrapping for DI much cleaner:
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.Decorate<IProductRepository, CachedProductRepository>();
// Now IProductRepository resolves to CachedProductRepository wrapping ProductRepository

Interview Answer

"The Proxy pattern wraps a real object, implementing the same interface, to add behaviour without the caller knowing. Most common in .NET backends: the Caching Proxy wraps a repository and returns cached results, the Protection Proxy enforces authorisation, and the Remote Proxy represents a gRPC or HTTP service. Proxy is structurally identical to Decorator — the difference is intent: Proxy controls access (the real object may not be created yet, may require permission, or results may be cached); Decorator adds behaviour (logging, validation) without restricting access. In .NET, Scrutor's Decorate method registers proxies/decorators cleanly without changing the DI consumer. Lazy<T> is the built-in virtual proxy for expensive initialisation."

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.