Open sandboxFocusImprove this doc

Generating interception proxies without reflection

An interception proxy is a class that implements an interface, forwards each call to another object that implements the same interface, and lets a third party observe or alter the call. It is the mechanism behind logging decorators, caching layers, remoting stubs and test doubles.

The .NET platform offers DispatchProxy, which builds the proxy class at run time. That approach requires run-time code generation, which is not available in a trimmed or ahead-of-time compiled application, and it packs every argument into an object[], which allocates an array and boxes value types.

This example generates the proxy class at compile time instead. The aspect reads the interface from the code model and introduces a new class that implements it. Each generated method packs its arguments into a ValueTuple, which is a value type, so no boxing occurs for value-type arguments.

The interception contracts

The generated proxy calls an interceptor through the following interface. The arguments are passed through a generic TArgs type parameter constrained to struct, ITuple, so the interceptor receives the argument tuple without boxing it.

1using System.Runtime.CompilerServices;
2
3namespace Metalama.Samples.Proxy;
4
5public interface IInterceptor
6{
7    TResult Invoke<TArgs, TResult>(
8        ref TArgs args,
9        InterceptionMetadata metadata,
10        InterceptorDelegate<TArgs, TResult> proceed ) where TArgs : struct, ITuple;
11
12    Task<TResult> InvokeAsync<TArgs, TResult>(
13        TArgs args,
14        InterceptionMetadata metadata,
15        Func<TArgs, Task<TResult>> proceed ) where TArgs : struct, ITuple;
16
17    ValueTask<TResult> InvokeAsync<TArgs, TResult>(
18        TArgs args,
19        InterceptionMetadata metadata,
20        Func<TArgs, ValueTask<TResult>> proceed ) where TArgs : struct, ITuple;
21}

The synchronous overload takes the argument tuple by reference, which is what allows an interceptor to modify out and ref arguments. The callback that invokes the intercepted object therefore also takes its argument by reference, so it needs a dedicated delegate type.

1using System.Runtime.CompilerServices;
2
3namespace Metalama.Samples.Proxy;
4
5public delegate TResult InterceptorDelegate<TArgs, out TResult>( ref TArgs args )
6    where TArgs : struct, ITuple;

Each intercepted method also passes metadata that identifies it. The proxy stores one metadata instance per method in a static field, so the reflection lookup happens once per type instead of once per call.

1using System.Reflection;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4
5namespace Metalama.Samples.Proxy;
6
7public sealed class InterceptionMetadata
8{
9    public MethodInfo Method { get; }
10
11    public bool ReturnsAwaitable { get; }
12
13    public InterceptionMetadata( MethodInfo method, bool returnsAwaitable )
14    {
15        this.Method = method;
16        this.ReturnsAwaitable = returnsAwaitable;
17    }
18}
19
20[CompileTime]
21internal sealed record InterceptionMetadataInfo( IMethod Method, IField MetadataField, bool ReturnsAwaitable );

Applying the aspect

This example proxies the following interface.

1namespace Metalama.Samples.Proxy;
2
3public interface ICalculator
4{
5    int Add( int a, int b );
6
7    void Store( string name, int value );
8
9    void Clear();
10}
11

The aspect is an assembly-level attribute. It takes the interface to proxy, the name of the class to generate, and optionally the namespace to generate it in.

1using Metalama.Samples.Proxy;
2
3[assembly: GenerateProxyAspect( typeof(ICalculator), "CalculatorProxy", "Proxies" )]
4

From that single attribute, the aspect generates the following class.

