You integrated a currency exchange rate API. Every time someone views a product price, you call the external API to get the latest rate. Clean. Accurate. Real-time.
Then traffic grows. 10,000 product views per hour. Each one fires an HTTP call to the exchange API. The external API rate-limits you. Your response times triple. Your monthly API bill quadruples.
The exchange rates change once a day. You're making 240,000 redundant calls.
You could add caching directly to the service. But then your ExchangeRateService handles both business logic and caching. And you'd need the same caching logic in every external service you call.
public class ExchangeRateService : IExchangeRateService{ private readonly HttpClient _client; private readonly IMemoryCache _cache; // Caching logic leaking in public async Task<decimal> GetRateAsync(string from, string to) { var key = $"rate:{from}:{to}"; // Service now manages its own cache if (_cache.TryGetValue(key, out decimal cached)) return cached; var response = await _client.GetAsync($"/rates/{from}/{to}"); var rate = await response.Content.ReadFromJsonAsync<decimal>(); _cache.Set(key, rate, TimeSpan.FromHours(1)); return rate; }}
Now every external service needs the same caching boilerplate. And if you want to add rate limiting, you repeat that too. And logging. The service grows with concerns it shouldn't own.
The Proxy pattern provides a surrogate object that controls access to the real object. The proxy implements the same interface, so clients can't tell the difference. But between the client and the real object, the proxy can add caching, access control, lazy loading, or logging.
Keep the real service clean. Add a caching proxy:
// Clean service - only business logicpublic class ExchangeRateService : IExchangeRateService{ private readonly HttpClient _client; public ExchangeRateService(HttpClient client) => _client = client; public async Task<decimal> GetRateAsync(string from, string to) { var response = await _client.GetAsync($"/rates/{from}/{to}"); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync<decimal>(); }} // Caching proxy - same interface, added behaviorpublic class CachingExchangeRateProxy : IExchangeRateService{ private readonly IExchangeRateService _realService; private readonly IMemoryCache _cache; public CachingExchangeRateProxy( IExchangeRateService realService, IMemoryCache cache) { _realService = realService; _cache = cache; } public async Task<decimal> GetRateAsync(string from, string to) { var key = $"exchange-rate:{from}:{to}"; return await _cache.GetOrCreateAsync(key, async entry => { entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1); // Only call the real service on cache miss return await _realService.GetRateAsync(from, to); }); }}
Register with DI:
builder.Services.AddHttpClient<ExchangeRateService>();builder.Services.AddScoped<IExchangeRateService>(sp =>{ var real = sp.GetRequiredService<ExchangeRateService>(); var cache = sp.GetRequiredService<IMemoryCache>(); return new CachingExchangeRateProxy(real, cache);});
Clients inject IExchangeRateService and get the proxy. They never know caching is happening.
Service stays clean. ExchangeRateService only handles API calls. No caching, no logging, no access control mixed in.
Transparent to clients. Any consumer of IExchangeRateService works identically whether it gets the real service or the proxy.
Stackable. Wrap the caching proxy with a logging proxy. Wrap that with a rate-limiting proxy. Each layer adds one concern.
Control access to sensitive operations:
public class AuthorizingOrderService : IOrderService{ private readonly IOrderService _inner; private readonly ICurrentUser _currentUser; public AuthorizingOrderService(IOrderService inner, ICurrentUser currentUser) { _inner = inner; _currentUser = currentUser; } public async Task<Order?> GetByIdAsync(int id) { // Read access - any authenticated user if (!_currentUser.IsAuthenticated) throw new UnauthorizedAccessException("Authentication required"); var order = await _inner.GetByIdAsync(id); // Ensure users can only see their own orders if (order != null && order.CustomerId != _currentUser.Id && !_currentUser.IsInRole("Admin")) throw new ForbiddenException("Access denied"); return order; } public async Task<OrderResult> DeleteOrderAsync(int id) { // Delete - admin only if (!_currentUser.IsInRole("Admin")) throw new ForbiddenException("Only admins can delete orders"); return await _inner.DeleteOrderAsync(id); }}
Authorization logic lives in the proxy. The real OrderService doesn't know about permissions.
Defer expensive initialization until first use:
public class LazyReportGenerator : IReportGenerator{ private readonly Lazy<IReportGenerator> _realGenerator; public LazyReportGenerator(IServiceProvider provider) { // Heavy dependencies loaded only when needed _realGenerator = new Lazy<IReportGenerator>(() => { // This triggers loading ML models, compiling templates, etc. return provider.GetRequiredService<HeavyReportGenerator>(); }); } public async Task<Report> GenerateAsync(ReportRequest request) { // First call triggers initialization return await _realGenerator.Value.GenerateAsync(request); }} // Register the lazy proxybuilder.Services.AddScoped<HeavyReportGenerator>();builder.Services.AddScoped<IReportGenerator, LazyReportGenerator>();
The ML models and templates only load when someone actually generates a report. Not on every request to the application.
Protect external APIs from overuse:
public class RateLimitingProxy<T> where T : class{ private readonly T _inner; private readonly SemaphoreSlim _semaphore; private readonly int _maxConcurrent; public RateLimitingProxy(T inner, int maxConcurrent = 5) { _inner = inner; _maxConcurrent = maxConcurrent; _semaphore = new SemaphoreSlim(maxConcurrent); }} public class RateLimitedExchangeService : IExchangeRateService{ private readonly IExchangeRateService _inner; private static readonly SemaphoreSlim _semaphore = new(5); public RateLimitedExchangeService(IExchangeRateService inner) => _inner = inner; public async Task<decimal> GetRateAsync(string from, string to) { await _semaphore.WaitAsync(); try { return await _inner.GetRateAsync(from, to); } finally { _semaphore.Release(); } }}
When there's nothing to control. If you just need a service with no caching, auth, or lazy loading, a proxy is unnecessary indirection.
When the Decorator pattern fits better. Proxy and Decorator look similar. Use Proxy when the focus is controlling access. Use Decorator when the focus is adding behavior.
When transparency causes confusion. If the team can't figure out which implementation is actually running, the "transparent" nature of the proxy becomes a debugging problem.
The Proxy pattern puts an intermediary between the client and a real object. The intermediary has the same interface, so the client doesn't know it's there. It can add caching, access control, lazy loading, or logging transparently.
When you need to add a cross-cutting concern (caching, auth, rate limiting) to a service without modifying it. Also when you need lazy initialization of expensive objects or remote service abstraction.
For a service with no access control, caching, or lazy loading needs, yes. It adds a layer of indirection that only pays off when you have a concrete concern to address.
Both wrap an object with the same interface. The Proxy controls access (caching, auth, lazy loading). The Decorator adds behavior (logging, metrics, retry). The intent differs even though the structure is similar.
The Proxy pattern is everywhere in .NET. HttpClientHandler is a proxy. EF Core's lazy-loading navigation properties are virtual proxies. ASP.NET Core's authorization middleware is a protection proxy.
When you need to intercept, control, or optimize access to an object without the client knowing, the Proxy pattern is your tool.
That's all from me today.
P.S. Follow me on YouTube.
If you made it this far, you're clearly serious about writing better .NET code. Here's a 20% discount code: DEEP20 for Design Patterns that Deliver. Consider it a thank-you for actually reading the whole thing.
Here are 2 ebooks I have about design patterns:
Design Patterns that Deliver — 5 essential patterns (Builder, Decorator, Strategy, Adapter, Mediator) with production-ready C# code and real-world examples. Or try a free chapter on the Builder Pattern first.
Design Patterns Simplified — A beginner-friendly guide to understanding design patterns without the academic fluff.
Stop arguing about code style. In this course you get a production-proven setup with analyzers, CI quality gates, and architecture tests — the exact system I use in real projects. Join here.
Not sure yet? Grab the free Starter Kit — a drop-in setup with the essentials from Module 01.
Design Patterns that Deliver — Solve real problems with 5 battle-tested patterns (Builder, Decorator, Strategy, Adapter, Mediator) using practical, real-world examples. Trusted by 650+ developers.
Just getting started? Design Patterns Simplified covers 10 essential patterns in a beginner-friendly, 30-page guide for just $9.95.
Every Monday morning, I share 1 actionable tip on C#, .NET & Architecture that you can use right away. Join here.
Join 20,000+ subscribers who mass-improve their .NET skills with actionable tips on C#, Software Architecture & Best Practices.
Subscribe to the TheCodeMan.net and be among the 20,000+ subscribers gaining practical tips and resources to enhance your .NET expertise.