The New C# Interceptors vs. AOP

Marek Sirkovský
11 min readFeb 21, 2024
Photo by Ryoji Iwata on Unsplash

.NET 8 has introduced a new feature called Interceptors — an experimental concept that allows us to rewrite existing code in quite an interesting way.

using System;
using System.Runtime.CompilerServices;

var c = new C();
c.InterceptableMethod(1); // (L1,C1): prints "interceptor 1"
c.InterceptableMethod(1); // (L2,C2): prints "other interceptor 1"
c.InterceptableMethod(2); // (L3,C3): prints "other interceptor 2"
c.InterceptableMethod(1); // prints "interceptable 1"

class C
{
public void InterceptableMethod(int param)
{
Console.WriteLine($"interceptable {param}");
}
}

// generated code
static class D
{
// refers to the call at (L1, C1)
[InterceptsLocation("Program.cs", line: /*L1*/, character: /*C1*/)]
public static void InterceptorMethod(this C c, int param)
{
Console.WriteLine($"interceptor {param}");
}

// refers to the call at (L2, C2)
[InterceptsLocation("Program.cs", line: /*L2*/, character: /*C2*/)]
// refers to the call at (L3, C3)
[InterceptsLocation("Program.cs", line: /*L3*/, character: /*C3*/)]
public static void OtherInterceptorMethod(this C c, int param)
{
Console.WriteLine($"other interceptor {param}");
}
}

I copied this code from the official Microsoft documentation, and I know it looks really strange at first glance. Unfortunately, upon closer inspection, it still seems quite unusual. You must correctly set the line(L1, L2, L3) and column(C1, C2, C3) in the InterceptsLocation to make it work. Despite the feature being quite powerful, the code seems fragile and prone to breaking easily. If you change the position of the line or the variable name c to something shorter or longer, the whole interception stops working, and you get an error:

[CS9147] The provided line and character number does not refer to 
the start of token 'InterceptableMethod'.
Did you mean to use line '5' and character '3'?

It’s fragile, but there is a reason for its fragility. The C# team discourages using interceptors for everyday coding tasks. Honestly, it may seem that they don’t want you to utilize it at all. The interceptors appear to be primarily used with the Minimal API source generator, as interceptors’ functionality is currently extremely limited.

Additionally, according to the interceptor’s documentation, interceptors are currently in preview mode and should not be used in production or released applications. Actually, the C# team was initially against allowing Source Generators to rewrite code. As you can see in their FAQ:

How do Source Generators compare to other metaprogramming features like macros or compiler plugins?

“The key difference is that Source Generators don’t allow you to rewrite user code. We view this limitation as a significant benefit since it keeps user code predictable with respect to what it actually does at runtime. We recognize that rewriting user code is a very powerful feature, but we’re unlikely to enable Source Generators to do that.”

In contrast to their statement, interceptors have introduced this particular type of rewriting :).

Since interceptors enable us to cover more Aspect-oriented programming(AOP) use cases, I’ve got an idea to look closely at AOP in C# in 2024.

What is Aspect-oriented programming?

AOP is a programming paradigm that aims to increase modularity by allowing the separation of cross-cutting concerns. It does so mainly by adding additional behavior to existing code without modifying the code itself.

In simpler terms, AOP allows you to inject additional code into various parts of your program without directly altering the code in those parts. AOP benefits concerns spanning multiple program areas, such as logging, security, or transaction management.

The AOP in C# is nothing new at all. After C# got attributes, you could use .NET reflection to add aspects to your code. Several libraries emerged in those times to support more complex AOP scenarios. One of the most well-known tools was PostSharp.

Another big thing has been introducing the Source Generators in .NET 5. However, the Source Generators were not primarily focused on providing the fully-feature AOP because of a missing direct mechanism for rewriting the code.

We now have this missing piece of the puzzle. In the rest of the blog post, I will introduce various ways to implement one of the most straightforward AOP concepts — modify the functionality of a method as required in the given code snippet:

