[Home](../) › [Blog](../blog) › Intercepting Calls to Any Method with Metalama Interceptors

# Intercepting Calls to Any Method with Metalama Interceptors

> Metalama 2027.0 adds call-site interceptors: an aspect or a fabric redirects the calls to a method of any assembly within a chosen scope to a hand-written C# method or to interceptors generated from a T# template.

Published: 2026-10-29 · Author: Gaël Fraiteur · Canonical: https://postsharp.net/blog/metalama-interceptors

Metalama 2027.0 adds call-site interceptors. An override aspect modifies the called method itself, for every caller, and requires its source code. An interceptor modifies the call sites instead: an aspect or a fabric selects the calls to a method within a given scope, and Metalama rewrites them at build time to target the interceptor. The called method can therefore belong to any assembly: the .NET libraries, a NuGet package, or another team's project. The interceptor is either a hand-written C# method or generated from a T# template for each signature.

## 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:

```csharp
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`:

```csharp
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:

```csharp
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 )` and `SaveCustomer( Customer customer )`. Calls to generic methods, such as `Serialize<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.** `WithArgs` assigns 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 `async` and 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:

```csharp
[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`:

```csharp
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:

```csharp
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:

```csharp
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:

```csharp
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:

```csharp
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:

```csharp
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](https://doc.postsharp.net/metalama/preview/conceptual/interceptors) for an overview and the full API.
- [When to Use Interceptors](https://doc.postsharp.net/metalama/preview/conceptual/interceptors/use-cases) for their use cases, their limitations, and a comparison with other techniques.

See the [Metalama 2027.0 release notes](https://doc.postsharp.net/metalama/preview/conceptual/release-notes/release-notes-2027-0) for the rest of the release. We welcome your feedback on [GitHub](https://github.com/metalama/Metalama/issues).

Happy meta-programming with Metalama!

---

## Site navigation

- Index of the whole site: [llms.txt](../llms.txt)

