DispatchProxy can centralize logging, timing, and policy checks at an interface boundary, but it is not merely a convenient before-and-after hook. A proxy must preserve invocation count, return types, asynchronous completion, cancellation, and exception behavior. If any of those observable semantics change, the cross-cutting layer can damage the operation it was intended to observe. This guide defines a reproducible baseline for .NET 9 without relying on private environments or unsupported performance claims.

The correctness contract for a DispatchProxy interceptor

A correct .NET 9 DispatchProxy interceptor invokes the target exactly once, returns synchronous values or tasks without blocking, and exposes the original failure type with a useful stack. Logging is secondary to those invariants and must not alter them.

That concise rule eliminates several deceptively attractive implementations. A generic interceptor must not retry because a branch wants another result, wait synchronously because completion is easier to log, return an exception as ordinary data, or serialize every argument and result. Retry, timeout, and fallback policies require explicit semantics and idempotency analysis; they should not appear accidentally inside a universal forwarding layer.

DispatchProxy.Create() creates a runtime object that implements interface T, while TProxy derives from DispatchProxy and overrides Invoke. The isolated test also confirmed an important construction constraint: the proxy base class cannot be sealed, because the runtime generates a derived type. A sealed proxy fails during creation rather than during the first business method call.

Four invariants to establish before adding telemetry

Start with four non-negotiable invariants: no extra invocation, no return-contract substitution, no synchronous wait on an asynchronous operation, and no conversion of a failure into ordinary data. These invariants take priority over log convenience.

The first invariant is invoke-once behavior. targetMethod.Invoke(Target, args) must occur once on every reachable path. Store or return that result; never invoke again merely to distinguish Task from Task, inspect a value, or produce a second log event. A second invocation is not observation. It is another operation, and its consequences depend on the target.

The second invariant is return and completion fidelity. If the interface returns an integer, return that integer. If it returns Task or Task, return a compatible task and let the caller await it. Calling Wait(), .Result, or GetAwaiter().GetResult() turns an asynchronous contract into a blocking one. It can also alter exception presentation and consume a thread while the operation remains incomplete.

The third invariant is failure fidelity. A synchronous target exception raised through reflection arrives wrapped by TargetInvocationException. The proxy should extract the non-null inner exception and rethrow it through ExceptionDispatchInfo, allowing the caller to observe the intended exception type while retaining diagnostic stack information. Returning the exception object violates the declared return type and bypasses normal error handling.

The fourth invariant is data minimization. Telemetry should record a stable method identifier, status, elapsed interval, and perhaps an approved item count. It should not serialize arbitrary arguments, full result collections, or object graphs. Minimization reduces exposure and prevents observation code from traversing expensive graphs, triggering lazy behavior, or failing on cycles.

A minimal invoke-once implementation

The baseline implementation is intentionally small: validate method metadata, invoke exactly once, return the original result, and unwrap only synchronous reflection failures. It adds no implicit retry, blocking wait, or object-graph serialization.

The following code is suitable for an isolated .NET 9 test project. The proxy type is deliberately not sealed. A composition root assigns Target after creating the generated proxy. A larger application should additionally constrain eligible interfaces, apply nullable annotations deliberately, and delegate approved telemetry to a separate component.

using System.Reflection;
using System.Runtime.ExceptionServices;

public class OnceProxy : DispatchProxy where T : class
{
    public T Target { get; set; } = default!;

    protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
    {
        ArgumentNullException.ThrowIfNull(targetMethod);
        try
        {
            return targetMethod.Invoke(Target, args); // exactly once
        }
        catch (TargetInvocationException ex) when (ex.InnerException is not null)
        {
            ExceptionDispatchInfo.Capture(ex.InnerException).Throw();
            throw; // reachability for the compiler; Throw does not return
        }
    }
}

For a synchronous return, reflection produces the value before Invoke returns. For Task and Task, reflection returns the task created by the target method, and the caller observes its eventual completion with await. An exception raised after an asynchronous suspension is normally stored in that returned task; it is not necessarily thrown by MethodInfo.Invoke. Therefore, the synchronous TargetInvocationException catch does not replace tests for asynchronously faulted tasks.

