Skip to content

Interceptors and Proxies

ActualLab.Interception is a high-performance method interception library that powers Fusion's compute services, CommandR, and RPC.

Why does ActualLab.Interception exist? Fusion requires the ability to intercept method calls transparently:

  • Compute Services intercept [ComputeMethod] calls to cache and track dependencies
  • CommandR intercepts command handler methods to run them through its pipeline
  • RPC intercepts remote service calls to route them over the network

Rather than using reflection-based proxies (like Castle DynamicProxy), ActualLab uses compile-time source generation via ActualLab.Generators for better performance and AOT compatibility.

Key Features

  • Compile-time proxy generation: No runtime reflection or IL emission
  • AOT and trimming compatible: Works with NativeAOT and trimmed applications
  • High performance: 8x faster than Castle DynamicProxy in benchmarks
  • Simple API: Extend Interceptor and override one method
  • Typed and untyped handlers: Choose the right approach for your use case
  • Built-in interceptors: Scheduling, scoped services, typed factories

Required Packages

PackagePurpose
ActualLab.InterceptionCore interception: Interceptor, IProxy, Invocation
ActualLab.GeneratorsSource generator for proxy classes (compile-time)

TIP

If you're using Fusion, RPC, or CommandR, these packages are already included. You only need to reference them directly when building custom interception without Fusion.

How It Works

The interception system has three main components:

  1. Marker Interfaces (IRequiresAsyncProxy, IRequiresFullProxy) - Tag types that need proxies
  2. Source Generator (ActualLab.Generators) - Generates proxy classes at compile time
  3. Interceptor - Your custom logic that runs when proxy methods are called
Your Interface                  Generated Proxy                    Your Interceptor
┌─────────────────┐            ┌─────────────────────┐            ┌─────────────────┐
│ IMyService      │            │ MyServiceProxy      │            │ MyInterceptor   │
│ : IRequires...  │  ───────>  │ : IMyService        │  ───────>  │ : Interceptor   │
│                 │  generates │ : IProxy            │  delegates │                 │
│ Task<T> Foo()   │            │ Interceptor field   │    to      │ CreateHandler() │
└─────────────────┘            └─────────────────────┘            └─────────────────┘

Getting Started

1. Define an Interface with Proxy Marker

cs
// IRequiresAsyncProxy: generates proxy that intercepts async methods only
// IRequiresFullProxy: generates proxy that intercepts both sync and async methods
public interface IGreetingService : IRequiresAsyncProxy
{
    Task<string> GreetAsync(string name, CancellationToken cancellationToken = default);
}

2. Create an Interceptor

cs
public sealed class LoggingInterceptor : Interceptor
{
    // Options record is required - extend Interceptor.Options
    public new record Options : Interceptor.Options
    {
        public static Options Default { get; set; } = new();
    }

    public LoggingInterceptor(Options settings, IServiceProvider services)
        : base(settings, services)
    {
        // MustInterceptAsyncCalls = true; // Default
        // MustInterceptSyncCalls = false; // Default, set to true for IRequiresFullProxy
    }

    // Override this for typed handlers (type-safe, slightly more overhead)
    protected override Func<Invocation, object?>? CreateTypedHandler<TUnwrapped>(
        Invocation initialInvocation, MethodDef methodDef)
    {
        // TUnwrapped is the unwrapped return type (e.g., string for Task<string>)
        // Return null to skip interception (falls through to target or throws)
        if (methodDef.IsAsyncMethod) {
            // For async methods, use InterceptedAsyncInvoker which returns Task<TUnwrapped>
            var asyncInvoker = (Func<Invocation, Task<TUnwrapped>>)methodDef.InterceptedAsyncInvoker;
            return invocation => {
                Console.WriteLine($"Calling: {methodDef.FullName}");
                var resultTask = LogAndWrap(methodDef, asyncInvoker.Invoke(invocation));
                // Wrap result to proper return type (Task<T> or ValueTask<T>)
                return methodDef.WrapAsyncInvokerResultOfAsyncMethod(resultTask);
            };
        }
        // For sync methods
        return invocation => {
            Console.WriteLine($"Calling: {methodDef.FullName}");
            try {
                var result = invocation.InvokeInterceptedUntyped();
                Console.WriteLine($"Completed: {methodDef.FullName}");
                return result;
            }
            catch (Exception e) {
                Console.WriteLine($"Failed: {methodDef.FullName}: {e.Message}");
                throw;
            }
        };
    }

