As C# developers, we strive to write code that is clean, readable, and maintainable. But often, our core business logic gets tangled up with secondary concerns. Things like logging, input validation, caching, and error handling are essential, but they create noise and violate the Single Responsibility Principle (SRP).
What if we could neatly separate these "cross-cutting concerns" from our main logic?
This is exactly what Aspect-Oriented Programming (AOP) helps us achieve. In this article, I'll introduce you to Aymen83.AspectWeaver, a lightweight, modern, and powerful AOP framework for C# that leverages C# 12's new Interceptors feature to keep your code clean and modular—with zero performance overhead from reflection at runtime.
The "Before" Scenario: Code Without AOP
Let's look at a familiar scenario. We have a WeatherService
with a method that fetches data from a slow, external API. The core logic is simple, but it's buried in boilerplate for caching, logging, and retries.
public class WeatherService
{
private readonly ILogger<WeatherService> _logger;
private readonly IMemoryCache _cache;
private readonly HttpClient _httpClient;
public WeatherService(ILogger<WeatherService> logger, IMemoryCache cache, HttpClient httpClient)
{
_logger = logger;
_cache = cache;
_httpClient = httpClient;
}
public async Task<string> GetWeatherAsync(string location)
{
var cacheKey = $"weather_{location}";
// 1. Caching concern
if (_cache.TryGetValue(cacheKey, out string cachedWeather))
{
_logger.LogInformation("Cache hit for {Location}. Returning cached data.", location);
return cachedWeather;
}
_logger.LogInformation("Cache miss for {Location}. Fetching from API.", location);
var stopwatch = Stopwatch.StartNew();
// 2. Retry & Error Handling concern
for (int i = 0; i < 3; i++)
{
try
{
// 3. Core Business Logic
var weather = await _httpClient.GetStringAsync($"/weather?q={location}");
stopwatch.Stop();
_logger.LogInformation("API call successful in {Elapsed}ms.", stopwatch.ElapsedMilliseconds);
// More caching concern
var cacheEntryOptions = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromMinutes(10));
_cache.Set(cacheKey, weather, cacheEntryOptions);
return weather;
}
catch (Exception ex)
{
_logger.LogError(ex, "API call failed. Attempt {Attempt}.", i + 1);
if (i == 2) throw; // rethrow on last attempt
await Task.Delay(200);
}
}
throw new InvalidOperationException("Should not be reached.");
}
}
This code is problematic because:
- It's cluttered: The business logic is hard to spot amongst the caching, logging, and retry logic.
- It violates SRP: The method does more than just get weather; it also manages cache, logs diagnostics, and handles transient errors.
- It's hard to maintain: Changing the cache duration or retry policy requires modifying the core flow of the method.
The "After" Scenario: Enter Aymen83.AspectWeaver
Aymen83.AspectWeaver lets you declaratively add behavior to methods using attributes. The framework then "weaves" this logic into your code at compile time.
First, let's install the necessary NuGet packages:
dotnet add package Aymen83.AspectWeaver
dotnet add package Aymen83.AspectWeaver.Extensions
# We also need this for the IMemoryCache in our custom aspect
dotnet add package Microsoft.Extensions.Caching.Memory
Now, let's see what that GetWeatherAsync
method looks like with aspects.
public class WeatherService
{
private readonly HttpClient _httpClient;
public WeatherService(HttpClient httpClient)
{
_httpClient = httpClient;
}
[Cache(10 * 60)] // Custom caching aspect (10 minutes)
[LogExecution] // Built-in logging aspect
[Retry(3, 200)] // Built-in retry aspect
public async Task<string> GetWeatherAsync(string location)
{
// Just the business logic!
return await _httpClient.GetStringAsync($"/weather?q={location}");
}
}
Look at that! All the boilerplate is gone. The method now only contains its core responsibility. The attributes clearly state the cross-cutting concerns that apply to it.
Creating a Custom Aspect: Showcasing True Extensibility
The real power of AOP shines when you create aspects tailored to your application's needs. Let's implement the [Cache]
aspect we just used. It's a perfect example of a common, real-world problem: improving performance by caching the results of expensive method calls.
1. The Aspect Attribute
First, we define the attribute that will hold our configuration—in this case, the cache duration in seconds.
// CacheAttribute.cs
using Aymen83.AspectWeaver.Abstractions;
[AttributeUsage(AttributeTargets.Method)]
public sealed class CacheAttribute : AspectAttribute
{
public int DurationInSeconds { get; }
public CacheAttribute(int durationInSeconds)
{
DurationInSeconds = durationInSeconds;
// Caching should happen early, but after validation.
Order = -900;
}
}
2. The Aspect Handler
Next, we create the handler, which contains the logic. Aymen83.AspectWeaver uses dependency injection, so we can easily inject the IMemoryCache
service.
// CacheHandler.cs
using Aymen83.AspectWeaver.Abstractions;
using Microsoft.Extensions.Caching.Memory;
using System.Text.Json;
public sealed class CacheHandler : IAspectHandler<CacheAttribute>
{
private readonly IMemoryCache _cache;
public CacheHandler(IMemoryCache cache)
{
_cache = cache;
}
public async ValueTask<TResult> InterceptAsync<TResult>(
CacheAttribute attribute,
InvocationContext context,
Func<InvocationContext, ValueTask<TResult>> next)
{
// Generate a unique key based on the method and its arguments
var cacheKey = $"{context.MethodName}_{JsonSerializer.Serialize(context.Arguments)}";
// Try to get the value from the cache
if (_cache.TryGetValue(cacheKey, out TResult cachedResult))
{
return cachedResult;
}
// If not in cache, execute the original method
var result = await next(context);
// Store the result in the cache
var cacheOptions = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromSeconds(attribute.DurationInSeconds));
_cache.Set(cacheKey, result, cacheOptions);
return result;
}
}
With just these two simple classes, you've created a reusable, declarative caching system!
Batteries Included: The Built-in Aymen83.AspectWeaver.Extensions
To get you started, Aymen83.AspectWeaver.Extensions
provides pre-built aspects for the most common concerns.
Example 1: Automatic Validation with [ValidateParameters]
and [NotNull]
Problem: Repetitive if (param == null)
guard clauses at the start of every method.
Before:
public void ProcessOrder(Order order, Customer customer)
{
if (order == null)
{
throw new ArgumentNullException(nameof(order));
}
if (customer == null)
{
throw new ArgumentNullException(nameof(customer));
}
// ... logic
}
With Aymen83.AspectWeaver:
Apply [ValidateParameters]
to the method and [NotNull]
to the parameters you want to check.
using Aymen83.AspectWeaver.Abstractions.Constraints;
using Aymen83.AspectWeaver.Extensions.Validation;
[ValidateParameters]
public void ProcessOrder([NotNull] Order order, [NotNull] Customer customer)
{
// ... logic
}
Example 2: Smart Logging with [LogExecution]
Problem: Scattering Log.Info(...)
and Stopwatch
calls all over your code.
Before:
public int CalculateComplexValue(int input)
{
_logger.LogInformation("Starting calculation for {Input}...", input);
var sw = Stopwatch.StartNew();
try
{
// ... complex logic ...
var result = input * 42;
sw.Stop();
_logger.LogInformation("Calculation finished in {Elapsed}ms. Result: {Result}", sw.ElapsedMilliseconds, result);
return result;
}
catch(Exception ex)
{
_logger.LogError(ex, "Calculation failed!");
throw;
}
}
With Aymen83.AspectWeaver:
Just add one attribute. It automatically logs method entry, exit, parameters, return value, and execution time.
using Aymen83.AspectWeaver.Extensions.Logging;
[LogExecution(LogArguments = true, LogReturnValue = true)]
public int CalculateComplexValue(int input)
{
// ... complex logic ...
return input * 42;
}
Example 3: Resilient Retries with [Retry]
Problem: Writing complex try-catch
loops to handle transient failures when calling an external service.
Before:
public async Task CallFlakyApiService()
{
int attempts = 0;
while(true)
{
try
{
await _flakyApiClient.ConnectAsync();
return; // Success
}
catch (HttpRequestException)
{
attempts++;
if (attempts >= 3) throw;
await Task.Delay(200); // Wait before retrying
}
}
}
With Aymen83.AspectWeaver:
This complex loop becomes a single, declarative attribute.
using Aymen83.AspectWeaver.Extensions.Resilience;
[Retry(attempts: 3, delayMilliseconds: 200)]
public async Task CallFlakyApiService()
{
await _flakyApiClient.ConnectAsync();
}
How Does It Work? A Glimpse Under the Hood
You might be wondering about performance. Older AOP frameworks often relied on runtime reflection or dynamic proxies, which could add overhead.
Aymen83.AspectWeaver is different. It's built on top of C# 12 Interceptors, a powerful new compiler feature. During compilation, Aymen83.AspectWeaver generates source code that "intercepts" the call to your annotated method and redirects it through the aspect pipeline. The final compiled code is as if you had written the boilerplate by hand—it's highly performant, with no runtime reflection involved.
Conclusion and Call to Action
Aspect-Oriented Programming is a powerful paradigm for writing cleaner, more maintainable C# applications. By separating cross-cutting concerns from your business logic, you make your code easier to read, test, and evolve.
Aymen83.AspectWeaver provides a simple and modern way to adopt AOP, helping you:
- Eliminate boilerplate code for logging, validation, and retries.
- Keep your business logic pure and focused on its single responsibility.
- Easily extend your application with custom, reusable aspects for concerns like authorization, caching, or auditing.
Ready to clean up your codebase?
- Try it out: Get the packages on NuGet:
Aymen83.AspectWeaver
andAymen83.AspectWeaver.Extensions
. - Check the source: Explore the code, see how it works, and contribute on GitHub: https://github.com/Aymen83/AspectWeaver
- Give feedback: Leave a star on GitHub if you find it useful, and open an issue with your suggestions!
Happy coding!
Top comments (0)