class User
{
public void SignUp(string email, string password)
{
// Write to the console: "User is being created"
var newUser = CreateUser(email, password);
// Write to the console: "User has been created"
}
}

Our goal is to enhance the functionality of the SignUp method by adding simple logging implemented via AOP. Let’s start with the simplest one.

AOP with reflection

AOP with reflection is a simple concept. You have probably seen it before. You don’t need anything but reflection and attributes.

The code:

public class LoggingAspect
{
public void RunAndApplyAspect(object target, object[] args)
{
var targetType = target.GetType();
var methods = targetType.GetMethods();

foreach (var method in methods)
{
if (method.IsDefined(typeof(LogAttribute), false))
{
Console.WriteLine("User is being created");
method.Invoke(target, args);
Console.WriteLine("User has been created");
}
}
}
}

// Program.cs
var user = new User();

var myAspect = new LoggingAspect();
myAspect.RunAndApplyAspect(user, new []
{
"example@email.com", "123"
});

Let me be super clear here. My implementation is a terrible way to achieve the goal. It works but it’s not the best code I’ve ever written. You can come up with a more elegant solution using delegates to force type safety for arguments or make a generic wrapper. But I hope you get an idea.

AOP with reflection is a clear and well-known pattern. Ultimately, reflection has been a part of our lives since 2002. If you’re a fan of history, you might enjoy reading an article on reflection published in 2002.

Despite its popularity and battle-tested nature, reflection has many drawbacks. Using reflection involves a significant performance cost, and it’s prone to errors like TypeNotFoundException or MethodNotFoundException raised at runtime. Reflection can also break encapsulation by allowing private members of classes access.

AOP with function composition

The second option involves utilizing standard function composition. It’s a simple concept that can be used in any programming language supporting higher-order functions.


//LoggingAspect.cs
public static class LoggingAspect
{
public static void Log(Action func)
{
Console.WriteLine("User is being created");
func();
Console.WriteLine("User has been created");
}
}

//Program.cs
var user = new User();
var loggedSignUp = (string email, string password) =>
{
LoggingAspect.Log(() => user.SignUp(a,b));
};

loggedSignUp("example@email.com", "123");

You can see that we have two functions. The first is the original one, and the second represents the one we want to add. We create a new function(loggedSignUp) that calls the original and then the second using composition. Many variations exist for this pattern, yet the concept remains the same.

The simplicity of function composition eliminates the need for special requirements, making it a versatile pattern. You can write similar code in Javascript or C#, and everyone immediately comprehends the principle.

On the other side, it’s not as powerful as other options. You can’t change the existing code. You need to do a lot of manual work, and some may even question whether it’s a genuine AOP.

AOP with a manual interception

Another option is to use the new interceptors feature I discussed at the beginning of this blog post.

//User.cs
public class User
{
public async Task SignUp(string email, string password)
{
var newUser = await CreateUser(email, password);
}
}

//Anywhere in your solution
static class InterceptionExtensions
{
[InterceptsLocation("""C:\YourFolder\Sample\Interceptors\Program.cs""",
line: 4, character: 13)]
public static void InterceptorMethod(this User user,
string email, string password)
{
Console.WriteLine($"User is being created");
user.SignUp(email, password);
Console.WriteLine($"User has been created");
}
}
//Program.cs
var user = new User();
user.SignUp("email@examle.com", "pass");

The code is super brittle. If you modify the name or position of the variable, it stops working. It works, but I strongly recommend against this pattern. Maybe use it for extreme cases, but be very careful. Incorporating such functionality into your everyday programming is not a good idea.

AOP with Source Generator — the version without interceptors

Let’s try to satisfy our requirement using Source Generator without the new interceptors feature.

It is important to mention that two APIs are available for the Source Generator: the old API and the new Incremental source generator API. The new incremental API offers better performance but is more complex than the old one. The examples provided below make use of this new incremental API.