    private static async Task<TUnwrapped> LogAndWrap<TUnwrapped>(MethodDef methodDef, Task<TUnwrapped> task)
    {
        try {
            var result = await task.ConfigureAwait(false);
            Console.WriteLine($"Completed: {methodDef.FullName}");
            return result;
        }
        catch (Exception e) {
            Console.WriteLine($"Failed: {methodDef.FullName}: {e.Message}");
            throw;
        }
    }
}

3. Create and Use the Proxy

cs
var services = new ServiceCollection()
    .AddSingleton(LoggingInterceptor.Options.Default)
    .AddSingleton<LoggingInterceptor>()
    .BuildServiceProvider();

var interceptor = services.GetRequiredService<LoggingInterceptor>();
var realService = new GreetingService();

// Create a proxy - Proxies.New finds the generated proxy type automatically
var proxy = (IGreetingService)Proxies.New(
    typeof(IGreetingService),
    interceptor,
    proxyTarget: realService); // Pass-through to real implementation

// All calls now go through your interceptor
var greeting = await proxy.GreetAsync("World");

Core Concepts

Marker Interfaces

InterfaceDescription
IRequiresAsyncProxyGenerates proxy that intercepts async methods (Task, ValueTask)
IRequiresFullProxyExtends above; also intercepts synchronous methods

Use IRequiresAsyncProxy when you only need to intercept async methods (most common). Use IRequiresFullProxy when you also need to intercept synchronous methods.

The Invocation Struct

Invocation contains everything about the intercepted call:

cs
protected override Func<Invocation, object?>? CreateTypedHandler<TUnwrapped>(
    Invocation initialInvocation, MethodDef methodDef)
{
    // For async methods, use the appropriate invoker
    if (methodDef.IsAsyncMethod) {
        var asyncInvoker = (Func<Invocation, Task<TUnwrapped>>)methodDef.InterceptedAsyncInvoker;
        return invocation => {
            // Access invocation details
            var proxy = invocation.Proxy;           // The proxy instance
            var method = invocation.Method;         // MethodInfo being called
            var args = invocation.Arguments;        // ArgumentList with call arguments
            var target = invocation.InterfaceProxyTarget; // Target object (if pass-through proxy)

            // Get argument values
            var arg0 = args.Get<string>(0);         // First argument as string
            var arg1 = args.GetCancellationToken(1); // CancellationToken helper

            // Invoke the original/intercepted method and wrap result
            return methodDef.WrapAsyncInvokerResultOfAsyncMethod(asyncInvoker.Invoke(invocation));
        };
    }
    // For sync methods
    return invocation => {
        var proxy = invocation.Proxy;
        var method = invocation.Method;
        var args = invocation.Arguments;
        var target = invocation.InterfaceProxyTarget;
        return invocation.InvokeInterceptedUntyped();
    };
}

MethodDef - Method Metadata

MethodDef provides cached metadata about the intercepted method:

PropertyDescription
MethodInfoThe MethodInfo being intercepted
FullNameFull name like MyNamespace.IService.MethodName
ReturnTypeThe method's return type
UnwrappedReturnTypeInner type for Task<T> (e.g., T), or return type for sync methods
IsAsyncMethodWhether method returns Task or ValueTask
ReturnsTask / ReturnsValueTaskSpecific async return type
CancellationTokenIndexIndex of CancellationToken parameter, or -1
DefaultResultDefault return value (completed task for async methods)
ParametersParameterInfo[] array

Typed vs Untyped Handlers

Typed handlers (CreateTypedHandler<TUnwrapped>) are generic over the unwrapped return type:

  • Type-safe access to return values
  • Slightly more overhead due to generic instantiation
  • Best for most use cases