1using System.Reflection;
2using Metalama.Samples.Proxy;
3
4namespace Proxies
5{
6    public class CalculatorProxy : ICalculator
7    {
8        private ICalculator _intercepted;
9        private IInterceptor _interceptor;
10        private static InterceptionMetadata _metadata1;
11        private static InterceptionMetadata _metadata2;
12        private static InterceptionMetadata _metadata3;
13
14        static CalculatorProxy()
15        {
16            CalculatorProxy._metadata1 = new InterceptionMetadata(((MethodInfo)(typeof(ICalculator).GetMethod("Add", BindingFlags.Public | BindingFlags.Instance, null, new[] { typeof(int), typeof(int) }, null) ?? throw new MissingMethodException("The method 'ICalculator.Add(int, int)' could not be found using reflection."))), false);
17            CalculatorProxy._metadata2 = new InterceptionMetadata(((MethodInfo)(typeof(ICalculator).GetMethod("Store", BindingFlags.Public | BindingFlags.Instance, null, new[] { typeof(string), typeof(int) }, null) ?? throw new MissingMethodException("The method 'ICalculator.Store(string, int)' could not be found using reflection."))), false);
18            CalculatorProxy._metadata3 = new InterceptionMetadata(((MethodInfo)(typeof(ICalculator).GetMethod("Clear", BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null) ?? throw new MissingMethodException("The method 'ICalculator.Clear()' could not be found using reflection."))), false);
19        }
20
21        public CalculatorProxy(IInterceptor interceptor, ICalculator intercepted)
22        {
23            this._interceptor = interceptor;
24            this._intercepted = intercepted;
25        }
26        public int Add(int a, int b)
27        {
28            var args = (a, b);
29            return _interceptor.Invoke(ref args, _metadata1, Invoke);
30            int Invoke(ref (int a, int b) receivedArgs) { return this._intercepted.Add((((int a, int b))receivedArgs).a, (((int a, int b))receivedArgs).b); }
31        }
32        public void Clear()
33        {
34            var args = ValueTuple.Create();
35            _interceptor.Invoke(ref args, _metadata3, Invoke);
36            return;
37            ValueTuple Invoke(ref ValueTuple receivedArgs) { this._intercepted.Clear(); return default; }
38        }
39        public void Store(string name, int value)
40        {
41            var args = (name, value);
42            _interceptor.Invoke(ref args, _metadata2, Invoke);
43            return;
44            ValueTuple Invoke(ref (string name, int value) receivedArgs) { this._intercepted.Store((((string name, int value))receivedArgs).name, (((string name, int value))receivedArgs).value); return default; }
45        }
46    }
47}

Three details of the generated code are worth noting. The argument tuple is a local variable, so it lives on the stack. The callback that reaches the intercepted object is a local function, so it captures that local variable instead of allocating a closure object on every call. A method that takes no parameter gets an empty tuple, built by ValueTuple.Create().

Writing an interceptor

The aspect generates the plumbing, but the behaviour comes from the interceptor. The following one writes a line before and after each call.

1using System.Runtime.CompilerServices;
2
3namespace Metalama.Samples.Proxy;
4
5public sealed class LoggingInterceptor : IInterceptor
6{
7    private readonly TextWriter _writer;
8
9    public LoggingInterceptor( TextWriter writer )
10    {
11        this._writer = writer;
12    }
13
14    public TResult Invoke<TArgs, TResult>(
15        ref TArgs args,
16        InterceptionMetadata metadata,
17        InterceptorDelegate<TArgs, TResult> proceed )
18        where TArgs : struct, ITuple
19    {
20        this._writer.WriteLine( $"{Format( metadata, args )} started." );
21
22        try
23        {
24            var result = proceed( ref args );
25
26            this._writer.WriteLine( $"{Format( metadata, args )} returned {FormatResult( result )}." );
27
28            return result;
29        }
30        catch ( Exception e )
31        {
32            this._writer.WriteLine( $"{Format( metadata, args )} failed: {e.GetType().Name}." );
33
34            throw;
35        }
36    }
37
38    public async Task<TResult> InvokeAsync<TArgs, TResult>(
39        TArgs args,
40        InterceptionMetadata metadata,
41        Func<TArgs, Task<TResult>> proceed )
42        where TArgs : struct, ITuple
43    {
44        this._writer.WriteLine( $"{Format( metadata, args )} started." );
45
46        try
47        {
48            var result = await proceed( args );
49
50            this._writer.WriteLine( $"{Format( metadata, args )} returned {FormatResult( result )}." );
51
52            return result;
53        }
54        catch ( Exception e )
55        {
56            this._writer.WriteLine( $"{Format( metadata, args )} failed: {e.GetType().Name}." );
57
58            throw;
59        }
60    }
61
62    public async ValueTask<TResult> InvokeAsync<TArgs, TResult>(
63        TArgs args,
64        InterceptionMetadata metadata,
65        Func<TArgs, ValueTask<TResult>> proceed )
66        where TArgs : struct, ITuple
67    {
68        this._writer.WriteLine( $"{Format( metadata, args )} started." );
69
70        try
71        {
72            var result = await proceed( args );
73
74            this._writer.WriteLine( $"{Format( metadata, args )} returned {FormatResult( result )}." );
75
76            return result;
77        }
78        catch ( Exception e )
79        {
80            this._writer.WriteLine( $"{Format( metadata, args )} failed: {e.GetType().Name}." );
81
82            throw;
83        }
84    }
85
86    private static string Format<TArgs>( InterceptionMetadata metadata, TArgs args )
87        where TArgs : struct, ITuple
88    {
89        var arguments = new string[args.Length];
90
91        for ( var i = 0; i < args.Length; i++ )
92        {
93            arguments[i] = args[i]?.ToString() ?? "null";
94        }
95
96        return $"{metadata.Method.Name}({string.Join( ", ", arguments )})";
97    }
98
99    // A void method is projected to a ValueTuple result, which has no meaningful text representation.
100    private static string FormatResult<TResult>( TResult result )
101        => typeof(TResult) == typeof(ValueTuple) ? "void" : result?.ToString() ?? "null";
102}
103