To simplify the code, I’ve used the method’s name to find all its occurrences and generate code using a constant string. It’s just an example; don’t use it in your production code! I’ll show you a more robust implementation in the next section.

// The source generator project:
[Generator]
public class SuperSimpleIncrementalSourceGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
context.RegisterSourceOutput(context.CompilationProvider, (ctx, _) =>
{
var source = @"// <auto-generated/>
using System;
using System.Runtime.CompilerServices;

namespace SourceGeneratorsExample.Sample.NoInterception;

public partial class User
{
partial void InterceptHere(string email, string password)
{
Console.WriteLine(""User is being created"");
}
}
";
ctx.AddSource($@"UserSimpleExtension.g.cs", source);
});
}
}


// the client project:
public partial class User
{
public void SignUp(string email, string password)
{
InterceptHere(email, password);
var newUser = CreateUser(email, password)
}
// the source generator generates the implementation for this method
partial void InterceptHere(string email, string password);
}


//generated code:
// <auto-generated/>
using System;

namespace SourceGeneratorsExample.Sample.NoInterception;

public partial class User
{
partial void InterceptHere(string email, string password)
{
Console.WriteLine("User is being created");
}
}

As you can see, the source generator simply generates the implementation of the partial method(InterceptHere). However, there are several issues with this approach:

  • You need to use partial classes and methods.
  • You can’t rewrite any functionality.
  • You can only add a piece of code into a clearly defined place.

Although I’ve seen some usage of this pattern, I don’t find it too appealing. I believe it simply proves that the old version of SG doesn’t support our use case.

AOP with Source Generator and interceptors

Let’s see what happens when we combine a source generator with the new interceptors. Here is a redacted version of the code to demonstrate how a source generator and interceptors can work together effectively. The complete code is here.

using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace SourceGeneratorsExample;

[Generator(LanguageNames.CSharp)]
public class SourceGeneratorWithInterceptors : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var providerInvocationExp = context.SyntaxProvider.CreateSyntaxProvider(
// we are looking only for the member access expressions
(n, _) => n is MemberAccessExpressionSyntax,
(n, cp) =>
{
// omitted for brevity...
// get all information via semantic model
...
});

var compilationWithProvider = context.CompilationProvider
.Combine(providerInvocationExp.Collect());

context.RegisterSourceOutput(compilationWithProvider, (ctx, t) =>
{
var foundedMethods = t.Right;
// filter out methods without an attribute
var extensions = foundedMethods.Where...
.Select(method =>
{
// create InterceptsLocation & InterceptorMethod
//using information from MethodInfoToIntercept.
var str = $@"
[System.Runtime.CompilerServices.InterceptsLocation(@""{method.FilePath}"",
line: {method.Line}, character: {method.Column})]
public static void InterceptorMethod(this {method.ClassNameWithNamespace} obj,
string email, string password)
{{
Console.WriteLine($""User is being created"");
obj.SignUp(email, password);
Console.WriteLine($""User has been created"");
return;
}}";
return str; });
var extensionCode = string.Join("\r\n", extensions);
// add InterceptsLocationAttribute and
//put all generated "interceptors"
//to the InterceptionExtensions class
var source = $@"
// <auto-generated/>
using System;
using System.Runtime.CompilerServices;

namespace System.Runtime.CompilerServices
{{
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
sealed class InterceptsLocationAttribute(string filePath,
int line, int character) : Attribute
{{
}}

static class InterceptionExtensions
{{
{extensionCode}
}}
}}";
ctx.AddSource("SampleIntercepting.g.cs", source);
});
}
}

// A custom attribute to mark the method
public class InterceptAttribute : Attribute
{
}

//User.cs
public class User
{
public static void Test()
{
var user = new User();
user.SignUp("email", "pass");
}

[Intercept]
public void SignUp(string email, string password)
{
var newUser = CreateUser(email, password);
}
}