Untyped handlers (CreateUntypedHandler) work with object?:

  • Set UsesUntypedHandlers = true in constructor
  • No generic instantiation overhead
  • Used by ComputeServiceInterceptor for maximum performance

Built-in Interceptors

SchedulingInterceptor

Schedules async method execution on a custom TaskFactory:

cs
var interceptor = new SchedulingInterceptor(SchedulingInterceptor.Options.Default, services) {
    // Resolve TaskFactory per invocation
    TaskFactoryResolver = invocation => {
        // Return null to skip scheduling (run on current context)
        // Return a TaskFactory to schedule on its scheduler
        return myCustomTaskFactory;
    },
    // Optional: chain to another interceptor
    NextInterceptor = anotherInterceptor
};

ScopedServiceInterceptor

Creates a new IServiceScope for each method call:

cs
var interceptor = new ScopedServiceInterceptor(ScopedServiceInterceptor.Options.Default, services) {
    ScopedServiceType = typeof(IGreetingService),
    // MustInterceptSyncCalls = true, // Requires IRequiresFullProxy interface
};

// Each call to the proxy will:
// 1. Create a new IServiceScope
// 2. Resolve IGreetingService from that scope
// 3. Invoke the method on the resolved service
// 4. Dispose the scope when the call completes
var proxy = (IGreetingService)Proxies.New(typeof(IGreetingService), interceptor);

TypedFactoryInterceptor

Creates instances via ActivatorUtilities.CreateFactory:

cs
// Returns new instances for each sync method call
// Useful for factory interfaces
var interceptor = new TypedFactoryInterceptor(TypedFactoryInterceptor.Options.Default, services);

Creating Pass-Through Proxies

Pass-through proxies delegate to an actual implementation while intercepting calls:

cs
// Create a real implementation
var realService = new GreetingService();

// Create proxy that passes through to the real service
var proxy = (IGreetingService)Proxies.New(
    typeof(IGreetingService),
    interceptor,
    proxyTarget: realService  // Calls will delegate to this
);

// Now calls go: proxy -> interceptor -> realService
var result = await proxy.GreetAsync("World");

Interceptor Options

All interceptors use an Options record for configuration:

cs
public new record Options : Interceptor.Options
{
    // Default instance pattern
    public static Options Default { get; set; } = new();

    // Inherited from Interceptor.Options:
    // - HandlerCacheConcurrencyLevel: Concurrency for handler cache
    // - HandlerCacheCapacity: Initial capacity
    // - LogLevel: Logging level for debug messages
    // - ValidationLogLevel: Logging level for validation
    // - IsValidationEnabled: Enable/disable validation

    // Add your custom settings here
    public string CustomSetting { get; init; } = "default";
}

Interceptor Properties

Key properties you can set in your interceptor constructor:

PropertyDefaultDescription
MustInterceptAsyncCallstrueIntercept async methods
MustInterceptSyncCallsfalseIntercept sync methods
MustValidateProxyTypetrueValidate proxy implements correct interface
UsesUntypedHandlersfalseUse untyped handlers instead of typed

Validation

Override ValidateTypeInternal to validate types when intercepted:

cs
protected override void ValidateTypeInternal(Type type)
{
    // Called once per type, results are cached
    foreach (var method in type.GetMethods()) {
        if (method.GetCustomAttribute<MyRequiredAttribute>() is null)
            throw new InvalidOperationException($"Method {method.Name} missing [MyRequired]");
    }
}

Learn More

Fusion's Use of Interceptors

Fusion builds on this interception system:

  • ComputeServiceInterceptor - Powers [ComputeMethod] caching and dependency tracking
  • CommandServiceInterceptor - Routes command handler calls through CommandR pipeline
  • RpcInterceptor - Handles remote procedure calls via ActualLab.Rpc
  • RemoteComputeServiceInterceptor - Combines compute and RPC interception for distributed scenarios

These interceptors demonstrate advanced patterns like handler chaining, custom MethodDef subclasses, and untyped handlers for maximum performance.