The three methods receive the arguments as a tuple constrained to ITuple, which exposes Length and an indexer, so an interceptor can read the arguments without knowing the signature of the method it intercepts. The InterceptionMetadata parameter supplies the MethodInfo of the intercepted method.

A void method is projected onto a ValueTuple result, because the interceptor is written in terms of a return value. That is why FormatResult treats ValueTuple as a special case.

The generated proxy is then used like any hand-written class.

var calculator = new CalculatorProxy( new LoggingInterceptor( Console.Out ), new Calculator() );
var sum = calculator.Add( 2, 3 );

The project Metalama.Samples.Proxy.UnitTests executes the generated proxy and asserts this behaviour: that the return value flows back through the interceptor, that the arguments reach the intercepted object, that the interceptor observes the method name and the argument values, and that an exception thrown by the intercepted object is reported and then propagates.

Step 1. Introducing the proxy class

The aspect derives from CompilationAspect because the class it generates does not belong to any existing type. Its BuildAspect method resolves the interface, introduces the class and its fields, then introduces one method per interface method.

30public override void BuildAspect( IAspectBuilder<ICompilation> builder )
31{
32    base.BuildAspect( builder );
33
34    var interfaceType = this._interfaceType as INamedType ??
35                        TypeFactory.GetNamedType( (Type) this._interfaceType );
36
37    // Add a type.
38    var type = builder.WithNamespace( this._ns )
39        .IntroduceClass(
40            this._typeName,
41            buildType: t => t.Accessibility = Accessibility.Public );
42
43    // Add a field with the intercepted object.
44    var interceptedField = type.IntroduceField(
45            "_intercepted",
46            interfaceType,
47            IntroductionScope.Instance )
48        .Declaration;
49
50    // Add a field for the interceptor.
51    var interceptorField = type.IntroduceField( "_interceptor", typeof(IInterceptor) )
52        .Declaration;
53
54    // Implement the interface.
55    type.ImplementInterface( interfaceType );
56
57    // Implement interface members.
58    var methodIndex = 0;
59
60    var metadataList = new List<InterceptionMetadataInfo>();
61
62    foreach ( var method in interfaceType.Methods )
63    {
64        methodIndex++;
65
66        var argsType = TypeFactory.CreateTupleType( method.Parameters );
67        var asyncInfo = method.GetAsyncInfo();
68        var hasByRefParameter = method.Parameters.Any( p => p.RefKind != RefKind.None );
69
70        string templateName;
71        IType resultType;
72
73        if ( !asyncInfo.IsAwaitable || hasByRefParameter )
74        {
75            if ( method.ReturnType.Equals( SpecialType.Void ) )
76            {
77                templateName = nameof(this.VoidMethodTemplate);
78            }
79            else
80            {
81                templateName = nameof(this.NonVoidMethodTemplate);
82            }
83
84            resultType = method.ReturnType;
85        }
86        else
87        {
88            var returnType = (INamedType) method.ReturnType;
89
90            switch ( returnType.Definition.SpecialType )
91            {
92                case SpecialType.Task:
93                    templateName = nameof(this.TaskMethodTemplate);
94                    resultType = TypeFactory.GetType( SpecialType.Void );
95
96                    break;
97
98                case SpecialType.Task_T:
99                    templateName = nameof(this.TaskOfTMethodTemplate);
100                    resultType = returnType.TypeArguments[0];
101
102                    break;
103
104                case SpecialType.ValueTask:
105                    templateName = nameof(this.ValueTaskMethodTemplate);
106                    resultType = TypeFactory.GetType( SpecialType.Void );
107
108                    break;
109
110                case SpecialType.ValueTask_T:
111                    templateName = nameof(this.ValueTaskOfTMethodTemplate);
112                    resultType = returnType.TypeArguments[0];
113
114                    break;
115
116                default:
117                    builder.Diagnostics.Report(
118                        DiagnosticDefinitions.AwaitableTypeNotSupported.WithArguments(
119                            (
120                                interfaceType, method) ) );
121
122                    continue;
123            }
124        }
125
126        var metadataField =
127            type.IntroduceField(
128                    $"_metadata{methodIndex}",
129                    typeof(InterceptionMetadata),
130                    buildField: field => field.IsStatic = true )
131                .Declaration;
132
133        metadataList.Add( new InterceptionMetadataInfo( method, metadataField, asyncInfo.IsAwaitable ) );
134
135        type.IntroduceMethod(
136            templateName,
137            IntroductionScope.Instance,
138            buildMethod: methodBuilder =>
139            {
140                methodBuilder.Accessibility = Accessibility.Public;
141                methodBuilder.Name = method.Name;
142                methodBuilder.ReturnType = method.ReturnType;
143
144                foreach ( var parameter in method.Parameters )
145                {
146                    methodBuilder.AddParameter(
147                        parameter.Name,
148                        parameter.Type,
149                        parameter.RefKind );
150                }
151            },
152            args:
153            new
154            {
155                TArgs = argsType,
156                TResult = resultType,
157                method,
158                interceptedField,
159                interceptorField,
160                metadataField
161            } );
162    }
163
164    // Add the constructor.
165    type.IntroduceConstructor(
166        nameof(this.Constructor),
167        buildConstructor: constructorBuilder
168            => constructorBuilder.AddParameter( "intercepted", interfaceType ),
169        args: new { interceptedField, interceptorField } );
170
171    type.AddInitializer(
172        nameof(this.StaticConstructor),
173        InitializerKind.BeforeTypeConstructor,
174        args: new { metadataList } );
175}
176