If final task status must be logged, introduce explicit, typed wrappers for Task and Task. Prove that each wrapper preserves success values, original failure types, and cancellation. Do not use a single loosely typed reflection branch to guess generic results, and do not gain a convenient completion point by blocking. Contract preservation comes first; richer observation follows only after tests establish equivalence.

Why Task, Task, and synchronous methods need separate tests

All return paths share invoke-once behavior, but completion and failure are observed at different locations. Treating every method as a synchronous reflection call is the shortest route to blocking, misleading timing, and duplicate execution.

A synchronous method has completed when Invoke returns, so a before-and-after interval represents the target call. If it fails, reflection wraps the target exception. A task-returning method may execute only to its first asynchronous boundary before returning a task. Timing only the reflection call measures task acquisition, not the complete asynchronous operation.

Task also carries a result type. Transparent forwarding does not require reading Result; returning the original task is enough. A typed observation wrapper may await the original task, record only an approved summary, and return the same T value. That wrapper creates a different task object, so tests must compare visible semantics rather than object identity: success value, failure, cancellation, and completion behavior.

A synchronous void method has no return object but still follows the same exception rules. An async void service method, however, exposes no awaitable completion contract and should generally be excluded from service interfaces. Prefer Task, which gives callers and tests a composable way to observe completion and failure.

Cancellation deserves its own case. A canceled task should remain canceled and surface OperationCanceledException when awaited according to normal task semantics. A wrapper must not translate cancellation into success, a generic failure, or an empty result. Even if the minimal proxy simply returns the original task, a cancellation test protects future telemetry changes.

Exception unwrapping and stack preservation

The objective of exception handling in a reflection proxy is not to make the log path look successful. It is to let the original failure continue with its intended type and enough stack information to identify the target method.

One damaging pattern is catch (Exception ex) { return ex; }. It violates the declared result contract, prevents ordinary caller-side exception handling, and may cause telemetry to classify a failed operation as a successful return. For value-returning interfaces, the generated proxy may then produce a secondary cast error that hides the original problem.

Another problematic pattern is throw ex;, which resets the apparent throw location. Within a catch that rethrows the same exception, bare throw; is appropriate. When unwrapping TargetInvocationException.InnerException, ExceptionDispatchInfo.Capture(inner).Throw() is the explicit mechanism for preserving captured stack information. The unreachable throw after it merely satisfies compiler flow analysis.

A robust assertion checks more than message text. Verify that the caller receives InvalidOperationException, not TargetInvocationException, and that the stack identifies the target path. A fixed message marker is useful for a deterministic test, but it does not replace type and stack assertions. For asynchronous faults, put the assertion around await, because failure may be recorded after Invoke has already returned.

The proxy should also avoid broad catch blocks that continue normally. If telemetry itself fails, the intended policy must be explicit: fail the call, suppress only the telemetry error through a narrowly scoped boundary, or use a separately tested fallback. A generic catch that returns a default value makes operational diagnosis almost impossible and silently rewrites the interface contract.

Telemetry minimization and performance boundaries

An interceptor sits on a shared, potentially frequent path, so every extra allocation, traversal, and serialization operation is multiplied across proxied calls. The safest default is an allowlist of low-cardinality summaries, not broad capture followed by attempted cleanup.

Reasonable fields include a stable operation name, success or failure status, elapsed interval, and an approved collection count. Avoid full request objects, complete response collections, free-form content, and arbitrary ToString() output. Parameter names are not a reliable sensitivity classifier, so an explicit allowlist is stronger than a growing denylist.

This article makes no benchmark claim and invents no latency percentage. Performance must be measured for a fixed SDK, method shape, data shape, warm-up policy, and observation configuration. Results from one workload should not be generalized to another. The correctness gates are simpler and universal: telemetry must not invoke the target again, synchronously wait for a task, or traverse the complete result merely to create a log field.

If measurement is required, use a separate benchmark project and compare direct interface calls with proxied calls under the same conditions. Publish the method, runtime version, build configuration, sample policy, and allocation results. Do not mix network or database variability into a microbenchmark intended to isolate proxy overhead. Correctness tests and benchmarks answer different questions and should remain separate.

