Proxy Pattern in C#: Lazy Loading, Auth, and Remote Stubs
Proxy pattern in C# / .NET 10: lazy-load via EF Core proxies, add authorisation around a service, and recognise gRPC clients as remote proxies.
Table of contents
- What problem does the Proxy pattern solve in C#?
- How does a virtual proxy look in .NET 10?
- How does a protection proxy enforce authorisation?
- How is a gRPC or HttpClient stub a remote proxy?
- When does the Proxy pattern misfire?
- How does Proxy compare to Decorator and Adapter?
- What does a real .NET 10 example look like?
- Where should you read next in this series?
You are designing the order-management dashboard. The
IOrderRepository.GetByIdAsync method works fine for support staff.
Then the security review lands: only an order's owner, or someone
in the support role, may read an order. Plumbing that check into
every call site is hopeless. Plumbing it into OrderRepository
itself mixes data access with authorisation.
The Proxy pattern is the answer. Place a class between the caller and the real repository that has the same interface and checks the caller's identity before forwarding. The pattern shows up in three flavours in modern .NET — virtual proxy (lazy load), protection proxy (authorisation), and remote proxy (gRPC, HttpClient) — and recognising each is what tells you whether to write the wrapper yourself or rely on the framework.
This article continues the e-commerce shop and uses each flavour on a real piece of the system.
What problem does the Proxy pattern solve in C#?
The pattern earns its place when you need a stand-in for an object that controls how the real one is reached, without changing the caller's code or the real object's interface. Three concrete shapes:
- Virtual proxy. Defer creating an expensive object until it is
actually used. EF Core's lazy-loading proxies,
Lazy<T>, and placeholder image objects all fit this shape. - Protection proxy. Enforce an authorisation check before each call. Useful when the same data store has different read rules for different roles.
- Remote proxy. Forward the call to a different process or machine. gRPC clients, REST clients, and message-bus stubs are all remote proxies — the local object presents an interface while the implementation lives elsewhere.
What is not a Proxy problem: "I want to add behaviour around the real object" — that is Decorator. "I want a foreign type to fit my interface" — that is Adapter. Proxy is specifically about controlling whether, when, or where the underlying call happens.
How does a virtual proxy look in .NET 10?
A virtual proxy defers expensive work until the value is read. The
manual version is a Lazy<T> field; the EF Core version is
generated for you.
A small hand-rolled virtual proxy for a CDN image whose metadata you do not always need:
public interface IProductImage
{
int Width { get; }
int Height { get; }
byte[] Bytes { get; }
}
public sealed class ProductImageProxy : IProductImage
{
private readonly Lazy<IProductImage> _real;
public ProductImageProxy(string cdnUrl, IHttpClientFactory http)
{
_real = new Lazy<IProductImage>(() => LoadFromCdn(cdnUrl, http));
}
public int Width => _real.Value.Width;
public int Height => _real.Value.Height;
public byte[] Bytes => _real.Value.Bytes;
private static IProductImage LoadFromCdn(string url, IHttpClientFactory http)
{
// download + decode happens once, on first property access
// ...
return /* RealProductImage */ default!;
}
}
Callers who need only a placeholder URL or never touch a property
pay zero cost. Callers who do read Bytes trigger the download
exactly once.
The same pattern, automated by EF Core when you opt in:
// Program.cs
builder.Services.AddDbContext<AppDbContext>(o =>
o.UseSqlServer(connStr).UseLazyLoadingProxies());
// In a model with virtual navigation properties
public sealed class Order
{
public int Id { get; set; }
public virtual Customer Customer { get; set; } = default!;
public virtual ICollection<OrderItem> Items { get; set; } = new List<OrderItem>();
}
EF Core generates a runtime subclass — the Proxy — that overrides
Customer and Items to load from the database the first time
they are accessed. The same shape as the hand-rolled version, just
synthesised by the framework.
How does a protection proxy enforce authorisation?
A protection proxy adds an authorisation check before each call to the real object. It implements the same interface and depends on a context (the current user) plus the real implementation.
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(int id, CancellationToken ct);
}
public sealed class OrderRepository : IOrderRepository
{
/* talks to AppDbContext */
public Task<Order?> GetByIdAsync(int id, CancellationToken ct) => /* ... */;
}
public sealed class AuthorisedOrderRepository : IOrderRepository
{
private readonly IOrderRepository _inner;
private readonly ICurrentUser _user;
public AuthorisedOrderRepository(IOrderRepository inner, ICurrentUser user)
=> (_inner, _user) = (inner, user);
public async Task<Order?> GetByIdAsync(int id, CancellationToken ct)
{
var order = await _inner.GetByIdAsync(id, ct);
if (order is null) return null;
if (order.CustomerId != _user.Id && !_user.IsInRole("support"))
throw new ForbiddenException($"User {_user.Id} cannot read order {id}");
return order;
}
}
// Composition root with Scrutor
builder.Services.AddScoped<OrderRepository>();
builder.Services.AddScoped<IOrderRepository>(sp => sp.GetRequiredService<OrderRepository>());
builder.Services.Decorate<IOrderRepository, AuthorisedOrderRepository>();
Every call site that depends on IOrderRepository automatically
goes through the authorisation check. The repository class never
imports ICurrentUser. The wiring is the same shape as
Decorator — and that is intentional;
the structural picture is identical, the intent is different.
How is a gRPC or HttpClient stub a remote proxy?
A remote proxy forwards calls across a process or machine boundary. The generated gRPC client, the typed HttpClient, and the message-bus RPC stubs in NServiceBus are all remote proxies. The shape:
flowchart LR
Caller[Local caller] --> Stub[Generated client<br>OrderServiceClient]
Stub -->|gRPC over HTTP/2| Server[Remote service]
Server --> Real[Real OrderService]
The caller sees a normal C# interface. The stub serialises arguments, sends a request, deserialises the response, and returns. The real object lives elsewhere. Recognising the stub as a Proxy is what reminds you that:
- Every call has network cost. A loop calling
client.GetOrderAsyncN times costs N round trips. - Failures are richer. The local object never throws "channel unavailable"; the remote proxy does.
- Observability changes. Wrap the stub in a Decorator for tracing — a remote proxy without distributed tracing is a blind spot.
The gRPC team's documentation calls these clients, not proxies — but in design-pattern vocabulary they are remote proxies, and naming them so makes the cost profile easier to reason about.
When does the Proxy pattern misfire?
Three traps:
- Hidden cost surprises callers. EF Core lazy loading is the
canonical example.
foreach (var item in order.Items)looks like a property access; it is N+1 queries. Disable lazy loading by default and useIncludeexplicitly when you need related data. - Protection proxy duplicated everywhere. Every repository wraps
itself in a protection proxy. The authorisation logic is now in
ten classes. Move it to one place — a single
IAuthorisationServiceconsulted by each Proxy — and keep the Proxy classes thin. - Remote proxy used as if local. Code calls a gRPC client inside a tight loop because it looks like a local method. Profile catches it eventually; recognising the Proxy at design time prevents the bug.
How does Proxy compare to Decorator and Adapter?
| Pattern | Same interface? | Adds behaviour? | Controls access / location? |
|---|---|---|---|
| Proxy | Yes | No (or transparent) | Yes (lazy, auth, remote) |
| Decorator | Yes | Yes (logging, retry) | No |
| Adapter | No (changes interface) | No | No |
The cleanest sentence: Proxy controls whether or where the call happens; Decorator controls what extra work surrounds it; Adapter controls what shape the call takes. All three look identical at the class definition level — the difference is in the intent and what the wrapper's body actually does.
What does a real .NET 10 example look like?
A complete shape combining the protection proxy with the rest of the checkout codebase. The repository is registered, the proxy is registered as a decorator, and consumers depend only on the interface.
public sealed record Order(int Id, int CustomerId, decimal Total);
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(int id, CancellationToken ct);
}
public sealed class OrderRepository : IOrderRepository
{
private readonly AppDbContext _db;
public OrderRepository(AppDbContext db) => _db = db;
public async Task<Order?> GetByIdAsync(int id, CancellationToken ct)
{
var row = await _db.Orders.FindAsync(new object[] { id }, ct);
return row is null ? null : new Order(row.Id, row.CustomerId, row.Total);
}
}
public interface ICurrentUser
{
int Id { get; }
bool IsInRole(string role);
}
public sealed class HttpCurrentUser : ICurrentUser
{
public HttpCurrentUser(IHttpContextAccessor http)
{
var p = http.HttpContext?.User
?? throw new InvalidOperationException("No user");
Id = int.Parse(p.FindFirst("sub")?.Value ?? "0");
_user = p;
}
private readonly ClaimsPrincipal _user;
public int Id { get; }
public bool IsInRole(string role) => _user.IsInRole(role);
}
public sealed class AuthorisedOrderRepository : IOrderRepository
{
private readonly IOrderRepository _inner;
private readonly ICurrentUser _user;
private readonly ILogger<AuthorisedOrderRepository> _log;
public AuthorisedOrderRepository(IOrderRepository inner, ICurrentUser user,
ILogger<AuthorisedOrderRepository> log)
=> (_inner, _user, _log) = (inner, user, log);
public async Task<Order?> GetByIdAsync(int id, CancellationToken ct)
{
var order = await _inner.GetByIdAsync(id, ct);
if (order is null) return null;
if (order.CustomerId != _user.Id && !_user.IsInRole("support"))
{
_log.LogWarning("User {U} blocked from order {O}", _user.Id, id);
throw new ForbiddenException($"User {_user.Id} cannot read order {id}");
}
return order;
}
}
// Composition root
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUser, HttpCurrentUser>();
builder.Services.AddScoped<OrderRepository>();
builder.Services.AddScoped<IOrderRepository>(sp => sp.GetRequiredService<OrderRepository>());
builder.Services.Decorate<IOrderRepository, AuthorisedOrderRepository>();
// Consumer
[ApiController, Route("orders")]
public sealed class OrdersController : ControllerBase
{
private readonly IOrderRepository _orders;
public OrdersController(IOrderRepository orders) => _orders = orders;
[HttpGet("{id:int}")]
public async Task<ActionResult<Order>> Get(int id, CancellationToken ct)
{
var order = await _orders.GetByIdAsync(id, ct);
return order is null ? NotFound() : Ok(order);
}
}
// Test the protection logic in isolation
[Fact]
public async Task Proxy_blocks_non_owner_who_is_not_support()
{
var inner = new Mock<IOrderRepository>();
inner.Setup(x => x.GetByIdAsync(1, default))
.ReturnsAsync(new Order(1, CustomerId: 42, Total: 9.99m));
var user = new Mock<ICurrentUser>();
user.SetupGet(u => u.Id).Returns(99);
user.Setup(u => u.IsInRole("support")).Returns(false);
var sut = new AuthorisedOrderRepository(inner.Object, user.Object, NullLogger<AuthorisedOrderRepository>.Instance);
await Assert.ThrowsAsync<ForbiddenException>(() => sut.GetByIdAsync(1, default));
}
What this shape gives you: the controller and the repository have
zero knowledge of authorisation. The Proxy is the only class that
imports ICurrentUser. The day the rules change, you edit one file.
Where should you read next in this series?
- Previous: Flyweight — sharing immutable instances to cut allocation cost.
- Next: Chain of Responsibility — the first behavioural pattern, about a chain of handlers that decide whether to process or pass along.
- Cross-reference: Decorator — same shape, different intent (adds behaviour vs controls access).
- Cross-reference: Adapter — same shape, different intent again (changes interface vs forwards through).
- Decision tree: How to choose the right design pattern.
A note on the structural group as a whole: every pattern in it is a class wrapping another class with the same or different interface. The differentiator is always intent. When you see a wrapping class in code, do not stop at "it's a wrapper" — push to "why is it a wrapper?". The answer to that question is which structural pattern the code is using, and changing it requires reading the body, not the signature.
Frequently asked questions
What is the difference between Proxy and Decorator?
How does EF Core lazy-loading use the Proxy pattern?
services.AddDbContext<MyContext>(o => o.UseLazyLoadingProxies()), EF Core generates a runtime subclass of every entity that overrides each navigation property. The subclass is the Proxy: accessing order.Customer for the first time triggers a database query, transparently. Reference: Microsoft's lazy loading docs.Is a gRPC or HttpClient API client a Proxy?
OrderServiceClient exposes the same interface as a local IOrderService but every call serialises and goes over the network. Recognising the gRPC stub as a Proxy is what reminds you to keep the interface narrow: every method on it costs a round trip.When does a Proxy hide too much?
order.Items.Count() in a foreach accidentally runs N+1 queries. Proxies that change the cost profile of an operation must come with discoverable hints — XML docs, naming, code analysis rules — or they will surprise the next person who reads the code.