Interceptor.Intercept<T> wraps any object behind its own interface and gives you three hooks: before the call, after the call, and when it throws. Your implementation is untouched.
This is local. It has nothing to do with hosts, clients or the wire — it works on plain objects inside one process. That also means you can apply it on the host side, the client side, or neither.
using ServiceWire.Aspects;
var cut = new CrossCuttingConcerns
{
PreInvoke = (id, methodName, parameters) => { /* ... */ },
PostInvoke = (id, methodName, results) => { /* ... */ },
ExceptionHandler = (id, methodName, parameters, ex) => true // true = rethrow
};
ISimpleMath math = Interceptor.Intercept<ISimpleMath>(new SimpleMath(), cut);
int sum = math.Add(3, 4); // hooks fire around the real Add| Hook | Signature | Called |
|---|---|---|
PreInvoke |
Action<int, string, object[]> |
Before the method, with the argument array |
PostInvoke |
Action<int, string, object[]> |
Always, in a finally |
ExceptionHandler |
Func<int, string, object[], Exception, bool> |
Only when the method throws |
All three are optional; leave one null and it is skipped.
The int is an instance id you choose, so one shared handler can tell several intercepted objects apart:
var primary = Interceptor.Intercept<IStore>(1, new Store("primary"), cut);
var secondary = Interceptor.Intercept<IStore>(2, new Store("secondary"), cut);This trips people up, so it is worth being precise. PostInvoke does not get the input arguments. It gets the result array:
results[0]— the return value (nullforvoid)results[1..n]— eachout/refparameter's final value, andnullfor input-only parameters
And because it runs in a finally, it also fires when the method threw:
- if the exception is being rethrown,
resultsis a single-element array holding the exception - if the handler swallowed it,
resultsis a single-element array holding the return type's default
Capture the arguments in PreInvoke if you need them afterwards.
var timings = new ConcurrentDictionary<string, Stopwatch>();
var cut = new CrossCuttingConcerns
{
PreInvoke = (id, method, args) =>
timings[$"{id}:{method}"] = Stopwatch.StartNew(),
PostInvoke = (id, method, results) =>
{
if (timings.TryRemove($"{id}:{method}", out var sw))
logger.LogInformation("{Method} took {Ms} ms", method, sw.ElapsedMilliseconds);
}
};For concurrent callers, key the dictionary by something per-call — a ThreadLocal<Stopwatch> or an AsyncLocal<T> — since id:method repeats across threads.
Wrap the implementation before handing it to the host, and every remote call is audited:
var cut = new CrossCuttingConcerns
{
PreInvoke = (id, method, args) =>
audit.Record(method, args.Length) // log shapes, not values, if args can be sensitive
};
IMath audited = Interceptor.Intercept<IMath>(new MathService(), cut);
using var host = new TcpHost(8098);
host.AddService<IMath>(audited); // clients see IMath; they cannot tell
host.Open();The client is unaware. It asked for IMath and got IMath.
Return false from ExceptionHandler and the exception is swallowed; the caller receives the return type's default value.
var cut = new CrossCuttingConcerns
{
ExceptionHandler = (id, method, args, ex) =>
{
logger.LogWarning(ex, "{Method} failed; returning default", method);
return false; // do not rethrow
}
};
IPricing safe = Interceptor.Intercept<IPricing>(new Pricing(), cut);
decimal price = safe.Quote(sku); // 0m if Quote threwUse this deliberately. A caller that cannot tell a real zero from a swallowed failure is a bug waiting to happen — prefer returning true and handling the exception at the call site unless the default is genuinely correct.
If the method is void, returning true still rethrows correctly; that path has explicit test coverage.
The proxy is just an object implementing your interface, so you can wrap it too:
using var client = new TcpClient<IMath>(endPoint);
IMath instrumented = Interceptor.Intercept<IMath>(client.Proxy, cut);
int sum = instrumented.Add(2, 3); // hooks fire around the whole round tripThis measures the call including network time, which is usually what you want on the client side. Host-side interception measures the method alone.
TTargetmust be an interface. Passing a class throwsArgumentException.targetmust not benull.- Interception adds one layer of indirection per call — a delegate invoke and a dictionary lookup, not a wire trip. Fine on anything but the very hottest loops.
- Disposing the intercepted proxy disposes the wrapped target if it implements
IDisposable.
Working examples: InterceptionTests.cs.
- Logging and diagnostics — the built-in instrumentation this complements
- Designing contracts — what the intercepted interface may contain