I'm not a big fan of forcing design patterns everywhere just for the sake of it, but they do exist for a reason. Today, I'm going to explain and show you the practical usage of one of them - the Proxy design pattern, specifically using it for method execution auditing. We'll see how it is implemented in C#, briefly explain how it's different than Decorator pattern, its limitations, and when you might need to switch to a more powerful tool, like Castle.DynamicProxy
. We'll also see a practical implementation of a logging proxy. But first, let's take a step back and understand the purpose of design patterns.
What's a Design Pattern?
Design patterns are tried and tested solutions to common problems in software design. They represent best practices used by experienced developers, and using them can help prevent minor issues that could cause major problems down the line. The Proxy design pattern is no exception.
Proxy Design Pattern Explained
The Proxy design pattern is about creating a stand-in or surrogate for an object to control access to it. In a nutshell, a proxy object is an entity that represents another object. This intermediary can control, monitor, or modify the interaction between the client and the actual object. This pattern is particularly handy when you want to add a layer of abstraction or control over an object. The proxy can handle additional responsibilities like security, logging, transaction handling, and more.
It can sometimes be confused with the Decorator pattern, but the Proxy pattern has its distinct identity. Unlike Proxy, the Decorator's purpose is to add or alter behavior that's directly related to what the function does.
DispatchProxy in Action
System.Reflection.DispatchProxy
is a built-in class in C# that enables the creation of lightweight dynamic proxies. It's a simplified version of the Proxy design pattern that's built into the .NET runtime and doesn't require a third-party library. It is useful for scenarios where we want to add behaviors like logging or performance monitoring to our methods without modifying their code.
Let's illustrate this with a practical example. We'll create a logging proxy that logs the start and end of each method execution, including its parameters and return value, for any given interface T
. Here's how it looks:
public class LoggingProxy<T> : DispatchProxy
where T : class
{
private T _target;
private ILogger<T> _logger;
public static T Create(T target, ILogger<T> logger)
{
object proxy = Create<T, LoggingProxy<T>>();
((LoggingProxy<T>)proxy).SetTarget(target, logger);
return (T)proxy;
}
protected override object Invoke(MethodInfo targetMethod, object[] args)
{
var parameters = args
.Select(arg => arg == null ? "null" : JsonSerializer.Serialize(arg))
.ToArray();
_logger.LogInformation(
"Executing {MethodOwner}.{MethodName}({MethodParameters})",
_target.GetType().Name,
targetMethod.Name,
string.Join(", ", parameters));
object result = targetMethod.Invoke(_target, args);
_logger.LogInformation(
"{MethodOwner}.{MethodName} returned {MethodResult}",
_target.GetType().Name,
targetMethod.Name,
result == null ? "null" : JsonSerializer.Serialize(result));
return result;
}
private void SetTarget(T target, ILogger<T> logger)
{
_target = target;
_logger = logger;
}
}
This LoggingProxy
is then registered in our application startup using Microsoft's Dependency Injection:
builder.Services.AddScoped(provider =>
{
var userService = ActivatorUtilities.CreateInstance<UserService>(provider);
var logger = provider.GetRequiredService<ILogger<IUserService>>();
return LoggingProxy<IUserService>.Create(userService, logger);
});
This code instructs the DI container to create a new instance of UserService
and ILogger<IUserService>
for each request and to wrap the UserService
with our LoggingProxy
. Now, each time an HTTP GET request is made to /users/{id}
, the LoggingProxy
intercepts the call to the GetById
method, and logs start, parameters, end, and result of the execution:
info: IUserService[0]
Executing UserService.GetById(1)
info: IUserService[0]
UserService.GetById returned {"Id":1,"Name":"Mario Smolcic","Website":"www.codecrafting.tips"}
Proxy Pattern Beyond Logging
The Proxy pattern extends beyond just logging. Mock libraries, such as Moq
and NSubstitute
, are practical applications of the Proxy design pattern. They generate 'mock' objects that mimic the behavior of real objects. The generated mock objects are essentially proxy objects. They implement the same interface or extend the same base class as the object being mocked, and they control the access to the object, just like a proxy.
These libraries use the Proxy design pattern to create these mock objects dynamically at runtime. Under the hood, they use another implementation of Proxy, the Castle.DynamicProxy
, which offers more flexibility and power than the built-in DispatchProxy
.
Limitations of DispatchProxy and When to Use Castle.DynamicProxy
Despite its usefulness and simplicity, DispatchProxy
has its limitations. It only supports interface proxying, which means that classes or methods that aren't part of an interface can't be intercepted. Also, you cannot intercept properties, non-public members, or non-virtual members.
If you need more control and flexibility, Castle.DynamicProxy
is a powerful alternative. Unlike DispatchProxy
, it can intercept calls to classes, intercept property calls, and create proxies without needing the target instance upfront. This makes it a great choice for advanced scenarios, such as lazy loading and more complex testing scenarios.
For instance, with Castle.DynamicProxy
, you can intercept a method call and decide not to call the target method at all, change the arguments before the call, change the return value, or even throw an exception. You can also intercept calls to properties, not just methods.
Conclusion
Whether you use DispatchProxy
for its simplicity and tight integration with the .NET runtime or Castle.DynamicProxy
for its advanced features, the Proxy design pattern is a powerful tool for separating concerns and adding behaviors to objects without modifying their code. As we've seen, it's widely used in various applications, from logging and access control to lazy loading and testing. With its use, we can write cleaner, more maintainable code.