The interface is stored in an object field because the aspect has two constructors. User code supplies a Type, while a fabric supplies an INamedType. BuildAspect accepts either and converts a Type with TypeFactory.GetNamedType.

builder.WithNamespace( ... ).IntroduceClass( ... ) introduces the class in the requested namespace, as explained in Introducing types, and type.ImplementInterface( interfaceType ) declares that the class implements the interface, as explained in Implementing interfaces.

For each method, TypeFactory.CreateTupleType( method.Parameters ) builds the tuple type that carries the arguments. The tuple element names and types come from the parameters, which is why the generated code reads receivedArgs.a instead of receivedArgs.Item1.

The template is then selected from the return type. There are six templates: two synchronous ones for void and non-void returns, and four asynchronous ones for Task, Task<TResult>, ValueTask and ValueTask<TResult>. The tuple type and the result type are passed to the template as compile-time type parameters, as explained in Template parameters and type parameters.

Step 2. Generating the method body

The following template generates the body of a synchronous method that returns a value. The other five templates follow the same shape.

245[Template]
246private TResult NonVoidMethodTemplate<[CompileTime] TArgs, [CompileTime] TResult>(
247    IMethod method,
248    IField interceptedField,
249    IField interceptorField,
250    IField metadataField )
251    where TArgs : struct, ITuple
252{
253    // Prepare the context.
254    var argsType = TypeFactory.CreateTupleType( method.Parameters );
255    var args = (TArgs) CreateTupleInstance( method, argsType ).Value!;
256    var argsExpression = ExpressionFactory.Capture( args );
257
258    // Get writable parameters.
259    var writableParameters = method.Parameters.Where( p =>
260                                                          p.RefKind is RefKind.Out or RefKind.Ref )
261        .ToList();
262
263    // Invoke the interceptor.
264    if ( writableParameters.Count == 0 )
265    {
266        // We don't need a try...finally if we don't have to write back writable parameters.
267        return ((IInterceptor) interceptorField.Value!).Invoke(
268            ref args,
269            (InterceptionMetadata) metadataField.Value!,
270            Invoke );
271    }
272    else
273    {
274        try
275        {
276            return ((IInterceptor) interceptorField.Value!).Invoke(
277                ref args,
278                (InterceptionMetadata) metadataField.Value!,
279                Invoke );
280        }
281        finally
282        {
283            // Copy back parameters.
284            foreach ( var parameter in writableParameters )
285            {
286                parameter.Value = argsType.TupleElements[parameter.Index].WithObject( argsExpression ).Value;
287            }
288        }
289    }
290
291    TResult Invoke( ref TArgs receivedArgs )
292    {
293        var receivedArgsExpression = ExpressionFactory.Parse( "receivedArgs" );
294
295        var arguments =
296            method.Parameters.Select( p => argsType.TupleElements[p.Index].WithObject( receivedArgsExpression ) );
297
298        return method.WithObject( interceptedField ).Invoke( arguments )!;
299    }
300}
301

