Proxy Generator
Overview
The Proxy Generator creates GoF-compliant Proxy pattern implementations that provide controlled access to objects through generated wrapper classes. It eliminates boilerplate by automatically generating proxy types with optional interceptor support for cross-cutting concerns like logging, caching, authentication, and performance monitoring.
The generator produces self-contained C# code with no runtime PatternKit dependency, making it suitable for AOT and trimming scenarios.
When to Use
Use the Proxy generator when you need to:
- Add cross-cutting concerns: Logging, timing, caching, authentication, circuit breakers
- Control access: Add authorization or validation before method execution
- Lazy initialization: Defer expensive object creation until first use
- Remote proxies: Add network communication layers
- Aspect-oriented programming: Inject behavior without modifying the original class
Installation
The generator is included in the PatternKit.Generators package:
dotnet add package PatternKit.Generators
Quick Start
Basic Proxy (No Interceptors)
using PatternKit.Generators.Proxy;
[GenerateProxy(InterceptorMode = ProxyInterceptorMode.None)]
public partial interface IUserService
{
User GetUser(Guid id);
void UpdateUser(User user);
}
Generated:
public sealed partial class UserServiceProxy : IUserService
{
private readonly IUserService _inner;
public UserServiceProxy(IUserService inner)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
}
public User GetUser(Guid id) => _inner.GetUser(id);
public void UpdateUser(User user) => _inner.UpdateUser(user);
}
Proxy with Single Interceptor
[GenerateProxy] // InterceptorMode.Single is default
public partial interface IUserService
{
User GetUser(Guid id);
ValueTask<User> GetUserAsync(Guid id, CancellationToken ct = default);
}
Generated proxy class + interceptor interface:
public sealed partial class UserServiceProxy : IUserService
{
private readonly IUserService _inner;
private readonly IUserServiceInterceptor? _interceptor;
public UserServiceProxy(IUserService inner, IUserServiceInterceptor? interceptor = null)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_interceptor = interceptor;
}
public User GetUser(Guid id)
{
if (_interceptor is null)
return _inner.GetUser(id);
var context = new GetUserMethodContext(id);
try
{
_interceptor.Before(context);
var __result = _inner.GetUser(id);
context.SetResult(__result);
_interceptor.After(context);
return __result;
}
catch (Exception __ex)
{
_interceptor.OnException(context, __ex);
throw; // Rethrow is default
}
}
public async ValueTask<User> GetUserAsync(Guid id, CancellationToken ct = default)
{
if (_interceptor is null)
return await _inner.GetUserAsync(id, ct).ConfigureAwait(false);
var context = new GetUserAsyncMethodContext(id, ct);
try
{
await _interceptor.BeforeAsync(context).ConfigureAwait(false);
var __task = _inner.GetUserAsync(id, ct);
context.SetResult(__task);
var __result = await __task.ConfigureAwait(false);
await _interceptor.AfterAsync(context).ConfigureAwait(false);
return __result;
}
catch (Exception __ex)
{
await _interceptor.OnExceptionAsync(context, __ex).ConfigureAwait(false);
throw;
}
}
}
public interface IUserServiceInterceptor
{
void Before(MethodContext context);
void After(MethodContext context);
void OnException(MethodContext context, Exception ex);
ValueTask BeforeAsync(MethodContext context);
ValueTask AfterAsync(MethodContext context);
ValueTask OnExceptionAsync(MethodContext context, Exception ex);
}
public abstract class MethodContext
{
public abstract string MethodName { get; }
public object? Result { get; private set; }
internal void SetResult(object? result) => Result = result;
}
// NOTE: For async methods that return Task<T> or ValueTask<T>, the Result property
// contains the Task itself (set before awaiting), not the unwrapped value.
// To access the actual result value in After/OnException hooks, you need to access
// the Task's Result property (e.g., ((Task<User>)context.Result).Result).
// Be aware that accessing Task.Result on an incomplete task can cause deadlocks;
// in the generated proxy, the task is already completed when After is called.
public sealed class GetUserMethodContext : MethodContext
{
public Guid Id { get; }
public GetUserMethodContext(Guid id) { Id = id; }
public override string MethodName => "GetUser";
}
Proxy with Pipeline Interceptors
[GenerateProxy(InterceptorMode = ProxyInterceptorMode.Pipeline)]
public partial interface IOrderService
{
Order CreateOrder(OrderRequest request);
}
Generated:
public sealed partial class OrderServiceProxy : IOrderService
{
private readonly IOrderService _inner;
private readonly IReadOnlyList<IOrderServiceInterceptor> _interceptors;
public OrderServiceProxy(
IOrderService inner,
IReadOnlyList<IOrderServiceInterceptor>? interceptors = null)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_interceptors = interceptors ?? Array.Empty<IOrderServiceInterceptor>();
}
public Order CreateOrder(OrderRequest request)
{
if (_interceptors.Count == 0)
return _inner.CreateOrder(request);
var context = new CreateOrderMethodContext(request);
try
{
// Before: ascending order (0 -> N)
for (int i = 0; i < _interceptors.Count; i++)
_interceptors[i].Before(context);
var __result = _inner.CreateOrder(request);
context.SetResult(__result);
// After: descending order (N -> 0)
for (int i = _interceptors.Count - 1; i >= 0; i--)
_interceptors[i].After(context);
return __result;
}
catch (Exception __ex)
{
// OnException: descending order (N -> 0)
for (int i = _interceptors.Count - 1; i >= 0; i--)
_interceptors[i].OnException(context, __ex);
throw;
}
}
}
Pipeline Ordering:
- Before: Called in ascending order (
interceptors[0]is outermost) - After: Called in descending order (unwinding the stack)
- OnException: Called in descending order (unwinding the stack)
Attributes
[GenerateProxy]
Main attribute for marking interfaces or abstract classes for proxy generation.
Target: interface or abstract class (only top-level, non-generic)
Properties:
| Property | Type | Default | Description |
|---|---|---|---|
ProxyTypeName |
string? |
{ContractName}Proxy |
Name of the generated proxy class |
InterceptorMode |
ProxyInterceptorMode |
Single |
Interceptor support mode |
GenerateAsync |
bool? |
Auto-detected | Generate async interceptor methods |
ForceAsync |
bool |
false |
Force async even if no async members detected |
Exceptions |
ProxyExceptionPolicy |
Rethrow |
Exception handling policy |
Example:
[GenerateProxy(
ProxyTypeName = "UserServiceLoggingProxy",
InterceptorMode = ProxyInterceptorMode.Pipeline,
Exceptions = ProxyExceptionPolicy.Rethrow)]
public partial interface IUserService { }
[ProxyIgnore]
Marks a method or property to exclude from proxy generation.
Target: Methods, Properties
Example:
[GenerateProxy]
public interface IUserService
{
User GetUser(Guid id);
[ProxyIgnore]
void InternalMethod(); // Not included in proxy
}
Interceptor Modes
ProxyInterceptorMode.None
Pure delegation with no interceptor support. Lightest weight option.
Use when: You just need a simple wrapper without any cross-cutting concerns.
[GenerateProxy(InterceptorMode = ProxyInterceptorMode.None)]
public partial interface ICalculator
{
int Add(int a, int b);
}
ProxyInterceptorMode.Single
Accepts a single interceptor instance. Good for simple scenarios.
Use when: You have one interceptor or want to compose interceptors manually.
[GenerateProxy] // Single is default
public partial interface IUserService { }
// Usage:
var logger = new LoggingInterceptor();
var proxy = new UserServiceProxy(realService, logger);
ProxyInterceptorMode.Pipeline
Accepts a list of interceptors with deterministic ordering.
Use when: You need multiple interceptors with clear execution order.
[GenerateProxy(InterceptorMode = ProxyInterceptorMode.Pipeline)]
public partial interface IOrderService { }
// Usage:
var interceptors = new List<IOrderServiceInterceptor>
{
new AuthenticationInterceptor(), // [0] - outermost
new LoggingInterceptor(), // [1]
new TimingInterceptor(), // [2]
new CachingInterceptor() // [3] - innermost
};
var proxy = new OrderServiceProxy(realService, interceptors);
Execution Flow:
Request → Auth.Before → Log.Before → Time.Before → Cache.Before
→ Real Method
← Cache.After ← Time.After ← Log.After ← Auth.After Response
Exception Handling
ProxyExceptionPolicy.Rethrow (Default)
OnException is called, then the exception is rethrown.
[GenerateProxy(Exceptions = ProxyExceptionPolicy.Rethrow)]
public partial interface IUserService { }
Behavior:
try
{
// ... method execution
}
catch (Exception ex)
{
_interceptor.OnException(context, ex);
throw; // Exception propagates to caller
}
ProxyExceptionPolicy.Swallow
OnException is called, but the exception is not rethrown. Use with extreme caution.
[GenerateProxy(Exceptions = ProxyExceptionPolicy.Swallow)]
public partial interface IResilientService { }
Behavior:
try
{
// ... method execution
}
catch (Exception ex)
{
_interceptor.OnException(context, ex);
// Exception is swallowed - method returns default value
}
⚠️ Warning: Swallow mode can hide errors and cause unexpected behavior. Only use when:
- You have explicit error recovery logic in your interceptor
- The contract allows for null/default return values
- You log exceptions thoroughly
Async Support
The generator automatically detects async members and generates async interceptor methods.
Auto-detection triggers:
- Any method returns
TaskorValueTask(with or without generic type) - Any method has a
CancellationTokenparameter
Generated async methods use ValueTask for efficiency:
public interface IUserServiceInterceptor
{
// Sync
void Before(MethodContext context);
void After(MethodContext context);
void OnException(MethodContext context, Exception ex);
// Async (generated when async members detected)
ValueTask BeforeAsync(MethodContext context);
ValueTask AfterAsync(MethodContext context);
ValueTask OnExceptionAsync(MethodContext context, Exception ex);
}
Force Async:
[GenerateProxy(ForceAsync = true)]
public partial interface IFutureProofService
{
void DoWork(); // No async yet, but interceptor will have async methods
}
Supported Members
Methods
✅ Supported:
- Void methods
- Methods with return values
- Async methods (
Task,ValueTask,Task<T>,ValueTask<T>) - Methods with
CancellationTokenparameters - Methods with default parameters
- Methods with
ref/inparameters (forwarded to inner, but not captured in MethodContext before execution)
❌ Not Supported (v1):
- Generic methods (generates PKPRX002 "Generic method")
- Interceptor observation of
outparameter values: methods withoutparameters are forwarded correctly to the inner implementation, butoutparameter values appear asdefaultin theBeforeMethodContext (since they haven't been assigned yet) and are not captured in the context after method execution - Events (generates PKPRX002)
Properties
✅ Supported:
- Get-only properties
- Set-only properties
- Get/set properties
- Auto-properties
- Expression-bodied properties
Generated forwarding:
public string Name
{
get => _inner.Name;
set => _inner.Name = value;
}
⚠️ Note: Properties do not invoke interceptors. They are simple forwarders. To intercept property access, use explicit getter/setter methods instead.
Abstract Classes
For abstract classes, only virtual and abstract members are proxied.
[GenerateProxy]
public abstract partial class UserServiceBase
{
// Proxied
public abstract User GetUser(Guid id);
public virtual void UpdateUser(User user) { }
// NOT proxied (sealed/non-virtual)
public void InternalMethod() { }
}
Diagnostics
The generator provides actionable diagnostics for invalid usage:
| ID | Severity | Description |
|---|---|---|
| PKPRX001 | Error | Type marked [GenerateProxy] must be partial |
| PKPRX002 | Error | Unsupported member kind (e.g., events not supported in v1) |
| PKPRX003 | Warning | Member not accessible for proxy generation |
| PKPRX004 | Error | Proxy type name conflicts with existing type |
| PKPRX005 | Warning | Async member detected but async interception disabled |
PKPRX001: Must Be Partial
[GenerateProxy]
public interface IUserService { } // ❌ Error: must be partial
[GenerateProxy]
public partial interface IUserService { } // ✅ Correct
PKPRX002: Unsupported Member
[GenerateProxy]
public partial interface IUserService
{
event EventHandler<UserEventArgs> UserChanged; // ❌ Error: Events not supported
}
Fix: Remove the event or use [ProxyIgnore] to exclude it.
PKPRX003: Inaccessible Member
Generated when a member cannot be accessed from the proxy type (e.g., protected members on interface members).
PKPRX004: Name Conflict
[GenerateProxy(ProxyTypeName = "UserService")] // ❌ Conflicts with existing type
public partial interface IUserService { }
public class UserService { } // Existing type
Fix: Use a different ProxyTypeName.
PKPRX005: Async Disabled
[GenerateProxy(GenerateAsync = false)]
public partial interface IUserService
{
Task<User> GetUserAsync(Guid id); // ⚠️ Warning: Async member but async disabled
}
Fix: Remove GenerateAsync = false or set ForceAsync = true.
Implementing Interceptors
Basic Interceptor
public class LoggingInterceptor : IUserServiceInterceptor
{
private readonly ILogger _logger;
public LoggingInterceptor(ILogger logger) => _logger = logger;
public void Before(MethodContext context)
{
// WARNING: Do not log sensitive data (passwords, tokens, PII, credit card numbers, etc.)
// from method parameters or context.Result. Log only non-sensitive metadata or masked values.
// Logging full arguments/results can expose secrets in application logs.
// Access strongly-typed parameters from specific context types if needed (e.g., ((GetUserMethodContext)context).Id)
_logger.LogInformation("Calling {Method}", context.MethodName);
}
public void After(MethodContext context)
{
// WARNING: Do not log sensitive data from context.Result.
// Prefer logging high-level status or non-sensitive identifiers.
_logger.LogInformation("{Method} completed successfully",
context.MethodName);
}
public void OnException(MethodContext context, Exception ex)
{
_logger.LogError(ex, "{Method} failed", context.MethodName);
}
// Async versions (if generated)
public ValueTask BeforeAsync(MethodContext context)
{
Before(context);
return default;
}
public ValueTask AfterAsync(MethodContext context)
{
After(context);
return default;
}
public ValueTask OnExceptionAsync(MethodContext context, Exception ex)
{
OnException(context, ex);
return default;
}
}
Performance Timing Interceptor
public class TimingInterceptor : IUserServiceInterceptor
{
private readonly IMetrics _metrics;
private readonly Dictionary<int, Stopwatch> _timers = new();
public TimingInterceptor(IMetrics metrics) => _metrics = metrics;
public void Before(MethodContext context)
{
var sw = Stopwatch.StartNew();
_timers[context.GetHashCode()] = sw;
}
public void After(MethodContext context)
{
if (_timers.Remove(context.GetHashCode(), out var sw))
{
sw.Stop();
_metrics.RecordDuration(context.MethodName, sw.Elapsed);
}
}
public void OnException(MethodContext context, Exception ex)
{
_timers.Remove(context.GetHashCode());
}
// Async versions...
public ValueTask BeforeAsync(MethodContext context)
{
Before(context);
return default;
}
public ValueTask AfterAsync(MethodContext context)
{
After(context);
return default;
}
public ValueTask OnExceptionAsync(MethodContext context, Exception ex)
{
OnException(context, ex);
return default;
}
}
Caching Interceptor (Advanced)
public class CachingInterceptor : IUserServiceInterceptor
{
private readonly IMemoryCache _cache;
public CachingInterceptor(IMemoryCache cache) => _cache = cache;
public void Before(MethodContext context)
{
// NOTE: The current proxy generator does not support short-circuiting method
// execution from interceptors. The SetResult method is internal and cannot be
// called from user code. You can use this hook for cache-key calculation,
// logging, or metrics, but the actual method will always execute.
// Cache population happens in After once the method has executed.
var cacheKey = $"{context.MethodName}";
// Could check cache here and record a metric/log if it's a hit
}
public void After(MethodContext context)
{
// Cache result after execution
var cacheKey = $"{context.MethodName}";
_cache.Set(cacheKey, context.Result, TimeSpan.FromMinutes(5));
}
public void OnException(MethodContext context, Exception ex) { }
// Async versions omitted for brevity
}
Real-World Example: Complete Pipeline
[GenerateProxy(InterceptorMode = ProxyInterceptorMode.Pipeline)]
public partial interface IOrderService
{
Task<Order> CreateOrderAsync(OrderRequest request, CancellationToken ct = default);
Task<Order> GetOrderAsync(Guid orderId, CancellationToken ct = default);
}
// Setup
var authInterceptor = new AuthenticationInterceptor(authService);
var loggingInterceptor = new LoggingInterceptor(logger);
var timingInterceptor = new TimingInterceptor(metrics);
var cachingInterceptor = new CachingInterceptor(cache);
var interceptors = new List<IOrderServiceInterceptor>
{
authInterceptor, // [0] - Outermost: auth first
loggingInterceptor, // [1] - Log after auth
timingInterceptor, // [2] - Time inner operations
cachingInterceptor // [3] - Innermost: cache closest to real call
};
var proxy = new OrderServiceProxy(realOrderService, interceptors);
// Usage
var order = await proxy.CreateOrderAsync(request, ct);
Execution Flow:
CreateOrderAsync called
├─ [0] Auth.BeforeAsync → Verify user has permission
├─ [1] Log.BeforeAsync → Log "Creating order..."
├─ [2] Time.BeforeAsync → Start stopwatch
├─ [3] Cache.BeforeAsync → Check cache (miss for create)
├─ Real CreateOrderAsync → Execute actual method
├─ [3] Cache.AfterAsync → Cache result
├─ [2] Time.AfterAsync → Stop stopwatch, record metric
├─ [1] Log.AfterAsync → Log "Order created: {orderId}"
└─ [0] Auth.AfterAsync → Audit log
Limitations
Version 1 Limitations
- ❌ Generic contracts not supported
- ❌ Nested types not supported
- ❌ Events not supported
- ⚠️ ref/out/in parameters are forwarded correctly to the inner implementation, but
outparameters appear asdefaultvalues in theBeforeMethodContext (actual assigned values are only observable after the method completes, but are not captured in the context) - ❌ Properties don't invoke interceptors (simple forwarding only)
Workarounds
Generic contracts:
// Instead of:
[GenerateProxy]
public interface IRepository<T> { } // ❌ Not supported
// Use:
[GenerateProxy]
public partial interface IUserRepository // ✅ Concrete type
{
User Get(Guid id);
}
Properties:
// To intercept property access, use methods:
[GenerateProxy]
public partial interface IUserService
{
// Instead of: string Name { get; set; }
string GetName();
void SetName(string name);
}
Best Practices
Use Pipeline mode for multiple concerns
[GenerateProxy(InterceptorMode = ProxyInterceptorMode.Pipeline)]Order interceptors carefully
- Authentication first
- Logging/auditing second
- Caching innermost (closest to real call)
Prefer
Rethrowexception policy- Swallow can hide bugs
- Only use when you have explicit recovery logic
Keep interceptors focused
- One concern per interceptor (SRP)
- Compose multiple interceptors rather than one complex interceptor
Use async throughout
- If any method is async, prefer async interceptor implementations
- Avoid blocking in async code paths
Test interceptors independently
var context = new GetUserMethodContext(userId); interceptor.Before(context); // Assert expected behaviorConsider performance
- Interceptors add overhead to every call
- Use
InterceptorMode.Noneif you don't need interception - Cache compiled expressions if building dynamic interceptors
Troubleshooting
"Type must be partial" (PKPRX001)
Problem: Interface/class is not marked partial.
Solution: Add partial keyword:
[GenerateProxy]
public partial interface IUserService { }
"Async member detected but async interception disabled" (PKPRX005)
Problem: Contract has async methods but GenerateAsync = false.
Solution: Remove the GenerateAsync property or set it to true.
Generated proxy not found
Problem: Proxy class doesn't appear in IntelliSense.
Solution:
- Rebuild the project (
dotnet build) - Ensure
PatternKit.Generatorspackage is referenced - Check for generator diagnostics in build output
- Verify type is
partial
Interceptor not being called
Problem: Interceptor methods aren't executing.
Solution:
- Ensure you pass interceptor to proxy constructor:
var proxy = new UserServiceProxy(inner, interceptor); - Verify
InterceptorModeis notNone - Check interceptor is not
null
Performance issues
Problem: Proxies are slow.
Solution:
- Profile your interceptors - they may be doing expensive work
- Consider using
InterceptorMode.Nonefor hot paths - Cache reflection-based operations in interceptors
- Use async methods properly (don't block)
See Also
- Decorator Generator - For wrapping objects with additional behavior
- Facade Generator - For simplifying complex subsystems
- Factory Generators - For object creation patterns
- Examples - Real-world usage examples
Feedback
Found an issue or have a feature request? Open an issue on GitHub.