Structural Intermediate 8 min read

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
  1. What problem does the Proxy pattern solve in C#?
  2. How does a virtual proxy look in .NET 10?
  3. How does a protection proxy enforce authorisation?
  4. How is a gRPC or HttpClient stub a remote proxy?
  5. When does the Proxy pattern misfire?
  6. How does Proxy compare to Decorator and Adapter?
  7. What does a real .NET 10 example look like?
  8. 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:

  1. 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.
  2. Protection proxy. Enforce an authorisation check before each call. Useful when the same data store has different read rules for different roles.
  3. 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:

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:

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.

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?
Both wrap an object behind the same interface. Decorator adds behaviour (logging, retry, caching). Proxy controls access (lazy creation, authorisation, remote calls). The line is fuzzy — a caching proxy is also a decorator that adds caching — but the intent helps: if you are saying 'do extra work before/after', you mean Decorator; if you are saying 'decide whether/where the call happens at all', you mean Proxy.
How does EF Core lazy-loading use the Proxy pattern?
When you call 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?
Yes — a remote Proxy. The generated 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?
When callers cannot tell that an operation has dramatically different cost than it looks. EF Core lazy loading is the canonical example: 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.