The template mixes compile-time and run-time code, as explained in Writing compile-time code. The argsType, writableParameters and arguments variables are compile-time: they are evaluated while the aspect runs and leave no trace in the generated code. The call to interceptorField.Value and the local Invoke function are run-time code.

The local Invoke function reprojects the tuple elements onto the parameters of the intercepted method. ExpressionFactory.Parse( "receivedArgs" ) builds an expression that refers to the parameter of the generated local function, and argsType.TupleElements[p.Index].WithObject( ... ) builds the expression receivedArgs.<name> for each parameter.

Step 3. Writing back out and ref arguments

An out parameter cannot be read before it is assigned, so the argument tuple cannot simply copy it. The aspect substitutes a default value for every out parameter when it builds the tuple.

237private static IExpression CreateTupleInstance( IMethod method, ITupleType argsType )
238{
239    return argsType.CreateCreateInstanceExpression(
240        (IReadOnlyCollection<IExpression>) method.Parameters
241            .Select( p => p.RefKind != RefKind.Out ? p : ExpressionFactory.Default( p.Type ) )
242            .ToArray() );
243}
244

After the interceptor returns, the values that the interceptor may have changed must be copied from the tuple back into the parameters. Only out and ref parameters are copied back, because in and ref readonly parameters cannot be assigned. The copy happens in a finally block, so the values are written back even when the method throws.

The template omits the try and finally blocks entirely when the method has no out or ref parameter, so the common case costs nothing. The following interface, taken from the test suite of this example, exercises all four parameter modes.

1using Metalama.Samples.Proxy;
2using Metalama.Samples.Proxy.Tests.OutRefParameters;
3
4[assembly: GenerateProxyAspect( typeof(ISomeInterface), "SomeProxy", "Metalama.Samples.Proxy.Tests" )]
5
6namespace Metalama.Samples.Proxy.Tests.OutRefParameters;
7
8public interface ISomeInterface
9{
10    void VoidMethod( out int a, ref string b, in DateTime dt, ref readonly TimeSpan ts );
11
12    int NonVoidMethod( out int a, ref string b, in DateTime dt, ref readonly TimeSpan ts );
13}

Read the limitation below before relying on this part of the aspect.

Step 4. Initializing the proxy

The instance constructor stores the interceptor and the intercepted object.

539[Template]
540public void Constructor(
541    IInterceptor interceptor,
542    IField interceptedField,
543    IField interceptorField )
544{
545    interceptorField.Value = interceptor;
546    interceptedField.Value = meta.Target.Parameters["intercepted"].Value;
547}
548

The interceptor parameter is declared by the template, while the intercepted parameter is added by the buildConstructor delegate in BuildAspect. Parameters declared by the template come first in the generated signature, which is why the generated constructor reads CalculatorProxy( IInterceptor interceptor, ICalculator intercepted ).

The static constructor fills the metadata fields. It is introduced with AddInitializer and InitializerKind.BeforeTypeConstructor, so the code runs before any other static initialization of the class.

549[Template]
550private void StaticConstructor( List<InterceptionMetadataInfo> metadataList )
551{
552    foreach ( var item in metadataList )
553    {
554        item.MetadataField.Value =
555            new InterceptionMetadata( item.Method.ToMethodInfo(), item.ReturnsAwaitable );
556    }
557}

The foreach loop is compile-time: it runs while the aspect executes and produces one assignment per method. item.Method.ToMethodInfo() generates the reflection expression that resolves the method, which is the typeof(ICalculator).GetMethod( ... ) call visible in the generated class.