Verification and reproduction with .NET SDK 9.0.305

The isolated test was executed in the Windows scheduler environment with .NET SDK 9.0.305. It used no external data source and performed no irreversible operation. Invoke-once behavior, return values, and synchronous exception unwrapping all passed.

Prerequisites are .NET SDK 9.0.305 and an empty local folder. A global.json pins version 9.0.305 with rollForward set to disable, and the project targets net9.0. Reproduce the checks in this order:

  1. Create a net9.0 console project containing IProbe, a counting Probe, and the OnceProxy implementation above; ensure the proxy class is not sealed.
  2. Call a synchronous method, a Task method, and a Task method once each. Assert that the counter equals three and that the fixed return values remain seven and nine.
  3. Call a synchronous method that throws a fixed InvalidOperationException. Assert that the caller observes that type and marker, then assert that the final counter equals four.
  4. Run the complete commands below. Treat any unhandled exception, nonzero exit code, wrong counter, or missing PASS line as a failure.
dotnet --version
dotnet run --configuration Release

The actual scheduler run reported SDK 9.0.305, followed by PASS once-and-return: calls=3 sync=7 asyncValue=9, PASS exception-unwrapped: InvalidOperationException expected-marker, and PASS final-call-count: calls=4. The process exited with code zero. No benchmark, success rate, or latency figure was generated.

The first test run also produced a real failure: declaring the proxy class sealed caused DispatchProxy.Create to throw ArgumentException because the runtime proxy base type cannot be sealed. Removing that modifier allowed the verification to pass. Recording this failed attempt matters because it distinguishes an API constraint discovered by execution from a stylistic recommendation.

For a fuller regression suite, add an asynchronously faulted Task, a canceled task, a Task observation wrapper if one exists, and a telemetry failure case. Those reader-reproducible extensions were not executed in this scheduler run. Their pass conditions should remain explicit: original failure type after await, canceled status preserved, result value unchanged, and exactly one target invocation.

Review checklist for a production-quality design

Review the proxy by tracing observable semantics before reviewing log formatting. The goal is not merely to prove that interception occurs; it is to prove that adding interception does not unexpectedly rewrite behavior.

  • Search for every targetMethod.Invoke call. Exactly one may execute on each reachable path.
  • Search for .Wait(), .Result, and GetAwaiter().GetResult(). A general asynchronous interception path should not block.
  • Assert that synchronous reflection exceptions are unwrapped to their original type and retain a target-relevant stack.
  • Test synchronous values, void, Task, Task, asynchronous faults, and cancellation separately.
  • Use an allowlist for telemetry fields and record summaries rather than complete object graphs.
  • If task wrappers are introduced, verify success, failure, cancellation, and generic values without relying on task identity.
  • Pin the SDK and state observable pass and fail conditions so runtime upgrades can be checked rather than assumed.
  • Keep retry and fallback out of the generic proxy unless their semantics, limits, and idempotency requirements are explicit.

A code review should also inspect proxy creation. Confirm the requested type is an interface, the proxy type derives from DispatchProxy, and the proxy class is not sealed. Ensure the target is assigned before the generated object is exposed. These construction checks catch failures earlier than an application-level smoke test.

Finally, treat telemetry as a dependency with its own failure modes. A slow exporter or serialization failure must not lead to a second target invocation. If the chosen policy suppresses telemetry failures, isolate that suppression around the telemetry call alone, not around target execution. This keeps diagnostic concerns from swallowing application failures.

Conclusion

A reliable DispatchProxy is a transparent boundary, not a second execution engine. Invoke once, forward tasks without synchronous waits, unwrap synchronous reflection failures correctly, and minimize telemetry. Each rule can be demonstrated in a small local project.

Begin with counter-based and fixed-exception regression tests, then add logging or policy features one at a time. Re-run return, completion, failure, and cancellation checks after every change. This discipline keeps cross-cutting behavior observable and removable while preventing the proxy from becoming a shared source of hard-to-localize defects.