Proxy Pattern: Lazy Loading, Remote Proxies and Intercepting with DispatchProxy
A proxy implements the same interface as the real object and controls access to it: it defers creation (virtual), checks permissions (protection), represents a remote service (remote) or does extra work on access (smart reference). EF Core's Castle.Proxies.AuthorProxy and the N+1 it hides, a virtual proxy with Lazy<T>, typed HttpClients as remote proxies, DispatchProxy's async and TargetInvocationException pitfalls, and how it differs from Decorator.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
Bookstore's author page reads from SQLite with Entity Framework Core (EF Core), and a while ago the team turned on UseLazyLoadingProxies(), "so it works even if we forget to write Include". The model is ordinary; the only difference is that the navigation properties are virtual:
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace Bookstore.Data;
public class Author
{
public int Id { get; set; }
public required string Name { get; set; }
// virtual: the proxy overrides this property and runs a query on first access
public virtual ICollection<Book> Books { get; set; } = [];
}
public class Book
{
public int Id { get; set; }
public required string Title { get; set; }
public int AuthorId { get; set; }
public virtual Author Author { get; set; } = null!;
}
public sealed class BookstoreContext(SqliteConnection connection) : DbContext
{
public int Commands { get; set; }
public DbSet<Author> Authors => Set<Author>();
public DbSet<Book> Books => Set<Book>();
protected override void OnConfiguring(DbContextOptionsBuilder options) =>
options
.UseSqlite(connection)
.UseLazyLoadingProxies()
.LogTo(_ => Commands++, [RelationalEventId.CommandExecuted]);
}
The database has five authors with two books each. The code below reads the authors in one query and prints each one's book count. How many SQL (Structured Query Language) commands go to the database during this block? And what does authors[0].GetType() return?
using (BookstoreContext db = new(connection))
{
List<Author> authors = db.Authors.ToList();
Console.WriteLine($"entity type: {authors[0].GetType().FullName}");
// An innocent-looking loop: each author's Books is a separate query on first access
foreach (Author author in authors)
Console.WriteLine($" {author.Name}: {author.Books.Count} books");
Console.WriteLine($"lazy: {db.Commands} SQL commands");
}
Six Queries and Castle.Proxies.AuthorProxy
entity type: Castle.Proxies.AuthorProxy
Frank Herbert: 2 books
Ursula K. Le Guin: 2 books
Isaac Asimov: 2 books
Octavia E. Butler: 2 books
Iain M. Banks: 2 books
lazy: 6 SQL commands
One query for the authors, five queries for each author's Books: the classic N+1. The first line is more interesting. db.Authors.ToList() didn't give you Authors but objects of Castle.Proxies.AuthorProxy, a type generated at runtime. That type derives from Author, overrides the virtual Books property and, on first access, tells the context "load this navigation" before returning the collection. The code thinks it's talking to an Author; in fact there's a proxy in between, and that proxy can turn every access into a database call.
The same data can be fetched in a single query with Include:
using (BookstoreContext db = new(connection))
{
List<Author> authors = db.Authors.Include(a => a.Books).ToList();
int total = authors.Sum(a => a.Books.Count);
Console.WriteLine($"include: {db.Commands} SQL command(s), {total} books");
}
include: 1 SQL command(s), 10 books
This post is about exactly that layer in between: the Proxy pattern, which stands in front of the real object with the same interface and controls access to it. We'll cover its kinds (virtual, protection, remote), DispatchProxy as .NET's way of generating proxies at runtime and its two pitfalls, how Proxy differs from Decorator, and the cost a proxy hides.
The GoF Definition and the Kinds of Proxy
Proxy is one of the structural patterns in the GoF (Gang of Four: Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides' 1994 book Design Patterns: Elements of Reusable Object-Oriented Software) catalog. Its intent, in my own words: provide an object that stands in for another object (a surrogate) in order to control access to it. The proxy implements the same interface as the real subject; the client can't tell the difference. There are three roles: Subject (the common interface), RealSubject (the object doing the real work) and Proxy (the object that holds a reference to the RealSubject, controls access to it and is responsible for creating and deleting it when needed).
GoF lists four kinds. A remote proxy represents locally an object in a different address space (another process, another machine). A virtual proxy doesn't create an expensive object until it's really needed; the book's example is an image proxy that doesn't load large images when a document is opened. A protection proxy passes or rejects the call based on access rights. A smart reference is a reference that does extra work on access; one of the examples the book lists is loading a persistent object into memory when it's first referenced. EF Core's lazy-loading proxy is exactly that: a smart reference that fetches data from the database on first access to a navigation.
Virtual Proxy: Deferring Creation with Lazy<T>
The annual sales report engine loads templates, fonts and the exchange rate table at startup. Most of the application's requests never need this engine, but the DI (Dependency Injection) container creates it the moment it's first injected. A virtual proxy defers the engine until the first Render call:
namespace Bookstore.Reports;
public interface ISalesReportEngine
{
string Render(int year);
}
// The real subject: expensive to create (templates, fonts, exchange rate table)
public sealed class SalesReportEngine : ISalesReportEngine
{
public SalesReportEngine()
{
Console.WriteLine(" [engine] loading templates and exchange rates...");
Thread.Sleep(300);
}
public string Render(int year) => $"Sales report {year}";
}
// Virtual proxy: creates the real object once, at the first real need
public sealed class LazySalesReportEngine(Func<SalesReportEngine> create) : ISalesReportEngine
{
private readonly Lazy<SalesReportEngine> _engine = new(create);
public string Render(int year) => _engine.Value.Render(year);
}
public static class VirtualProxyDemo
{
public static void Run()
{
Console.WriteLine("app started");
ISalesReportEngine engine = new LazySalesReportEngine(() => new SalesReportEngine());
Console.WriteLine("proxy registered");
Console.WriteLine(engine.Render(2025));
Console.WriteLine(engine.Render(2026));
}
}
app started
proxy registered
[engine] loading templates and exchange rates...
Sales report 2025
Sales report 2026
Creating the proxy is free; the expensive constructor runs only on the first Render, and only once. Lazy<T> is thread-safe by default (LazyThreadSafetyMode.ExecutionAndPublication), so even if two requests arrive at the same time the engine is created once. You register this proxy in DI with a factory such as services.AddSingleton<ISalesReportEngine>(sp => new LazySalesReportEngine(() => new SalesReportEngine())); clients ask for ISalesReportEngine and know nothing about the proxy. Note: if the client itself injected Lazy<ISalesReportEngine> or Func<ISalesReportEngine>, you'd get the same deferred creation, but then the knowledge that "this dependency is expensive" would leak into every client. The proxy keeps that knowledge in one place.
Protection Proxy: Same Interface, Authorization First
Refunds are open to the finance team; the support team can refund up to 50 EUR, and the warehouse team can't refund at all. Writing the rule inside RefundService ties refunding to the concept of permissions; the proxy puts the check in front of it instead:
namespace Bookstore.Payments;
public interface IRefundService
{
void Refund(Guid orderId, decimal amount);
}
public sealed class RefundService : IRefundService
{
public void Refund(Guid orderId, decimal amount) => Console.WriteLine($" refunded {amount} EUR");
}
public sealed record CurrentUser(string Name, string Role);
// Protection proxy: the same interface, authorization check first
public sealed class AuthorizedRefundService(IRefundService inner, CurrentUser user) : IRefundService
{
private const decimal SupportLimit = 50m;
public void Refund(Guid orderId, decimal amount)
{
bool allowed = user.Role switch
{
"Finance" => true,
"Support" => amount <= SupportLimit,
_ => false
};
if (!allowed)
throw new UnauthorizedAccessException($"{user.Name} ({user.Role}) cannot refund {amount} EUR.");
inner.Refund(orderId, amount);
}
}
public static class ProtectionProxyDemo
{
public static void Run()
{
foreach (CurrentUser user in (CurrentUser[])[new("Ada", "Finance"), new("Alan", "Support"), new("Grace", "Warehouse")])
{
IRefundService refunds = new AuthorizedRefundService(new RefundService(), user);
try
{
refunds.Refund(Guid.CreateVersion7(), 80m);
}
catch (UnauthorizedAccessException ex)
{
Console.WriteLine($" denied: {ex.Message}");
}
}
}
}
refunded 80 EUR
denied: Alan (Support) cannot refund 80 EUR.
denied: Grace (Warehouse) cannot refund 80 EUR.
In a Web API (Application Programming Interface) the natural place for this check is usually the endpoint: [Authorize], policies and resource-based authorization, as covered in the "Authorization: Roles, Policies and Resource-Based Authorization" post of the ASP.NET Core Web API series. A protection proxy gains value where the same service is also called from outside HTTP (Hypertext Transfer Protocol): if a background job, a message consumer or an admin console also uses IRefundService, the rule stays the same at every entry point.
Remote Proxy: Typed HttpClients and gRPC Clients
You probably write remote proxies in .NET every day without naming them. When the catalog moves to a separate service, you write a typed client that implements the same ICatalog interface over HTTP so that client code doesn't change:
using System.Net.Http.Json;
namespace Bookstore.Catalog;
public sealed record Book(string Isbn, string Title, decimal Price);
public interface ICatalog
{
Task<Book?> FindAsync(string isbn, CancellationToken ct = default);
Book? FindCached(string isbn);
}
// Local implementation
public sealed class InMemoryCatalog : ICatalog
{
private readonly Dictionary<string, Book> _books = new()
{
["978-0441013593"] = new("978-0441013593", "Dune", 12.99m)
};
public async Task<Book?> FindAsync(string isbn, CancellationToken ct = default)
{
await Task.Delay(120, ct);
return _books.GetValueOrDefault(isbn);
}
public Book? FindCached(string isbn) =>
_books.TryGetValue(isbn, out Book? book) ? book : throw new InvalidOperationException($"Unknown ISBN {isbn}");
}
// Remote proxy: the same interface, every call is an HTTP request
public sealed class CatalogClient(HttpClient http) : ICatalog
{
public Task<Book?> FindAsync(string isbn, CancellationToken ct = default) =>
http.GetFromJsonAsync<Book>($"books/{isbn}", ct);
public Book? FindCached(string isbn) => throw new NotSupportedException("Use FindAsync over the network.");
}
public static class RemoteProxyDemo
{
public static async Task RunAsync()
{
CountingHandler handler = new();
ICatalog catalog = new CatalogClient(new HttpClient(handler) { BaseAddress = new Uri("https://catalog.example/") });
// The client code looks like it's talking to a local collection
string[] cart = ["978-0441013593", "978-0441013593", "978-0441013593"];
decimal total = 0;
foreach (string isbn in cart)
total += (await catalog.FindAsync(isbn))?.Price ?? 0;
Console.WriteLine($"total {total} EUR, HTTP requests: {handler.Requests}");
}
// A handler that answers without touching the network and counts the requests
private sealed class CountingHandler : HttpMessageHandler
{
public int Requests { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
Requests++;
await Task.Delay(50, ct);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"isbn":"978-0441013593","title":"Dune","price":12.99}""", Encoding.UTF8, "application/json")
};
}
}
}
total 38.97 EUR, HTTP requests: 3
The code asks for the price of the same book three times, and three HTTP requests go out. With the local InMemoryCatalog this loop was harmless; behind a remote proxy every iteration is a network round trip, a possible timeout and one more kind of failure. Transparency, the proxy's strength, is its weakness here: because the interface is the same, the client code can't see the cost. For gRPC (an open-source RPC (Remote Procedure Call) framework developed by Google), the client class that Grpc.Tools generates from a .proto file is a remote proxy of the same shape: methods that mirror the service's methods one-to-one, with a network call behind them. A good remote proxy makes this cost visible in its signature: its methods are async, take a CancellationToken and, where possible, offer a batch call (FindManyAsync(isbns)).
Generating Proxies at Runtime with DispatchProxy
Writing a proxy by hand for every interface means repetitive code for a single cross-cutting need (timing, logging). .NET's System.Reflection.DispatchProxy class generates a type at runtime that implements the interface you give it and routes every method call to your Invoke override. The first attempt often looks like this:
using System.Diagnostics;
using System.Reflection;
using System.Runtime.ExceptionServices;
namespace Bookstore.Diagnostics;
// First attempt: a proxy designed with synchronous calls in mind
public class NaiveTimingProxy<T> : DispatchProxy where T : class
{
private T _target = null!;
public static T Create(T target)
{
T proxy = Create<T, NaiveTimingProxy<T>>();
((NaiveTimingProxy<T>)(object)proxy)._target = target;
return proxy;
}
protected override object? Invoke(MethodInfo? method, object?[]? args)
{
Stopwatch watch = Stopwatch.StartNew();
object? result = method!.Invoke(_target, args);
Console.WriteLine($" [timing] {method.Name}: {watch.ElapsedMilliseconds} ms");
return result;
}
}
// The fixed version: writes the duration when the Task completes, unwraps the exception wrapped by reflection
public class TimingProxy<T> : DispatchProxy where T : class
{
private T _target = null!;
public static T Create(T target)
{
T proxy = Create<T, TimingProxy<T>>();
((TimingProxy<T>)(object)proxy)._target = target;
return proxy;
}
protected override object? Invoke(MethodInfo? method, object?[]? args)
{
Stopwatch watch = Stopwatch.StartNew();
object? result;
try
{
result = method!.Invoke(_target, args);
}
catch (TargetInvocationException ex) when (ex.InnerException is not null)
{
ExceptionDispatchInfo.Throw(ex.InnerException);
throw;
}
if (result is Task task)
{
// Write the duration when the Task completes; return the same Task to the caller
task.ContinueWith(t => Console.WriteLine($" [timing] {method.Name}: {watch.ElapsedMilliseconds} ms ({t.Status})"),
TaskContinuationOptions.ExecuteSynchronously);
return task;
}
Console.WriteLine($" [timing] {method.Name}: {watch.ElapsedMilliseconds} ms");
return result;
}
}
public static class DispatchProxyDemo
{
public static async Task RunAsync()
{
ICatalog naive = NaiveTimingProxy<ICatalog>.Create(new InMemoryCatalog());
Book? first = await naive.FindAsync("978-0441013593");
Console.WriteLine($"naive proxy -> {first?.Title}");
try
{
naive.FindCached("000");
}
catch (Exception ex)
{
Console.WriteLine($"naive proxy threw {ex.GetType().Name}");
}
ICatalog timed = TimingProxy<ICatalog>.Create(new InMemoryCatalog());
Book? second = await timed.FindAsync("978-0441013593");
Console.WriteLine($"fixed proxy -> {second?.Title}");
try
{
timed.FindCached("000");
}
catch (Exception ex)
{
Console.WriteLine($"fixed proxy threw {ex.GetType().Name}: {ex.Message}");
}
Console.WriteLine($"proxy type: {timed.GetType().Name}");
}
}
[timing] FindAsync: 1 ms
naive proxy -> Dune
naive proxy threw TargetInvocationException
[timing] FindAsync: 122 ms (RanToCompletion)
fixed proxy -> Dune
fixed proxy threw InvalidOperationException: Unknown ISBN 000
proxy type: generatedProxy_2
The naive proxy makes two mistakes, and both would quietly report wrong information in production. First, it measured 1 ms for an async method that takes 120 ms. When method.Invoke calls an async method, the method runs until its first await and returns a Task; if the proxy stops the clock at that point, it has only measured the synchronous part. The fix is to write the duration when the task completes if the result is a Task, and return the same task to the caller. Second, the InvalidOperationException thrown by the real method reached the client wrapped in a TargetInvocationException by reflection; a client that writes catch (InvalidOperationException) can't catch it. The fix is to rethrow the inner exception with its original stack trace using ExceptionDispatchInfo.Throw.
You also need to know DispatchProxy's limits. It can only proxy interfaces, not classes; the type it generates (generatedProxy_2) derives from your TimingProxy<T> class. For methods returning ValueTask, the Task check above isn't enough and needs separate handling. Because code is generated at runtime, it isn't compatible with Native AOT (Ahead-of-Time); when I built the project with IsAotCompatible, the compiler issued warning IL3050 (the AOT analyzer's warning code) for DispatchProxy.Create: "Creating a proxy instance requires generating code at runtime." If you need to proxy classes as well, or to build an interceptor chain, the industry's standard tool is Castle DynamicProxy (the Castle.Core package): it generates subclasses that override virtual members. EF Core's Microsoft.EntityFrameworkCore.Proxies package depends on Castle.Core; that's where the Castle.Proxies.AuthorProxy name at the start of the post comes from. Mocking libraries such as Moq and NSubstitute generate their mock objects with the same library.
Proxy or Decorator?
The cache and log decorators in this series' Decorator post and the proxies here are structurally almost identical: both implement the same interface and hold a reference to the real object. GoF itself acknowledges the similarity and ties the difference to intent: a decorator adds responsibilities to an object, while a proxy controls access to it.
| Question | Decorator | Proxy |
|---|---|---|
| Intent | Add behavior (cache, log, retry) | Control access (defer, permit, move far away) |
| Who creates the real object? | It's passed in; the decorator only wraps it | Often the proxy creates or manages it (virtual, remote) |
| Is it chained? | Yes, several stacked decorators are typical | Usually a single layer |
| Does the client know? | No | No, but it may feel the cost (network, queries) |
In practice the line is blurry: you could also read TimingProxy<T> as a decorator because it adds behavior. What matters isn't the name but that the person reading the code understands the intent; let the answer to "does this layer control access or add behavior?" decide its name too.
Common Pitfalls
The cost a proxy hides. Lazy loading and remote proxies make an expensive operation look like a property access or an ordinary method call. Six queries at the start of the post may look negligible for one page; the same loop in a report endpoint with 500 authors means 501 queries. If you turn on lazy loading in EF Core, read the "Loading Related Data: Include, Explicit and Lazy Loading, Split Queries" and "Performance Basics: N+1, Projection, AsNoTracking and Compiled Queries" posts in the Entity Framework Core series and actually watch the SQL logs.
A proxy's lifetime is tied to its real subject's lifetime. Accessing a lazy-loading proxy after its context is disposed throws:
Author detached;
using (BookstoreContext db = new(connection))
detached = db.Authors.First();
try
{
Console.WriteLine(detached.Books.Count);
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}
InvalidOperationException: An error was generated for warning 'Microsoft.EntityFrameworkCore.Infrastructure.LazyLoadOnDisposedContextWarning': An attempt was made to lazy-load navigation 'AuthorProxy.Books' after the associated DbContext was disposed or returned to the pool, or the entity was explicitly detached from the context. This exception can be suppressed or logged by passing event ID 'CoreEventId.LazyLoadOnDisposedContextWarning' to the 'ConfigureWarnings' method in 'DbContext.OnConfiguring' or 'AddDbContext'.
The same mechanism kicks in when you serialize an entity to JSON (JavaScript Object Notation) from a controller: the serializer touches every navigation, and either extra queries fire or this exception arrives. That's one more reason to return a DTO (Data Transfer Object) instead of an entity.
Forgetting the rule that makes the proxy work. Lazy-loading proxies only work on virtual navigations and on classes that aren't sealed. If you forget to make a navigation virtual, EF Core catches it while building the model:
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Data.Broken;
public class Publisher
{
public int Id { get; set; }
public required string Name { get; set; }
// virtual was forgotten
public ICollection<Imprint> Imprints { get; set; } = [];
}
public class Imprint
{
public int Id { get; set; }
public required string Name { get; set; }
public int PublisherId { get; set; }
}
public sealed class BrokenContext(SqliteConnection connection) : DbContext
{
public DbSet<Publisher> Publishers => Set<Publisher>();
protected override void OnConfiguring(DbContextOptionsBuilder options) =>
options.UseSqlite(connection).UseLazyLoadingProxies();
}
InvalidOperationException: Property 'Publisher.Imprints' is not virtual. 'UseChangeTrackingProxies' requires all entity types to be public, unsealed, have virtual properties, and have a public or protected constructor. 'UseLazyLoadingProxies' requires only the navigation properties be virtual.
Relying on type identity. For a proxied entity, entity.GetType() == typeof(Author) returns false; logging, switch or serialization code that works by type name won't recognize the proxy. An is Author check does work, because the proxy is a subclass.
Business rules inside the proxy. The permission check in the protection proxy is an access rule; "a refund can't exceed the order total" is a business rule and belongs inside RefundService. If removing the proxy also removes a business rule, it was written in the wrong place.
When Not to Use It
A virtual proxy for an object that isn't expensive is an unmeasured optimization; putting a service whose constructor takes a few microseconds behind Lazy<T> only hurts readability. If the framework already provides access control (ASP.NET Core authorization, database permissions), don't repeat the same rule in a protection proxy; a rule that lives in two places drifts apart sooner or later. And a remote proxy's transparency shouldn't be taken so far that it makes you forget the network exists: rather than moving a frequently called, fine-grained local interface across the network as is, design a coarse-grained remote interface with batch calls. Think twice before writing a DispatchProxy for cross-cutting needs (logs, metrics, tracing): .NET's ILogger, System.Diagnostics.Metrics and OpenTelemetry cover most needs without reflection and in an AOT-compatible way.
A Small Experiment
★☆☆ Put a small cache layer in front of CatalogClient that asks for the same ISBN (International Standard Book Number) only once per request. Is this layer a proxy or a decorator? How many of the three HTTP requests in the example remain?
★★☆ Extend TimingProxy<T> for methods returning ValueTask and ValueTask<T>. Think about why you mustn't await a ValueTask twice and how that affects the proxy's design.
★★★ Find the N+1 on the author page without turning off lazy loading: count the commands executed during a request with LogTo and build a mechanism that logs a warning above a threshold. How would you write the same mechanism with EF Core interceptors?
With the next post we move on to behavioral patterns, and the first stop is a pattern you use in ASP.NET Core on every request without noticing: Chain of Responsibility. We'll build the middleware pipeline, a DelegatingHandler chain and, in Bookstore, a discount approval chain that runs from the manager up to the finance director.