I wrote a more elaborate version than the previous example, but it is still not completely foolproof. In the first part, you must locate all methods labeled with the Intercept attribute using the SyntaxProvider. I gathered all the information about the class, namespace, etc. In the second part, I utilize this information to create InterceptsLocation and substitute the InterceptorMethodmethod call.

Interceptors vs. AOP

The example demonstrates a simple AOP scenario using interceptors. Despite working correctly, it falls short of supporting standard AOP use cases. The limitations of interceptors:

There are two types of rewriting.

  • call-site
  • implementation

Interceptors are only capable of intercepting a method at the call site. To illustrate the difference, consider the following example:

// User.cs
public class User
{
// Rewriting of implementation
// Interceptor DOESN'T support replacing code here.
//You CAN'T replace the entire body of the SignUp method
[Intercept]
public void SignUp(string email, string password)
{
}
}

// Program.cs
var user = new User();
// Call-site interception
// Interceptor DOES support replacing code here.
user.SignUp("email@examle.com", "pass");

The limitation is quite significant. You cannot modify a method’s behavior unless it is used within your code! It also means if your method is called in more places, you also need to register interceptors at all of the places to rewrite all of the usages.

Actually, there is a long list of what interceptors don’t support. It supports only intercepting methods, not properties. It’s not possible to intercept constructors, generic methods, methods with ref or out parameters, etc.

In GitHub discussions, the sentiment frequently conveys that the current limited version of interceptors merely initiates the process of gathering feedback from the community. The situation with the primary constructors discussed in my previous post is somewhat similar.

Unfortunately, C# currently doesn’t have any native AOP open-source libraries available, unlike other languages and ecosystems. Furthermore, it seems unlikely that the situation will change in the near future.

AOP with Metalama

After realizing that the C# team has no plans to support AOP, I searched for alternative options. One of these options is the Metalama framework. It is a commercial product that is incredibly easy to use and a successor of PostSharp.

class User
{
[Log]
public async void SignUp(string email, string password)
{
var user = await CreateUser ...
}
}

//OverrideMethodAspect comes from Metalama
public class LogAttribute : OverrideMethodAspect
{
public override dynamic? OverrideMethod()
{
Console.WriteLine("User is being created");
var result = meta.Proceed();
Console.WriteLine("User has been created");
return result;
}
}

//Program.cs
var user = new User();
await user.SignUp("example@mail.com", "123");

That’s all the code you need. You can easily change the existing code without hassle with lines, columns, and Source Generators. Metalama also offers a wide range of functionality plus a Visual Studio CodeLens plugin to make working with rewritten code as smooth as possible.

On the other hand, it looks like Metalama still doesn’t support call-site aspects (i.e., interceptors).

The main disadvantage of Metalama is its price. It seems a bit high to me.

Note Metalama vs. interceptors:
It’s important to note that Metalama currently cannot be used with interceptors. If you try to combine it, you will get the compilation error:

Interceptors and Metalama can’t currently be used together.

Metalama and others

It would be unfair if I didn’t mention any alternatives to PostSharp/Metalama. There are several libraries for AOP in C#. One worth mentioning is MrAdvice, which is still active.

According to the author:

Mr. Advice is an open-source (and free of charge) alternative to PostSharp (which is still far more advanced).

I’ve looked at the API and think you can still do many things with it. If you know PostSharp/Metalama, its API will look familiar.

public class User
{
[Log]
public void SignUp(string email, string password)
{
}
}

public class LogAttribute : Attribute, IMethodAdvice
{
public void Advise(MethodAdviceContext context)
{
Console.WriteLine("User is being created");
context.Proceed();
Console.WriteLine("User has been created");
}
}

Summary

The Source Generator has been a game-changer, and with the new feature of interception, it allows you to do things that were not possible before. However, remember that it’s not perfect and still only experimental.

In case you need a robust AOP library and have a flexible budget, consider the Metalama framework. Alternatively, open-source alternatives are viable options if your requirements are more straightforward and you don’t want to spend money.

--

--