Applying the aspect from a fabric

Adding an assembly-level attribute for each interface is repetitive. A fabric can add the aspect to a set of interfaces instead, as explained in Adding many aspects simultaneously.

1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3using Metalama.Framework.Fabrics;
4
5namespace Metalama.Samples.Proxy;
6
7[CompileTime]
8public static class FabricExtensions
9{
10    public static void GenerateStaticProxy(
11        this IQuery<INamedType> receiver,
12        Func<INamedType, string>? getProxyTypeName,
13        Func<INamedType, string>? getProxyNamespace = null )
14    {
15        receiver.WithTag( type => type )
16            .Select( type => type.Compilation )
17            .AddAspect( ( _, type ) => new GenerateProxyAspect(
18                            type,
19                            getProxyTypeName?.Invoke( type ) ?? type.Name.Substring( 1 ) + "Proxy",
20                            getProxyNamespace?.Invoke( type ) ?? type.ContainingNamespace.FullName ) );
21    }
22
23    public static void GenerateStaticProxy(
24        this IQuery<INamedType> receiver,
25        string? proxyTypeName = null,
26        string? proxyNamespace = null )
27    {
28        receiver.WithTag( type => type )
29            .Select( type => type.Compilation )
30            .AddAspect( ( _, type ) => new GenerateProxyAspect(
31                            type,
32                            proxyTypeName ?? type.Name.Substring( 1 ) + "Proxy",
33                            proxyNamespace ?? type.ContainingNamespace.FullName ) );
34    }
35}

The aspect targets the compilation, but the fabric selects interfaces, so the query cannot add the aspect directly. WithTag stores the selected interface, Select( type => type.Compilation ) moves the query to the compilation, and the tag is read back in the AddAspect delegate to build the aspect instance.

A fabric then needs a single statement.

1using Metalama.Framework.Fabrics;
2
3namespace Metalama.Samples.Proxy.Tests.Fabric;
4



5public class Fabric : ProjectFabric
6{
7    public override void AmendProject( IProjectAmender amender )
8    {
9        amender.SelectReflectionType( typeof(ISomeInterface) ).GenerateStaticProxy();
10    }
11}



12
13public interface ISomeInterface
14{
15    void VoidMethod( int a, string b );
16
17    int NonVoidMethod( int a, string b );
18
19    void VoidNoParamMethod();
20}

When no name is given, the extension method removes the first character of the interface name and appends Proxy, so ISomeInterface yields SomeInterfaceProxy.

Reporting unsupported return types

The aspect can generate an asynchronous method only for Task, Task<TResult>, ValueTask and ValueTask<TResult>. For any other awaitable return type it reports an error and skips the method, as explained in Reporting and suppressing diagnostics.

1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3using Metalama.Framework.Diagnostics;
4
5namespace Metalama.Samples.Proxy;
6
7[CompileTime]
8internal static class DiagnosticDefinitions
9{
10    public static readonly DiagnosticDefinition<(INamedType, IMethod)> AwaitableTypeNotSupported = new(
11        "PROXY01",
12        Severity.Error,
13        "Cannot generate a proxy for '{0}' because the return type of method '{1}' is awaitable but is not a Task or ValueTask. ",
14        "Cannot generate a proxy for the type because it contains a method with awaitable return type that is not a Task or ValueTask." );
15}

Limitations

A method with an out or ref parameter does not compile. The aspect builds the copy-back code described in step 3, and that code is correct once it is formatted, but the syntax the aspect hands to the compiler is rejected with error CS1510, reported as LAMA0611. The aspect tests do not detect this, because src/metalamaTests.json sets FormatOutput, so the test harness compiles the formatted code. Until this is fixed, restrict the interface to by-value parameters.

The aspect implements the methods of the interface, and only its methods. An interface that declares a property, an indexer or an event is not supported.

When a method has an out or ref parameter, the aspect uses the synchronous templates even if the method returns a task. The interceptor then receives the task as an ordinary return value and cannot await it, although the metadata still reports the return type as awaitable. This follows from passing the argument tuple by reference, which an asynchronous method cannot do.

The default naming rule in FabricExtensions removes the first character of the type name unconditionally. It gives the expected result for an interface named according to the usual convention, and a truncated name for any other.