Core Concepts
This chapter explains how Metalama aspects work internally — their lifecycle, ordering, composition, and the relationship between compile-time and run-time execution.
How Aspects Work
An aspect in Metalama is fundamentally a compile-time code transformer. When the compiler encounters an aspect attribute on a target declaration, it:
- Instantiates the aspect class at compile time
- Calls
BuildAspect()(if using the imperative API) to collect advice - Expands templates to generate the transformed code
- Merges the transformed code into the compilation output
The Two APIs
Metalama offers two ways to define aspect behavior:
Simple API (Template-Based)
Override a template method. The template is the entire behavior:
public class RetryAttribute : OverrideMethodAspect
{
public int MaxAttempts { get; set; } = 3;
// This IS the template — it defines the transformed code
public override dynamic? OverrideMethod()
{
for (var i = 0; i < MaxAttempts; i++)
{
try
{
return meta.Proceed();
}
catch (Exception ex) when (i < MaxAttempts - 1)
{
Console.WriteLine($"Attempt {i + 1} failed: {ex.Message}");
}
}
throw new InvalidOperationException("Should not reach here");
}
}
Use when: The aspect has a single, straightforward transformation.
Programmatic API (BuildAspect-Based)
Override BuildAspect() for complex scenarios:
public class NotifyPropertyChangedAttribute : TypeAspect
{
public override void BuildAspect(IAspectBuilder<INamedType> builder)
{
// Step 1: Implement INotifyPropertyChanged
builder.ImplementInterface(typeof(INotifyPropertyChanged));
// Step 2: Introduce the event
builder.IntroduceEvent(nameof(PropertyChanged));
// Step 3: Override each property setter
foreach (var property in builder.Target.Properties
.Where(p => !p.IsStatic && p.Writeability == Writeability.All))
{
builder.With(property).Override(nameof(PropertyTemplate));
}
}
[Template]
public dynamic? PropertyTemplate
{
get => meta.Proceed();
set
{
if (!Equals(value, meta.Target.FieldOrProperty.Value))
{
meta.Proceed();
OnPropertyChanged(meta.Target.FieldOrProperty.Name);
}
}
}
}
Use when: You need to introspect the target, introduce members, implement interfaces, or apply advice conditionally.
Aspect Lifecycle
Understanding when aspect code runs is crucial:
┌─────────────────────────────────────────────────┐
│ COMPILE TIME │
│ │
│ 1. Aspect class is instantiated │
│ 2. Aspect properties are set (from attribute) │
│ 3. BuildEligibility() is called │
│ 4. BuildAspect() is called │
│ 5. Templates are expanded into C# code │
│ 6. Generated code is compiled into IL │
│ │
│ ⚠ All aspect class members are evaluated │
│ at compile time, NOT runtime! │
│ │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ RUN TIME │
│ │
│ Only the EXPANDED template code runs. │
│ The aspect class itself does NOT exist. │
│ │
│ meta.Proceed() → original method body │
│ meta.Target.Method.Name → string literal │
│ foreach(param in meta.Target.Parameters) │
│ → unrolled individual lines │
│ │
└─────────────────────────────────────────────────┘
Key Insight
The aspect class is a compile-time artifact. It is never instantiated at runtime. The template it contains is expanded into the target method, and only the expanded code exists in the final assembly.
This is fundamentally different from runtime AOP frameworks (like Castle DynamicProxy) where interceptors are actual objects that exist at runtime.
Aspect Ordering
When multiple aspects are applied to the same method, they form a pipeline. The order matters:
[Authorize] // Runs first (outermost)
[Log] // Runs second
[Cache] // Runs third
[Retry] // Runs fourth (innermost, closest to original method)
public async Task<Recipe> GetRecipeAsync(int id) { ... }
Execution Order (Pipeline Model)
Request → [Authorize] → [Log] → [Cache] → [Retry] → Original Method
│
Response ← [Authorize] ← [Log] ← [Cache] ← [Retry] ←───────┘
Each aspect's meta.Proceed() calls the next layer inward.
Controlling Order
Use [AspectOrder] at the assembly level:
[assembly: AspectOrder(
AspectOrderDirection.RunTime,
typeof(AuthorizeAttribute),
typeof(LogAttribute),
typeof(CacheAttribute),
typeof(RetryAttribute)
)]
RunTimedirection means the first type listed executes first at runtime (outermost)CompileTimedirection is the reverse — first type is compiled first (innermost)
Default Ordering
Without explicit ordering, aspects execute in the order they appear in source code (top to bottom on the attribute list).
Aspect Composition
Multiple aspects can be stacked on the same target. Each aspect is independent and composable:
[Log]
[Retry(MaxAttempts = 3)]
[Timing(WarningThresholdMs = 1000)]
public async Task SendNotificationAsync(string userId, string message)
{
await _notificationService.SendAsync(userId, message);
}
How Stacking Works
Each aspect wraps the next using meta.Proceed(). The compiled result is equivalent to:
// Conceptual equivalent of stacked aspects
public async Task SendNotificationAsync(string userId, string message)
{
// [Log] layer
_logger.LogDebug("Entering SendNotificationAsync...");
try
{
// [Retry] layer
for (int attempt = 0; attempt < 3; attempt++)
{
try
{
// [Timing] layer
var sw = Stopwatch.StartNew();
try
{
// Original method
await _notificationService.SendAsync(userId, message);
}
finally
{
sw.Stop();
if (sw.ElapsedMilliseconds > 1000)
_logger.LogWarning("Slow: {Elapsed}ms", sw.ElapsedMilliseconds);
}
break; // Success, exit retry loop
}
catch when (attempt < 2)
{
await Task.Delay(500 * (int)Math.Pow(2, attempt));
}
}
_logger.LogDebug("Exiting SendNotificationAsync");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in SendNotificationAsync");
throw;
}
}
Composition Rules
- Aspects don't know about each other — each aspect only sees
meta.Proceed(), which calls the next layer - Order matters —
[Retry]outside[Cache]is different from[Cache]outside[Retry] - Same-type stacking — you can apply the same aspect type multiple times (if the aspect supports it)
- No conflicts — Metalama handles the weaving automatically
Compile-Time vs. Run-Time: Mental Model
This is the most important mental model for Metalama development. Every piece of code in an aspect template is either:
| Category | When it Executes | What It Produces |
|---|---|---|
| Compile-time | During build | C# source code |
| Run-time | When the app runs | Actual behavior |
Examples
public override dynamic? OverrideMethod()
{
// COMPILE-TIME: meta.Target.Method.Name is resolved to a string literal
var name = meta.Target.Method.Name; // → "GetRecipeAsync"
// RUN-TIME: Console.WriteLine is generated into the output code
Console.WriteLine($"Entering {name}");
// COMPILE-TIME: This loop iterates over parameters during compilation
foreach (var p in meta.Target.Parameters)
{
// RUN-TIME: Each iteration generates a Console.WriteLine call
Console.WriteLine($" {p.Name} = {p.Value}");
}
// RUN-TIME: meta.Proceed() is replaced with the original method body
return meta.Proceed();
}
When Applied to GetRecipeAsync(int id, string name):
// Generated code (what actually runs)
public async Task<Recipe> GetRecipeAsync(int id, string name)
{
var name_1 = "GetRecipeAsync"; // Compile-time resolved
Console.WriteLine($"Entering {name_1}");
Console.WriteLine($" id = {id}"); // Unrolled from foreach
Console.WriteLine($" name = {name}"); // Unrolled from foreach
return await _repository.GetRecipeAsync(id, name); // meta.Proceed() expanded
}
Async Support
Metalama automatically handles async methods. Your aspect template can provide separate handling for sync and async methods:
public class TimingAttribute : OverrideMethodAspect
{
// Called for synchronous methods
public override dynamic? OverrideMethod()
{
var sw = Stopwatch.StartNew();
try
{
return meta.Proceed();
}
finally
{
sw.Stop();
Console.WriteLine($"Elapsed: {sw.ElapsedMilliseconds}ms");
}
}
// Called for async methods (Task, Task<T>, ValueTask, ValueTask<T>)
public override async Task<dynamic?> OverrideAsyncMethod()
{
var sw = Stopwatch.StartNew();
try
{
return await meta.ProceedAsync();
}
finally
{
sw.Stop();
Console.WriteLine($"Elapsed: {sw.ElapsedMilliseconds}ms");
}
}
}
Key Points
- If you only override
OverrideMethod(), Metalama automatically wraps async methods correctly - Override
OverrideAsyncMethod()for explicit async control (e.g., usingawaitin the aspect itself) meta.Proceed()automatically becomesawait meta.ProceedAsync()in async contexts- Supported return types:
Task,Task<T>,ValueTask,ValueTask<T>,IAsyncEnumerable<T>
Summary
| Concept | Description |
|---|---|
| Two APIs | Simple (template override) and Programmatic (BuildAspect()) |
| Compile-time lifecycle | Aspects are instantiated, configured, and expanded during compilation |
| No runtime instance | The aspect class does not exist at runtime |
| Pipeline model | Stacked aspects form layers around meta.Proceed() |
| Ordering | Controlled by [AspectOrder] or attribute declaration order |
| Composition | Aspects are independent and composable |
| Async support | Automatic or explicit via OverrideAsyncMethod() |
Next: T# Template Language — Master the compile-time template system.