A quick example: retrying calls to a third-party service
ShippingClient is a class of a vendor SDK. Its methods sometimes throw a TimeoutException, and the application calls them from many places:
public class ShipmentService
{
private readonly ShippingClient _client = new();
public decimal EstimateCost( string destination ) => this._client.GetQuote( destination );
public Task<string> GetStatusAsync( string trackingNumber )
=> this._client.GetTrackingStatusAsync( trackingNumber );
public string Ship( string destination ) => this._client.CreateShipment( destination );
}
The following project fabric retries the calls to the methods of ShippingClient whose name starts with Get:
internal class Fabric : ProjectFabric
{
public override void AmendProject( IProjectAmender amender )
=> amender.InterceptMethods(
calls => calls
.Type( typeof(ShippingClient) )
.AllMethods()
.Where( m => m.Name.StartsWith( "Get" ) )
.RedirectToSynthesizedMethod( nameof(this.Retry) )
.WithArgs( new { maxAttempts = 3 } ) );
[Template]
public dynamic? Retry( [CompileTime] int maxAttempts )
{
for ( var attempt = 1;; attempt++ )
{
try
{
return meta.Proceed();
}
catch ( TimeoutException e ) when ( attempt < maxAttempts )
{
Console.WriteLine( $"Attempt {attempt} failed: {e.Message} Retrying." );
}
}
}
}
Retry is a T# template: C# code in which meta.Proceed() stands for the original call. From this template, Metalama generates one interceptor per intercepted method:
internal static class MetalamaInterceptors
{
internal static decimal ShippingClient_GetQuote_Interceptor( ShippingClient receiver, string destination )
{
for ( var attempt = 1;; attempt++ )
{
try
{
return receiver.GetQuote( destination );
}
catch ( TimeoutException e ) when ( attempt < 3 )
{
Console.WriteLine( $"Attempt {attempt} failed: {e.Message} Retrying." );
}
}
}
internal static async Task<string> ShippingClient_GetTrackingStatusAsync_Interceptor( ShippingClient receiver, string trackingNumber )
{
for ( var attempt = 1;; attempt++ )
{
try
{
return await receiver.GetTrackingStatusAsync( trackingNumber );
}
catch ( TimeoutException e ) when ( attempt < 3 )
{
Console.WriteLine( $"Attempt {attempt} failed: {e.Message} Retrying." );
}
}
}
}
The source code of ShipmentService is unchanged; its calls to GetQuote and GetTrackingStatusAsync are routed to these methods. The compile-time parameter maxAttempts became the constant 3, and the interceptor of the async method awaits the call, so the retry covers the whole operation. CreateShipment is not intercepted, because retrying an operation that is not idempotent could create the shipment twice.
Compared with C# interceptors and Roslyn source generators
C# has its own interceptors: a method marked with [InterceptsLocation] replaces the call at one location in the source code, and a Roslyn source generator writes these methods. Metalama interceptors are unrelated to this feature, although both rewrite calls at build time.
Compared with C# interceptors, Metalama interceptors are:
- Much simpler. No low-level Roslyn code generator is needed to walk syntax trees, query the semantic model and emit interceptors as C# text. Metalama locates the call sites and generates the interceptors, as in the retry example above.
- No additional project. A source generator must live in its own project, referenced as an analyzer. The fabric and the interceptors can live in the same project as the business code.
- Template-based. The interceptor is a T# template: C# that looks like the code it generates, with
meta.Proceed()for the original call. One template covers every signature, including async and generic methods. - Able to intercept delegates. Method groups converted to delegates, such as
items.Select( Normalize ), and event handlers can be intercepted, in addition to calls.
Each approach has its use cases:
| Use case | Better tool |
|---|---|
| Retrying, timing or auditing the calls to an API | Metalama |
| Tracing calls temporarily, or only in some builds | Metalama |
Adding test seams to legacy code, such as faking Thread.Sleep or the file system | Metalama |
| Moving PostSharp call-site interception to Metalama | Metalama |
| Generating optimized code from the arguments of each call, such as a lambda, a query or a SQL string, as Dapper.AOT and EF Core precompiled queries do | Roslyn source generator |
| A library that ships interceptors to its users without adding a dependency | Roslyn source generator |
Features
- Hand-written or template-based interceptors. Calls can be redirected to an existing static method, which can be debugged and unit-tested like any C# code, or to interceptors generated from a T# template, one for each intercepted signature.
- Generic interceptors. A generic template covers methods of the same shape whose parameters have different types, such as
SaveOrder( Order order )andSaveCustomer( Customer customer ). Calls to generic methods, such asSerialize<T>( T value ), are handled either by one interceptor per type argument or by one shared generic interceptor. - Call-site context. An interceptor can receive the calling member, the calling object, a parameter of the calling method, and the caller information attributes (
[CallerMemberName],[CallerFilePath],[CallerLineNumber]) resolved to the intercepted call. - Additional parameter values.
WithArgsassigns values to the parameters of the interceptor that the intercepted method does not have: constants such as literals, enumeration values and types, or values computed for each call site. For templates, it also sets the compile-time parameters. - Async and iterator methods. The interceptor of an awaitable method is
asyncand awaits the call; the interceptor of an async iterator is an async iterator. - Delegates and event handlers. Method groups converted to delegates and event subscriptions are intercepted along with calls, and can be filtered by kind of use.
- Interface and virtual calls. Calls bound to overrides of the selected methods are intercepted, and calls bound to implementations of interface methods can be included.
- Flexible scope. A registration applies to the target type of an aspect, a namespace, a project, the types that a query selects, some build configurations only, or every project that references a library.
- Configurable placement. Interceptors are generated in a shared static class, in a class of your choice, or in the calling type, where they can access its private members and the protected methods of its base classes.
Redirecting calls to a hand-written method
The simplest interceptor is a hand-written static method. Being plain C#, it can be debugged and unit-tested like any other code.
The following method audits the deletion of files. The [ExcludeInterceptors] attribute prevents its own call to File.Delete from being intercepted, which would cause infinite recursion:
[ExcludeInterceptors]
public static class Audit
{
public static void DeleteFile(
string path,
string category,
[CallerMemberName] string caller = "",
[CallerLineNumber] int line = 0 )
{
Console.WriteLine(
$"[{category}] {caller} (line {line}) deletes {Path.GetFileName( path )}." );
File.Delete( path );
}
}
A project fabric redirects every call to File.Delete in the project to this method, and passes a constant value for category:
internal class Fabric : ProjectFabric
{
public override void AmendProject( IProjectAmender amender )
{
var interceptor = typeof(Audit).AsINamedType()
.Methods.OfName( nameof(Audit.DeleteFile) )
.Single();
amender.InterceptMethods(
calls => calls
.Type( typeof(File) )
.Methods( nameof(File.Delete) )
.RedirectToExistingMethod( interceptor )
.WithArgs( new { category = "FileSystem" } ) );
}
}
The calls in the business code are rewritten at build time:
public class ReportCleaner
{
public void DeleteReport( string directory, string name )
=> Audit.DeleteFile( path: Path.Combine( directory, name + ".pdf" ), category: "FileSystem", caller: "DeleteReport", line: 10 );
public void DeleteDraft( string directory, string name )
=> Audit.DeleteFile( path: Path.Combine( directory, name + ".draft" ), category: "FileSystem", caller: "DeleteDraft", line: 13 );
}
Each parameter of the interceptor is bound to a value of the call site. For instance methods, the first parameter receives the receiver of the call. The remaining parameters are matched by name rather than position. Parameters with a caller information attribute, such as [CallerMemberName], receive the location of the intercepted call.
Generating interceptors from a template
Beyond meta.Proceed(), templates can inspect the interception they implement through meta.Interception. For instance, the following line prints the name of the intercepted method:
Console.WriteLine( $"Calling {meta.Interception.Method.InterceptedMethod.Name}." );
By default, call sites that need the same code share one generated interceptor in an internal class named MetalamaInterceptors. A template that depends on the caller must declare it with WithGranularity: the Member granularity exposes the calling member, and the CallSite granularity the call site itself. A finer granularity generates more methods.
The location of the generated interceptor is also configurable. With PlaceInCallingType, it becomes a private method of the calling type, which does not need to be partial. The template can then access the private members of the caller, such as its logger:
public class LogPaymentCallsAttribute : TypeAspect
{
public override void BuildAspect( IAspectBuilder<INamedType> builder )
=> builder.InterceptMethods(
calls => calls
.Type( typeof(PaymentGateway) )
.AllMethods()
.RedirectToSynthesizedMethod( nameof(this.LogCall) )
.PlaceInCallingType() );
[Template]
public dynamic? LogCall()
{
meta.ThisType._log.WriteLine( $"Calling {meta.Interception.Method.InterceptedMethod.Name}." );
return meta.Proceed();
}
}
Applied to OrderService, the aspect generates this method inside the class, next to its _log field:
private static string PaymentGateway_Charge_Interceptor( decimal amount, string currency )
{
_log.WriteLine( "Calling Charge." );
return PaymentGateway.Charge( amount, currency );
}
Scoping interceptors
The scope of an interception is determined by where it is registered:
- in an aspect, the calls written in the target type of the aspect;
- in a namespace fabric, the calls written in its namespace;
- in a project fabric, the calls written in the whole project, or in the types that a query selects.
A fabric can also register interceptors only in some builds. The following fabric does nothing unless the TRACE_CALLS symbol is defined:
if ( !amender.Project.PreprocessorSymbols.Contains( "TRACE_CALLS" ) )
{
return;
}
For finer control, ForEachSite makes a decision for each call site. It can, for instance, pass a parameter of the calling method to the interceptor, or skip the call sites where that parameter does not exist.
Try it
Interceptors are available from Metalama 2027.0.4-preview, in the new Metalama.Extensions.Interceptors package, which works with a free Metalama Community license. This is a preview release and is not supported in production.
The documentation covers the feature in depth:
- Interceptors for an overview and the full API.
- When to Use Interceptors for their use cases, their limitations, and a comparison with other techniques.
See the Metalama 2027.0 release notes for the rest of the release. We welcome your feedback on GitHub.
Happy meta-programming with Metalama!