Skip to main content

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:

  1. Instantiates the aspect class at compile time
  2. Calls BuildAspect() (if using the imperative API) to collect advice
  3. Expands templates to generate the transformed code
  4. 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)
)]
  • RunTime direction means the first type listed executes first at runtime (outermost)
  • CompileTime direction 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

  1. Aspects don't know about each other — each aspect only sees meta.Proceed(), which calls the next layer
  2. Order matters[Retry] outside [Cache] is different from [Cache] outside [Retry]
  3. Same-type stacking — you can apply the same aspect type multiple times (if the aspect supports it)
  4. 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:

CategoryWhen it ExecutesWhat It Produces
Compile-timeDuring buildC# source code
Run-timeWhen the app runsActual 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., using await in the aspect itself)
  • meta.Proceed() automatically becomes await meta.ProceedAsync() in async contexts
  • Supported return types: Task, Task<T>, ValueTask, ValueTask<T>, IAsyncEnumerable<T>

Summary

ConceptDescription
Two APIsSimple (template override) and Programmatic (BuildAspect())
Compile-time lifecycleAspects are instantiated, configured, and expanded during compilation
No runtime instanceThe aspect class does not exist at runtime
Pipeline modelStacked aspects form layers around meta.Proceed()
OrderingControlled by [AspectOrder] or attribute declaration order
CompositionAspects are independent and composable
Async supportAutomatic or explicit via OverrideAsyncMethod()

Next: T# Template Language — Master the compile-time template system.