Skip to main content

Best Practices & Common Pitfalls

Guidelines for writing correct, maintainable, and performant aspects, drawn from Metalama documentation and GST framework experience.


Design Principles

1. Single Responsibility

Each aspect should handle one cross-cutting concern:

// ✅ Good: Focused aspects
[Log]
[Retry(MaxAttempts = 3)]
[Cache(AbsoluteExpirationSeconds = 300)]
public async Task<Recipe> GetRecipeAsync(int id) { ... }

// ❌ Bad: Monolithic aspect doing everything
[SuperAspect] // Logs, retries, caches, validates, audits...
public async Task<Recipe> GetRecipeAsync(int id) { ... }

Why: Focused aspects are reusable, testable, and composable. A monolithic aspect becomes unmaintainable.

2. Fail-Safe Design

Aspects should never break the application, even if their dependencies are unavailable:

// ✅ Good: Graceful degradation
public override dynamic? OverrideMethod()
{
var logger = AspectServiceLocator.GetLogger();
logger?.Debug(meta.Target.Type.Name, $"Entering {meta.Target.Method.Name}");

return meta.Proceed(); // Always call the original method
}

// ❌ Bad: Aspect failure breaks the application
public override dynamic? OverrideMethod()
{
var logger = AspectServiceLocator.GetLogger()!; // Throws if null!
logger.Debug(meta.Target.Type.Name, $"Entering {meta.Target.Method.Name}");

return meta.Proceed();
}

3. Minimal Generated Code

Keep templates lean — every line of template code is duplicated into every target method:

// ✅ Good: Delegate heavy logic to a helper method
public override dynamic? OverrideMethod()
{
AuditHelper.RecordEntry(meta.Target.Type.Name, meta.Target.Method.Name);
var result = meta.Proceed();
AuditHelper.RecordExit(meta.Target.Type.Name, meta.Target.Method.Name, result);
return result;
}

// ❌ Bad: Complex logic in the template (duplicated into every target)
public override dynamic? OverrideMethod()
{
var user = Thread.CurrentPrincipal?.Identity?.Name ?? "anonymous";
var timestamp = DateTime.UtcNow.ToString("o");
var parameters = new Dictionary<string, object>();
foreach (var p in meta.Target.Parameters)
{
try { parameters[p.Name] = JsonSerializer.Serialize(p.Value); }
catch { parameters[p.Name] = p.Value?.ToString() ?? "null"; }
}
var entry = new AuditEntry(user, timestamp, meta.Target.Method.Name, parameters);
// ... 20 more lines of audit logic
// All of this is duplicated into EVERY method with [Audit]
return meta.Proceed();
}

4. Define Eligibility

Always tell users where an aspect can and cannot be applied:

public class CacheAttribute : OverrideMethodAspect
{
public override void BuildEligibility(IEligibilityBuilder builder)
{
base.BuildEligibility(builder);
builder.ReturnType().MustNotBe(typeof(void)); // Can't cache void
builder.MustNotBeAbstract(); // Can't override abstract
}
}

5. Provide Both Sync and Async Templates

Always handle both synchronous and asynchronous methods:

public class MyAspect : OverrideMethodAspect
{
public override dynamic? OverrideMethod()
{
// Synchronous path
return meta.Proceed();
}

public override async Task<dynamic?> OverrideAsyncMethod()
{
// Asynchronous path — use await
return await meta.ProceedAsync();
}
}

If you only override OverrideMethod(), Metalama wraps it for async — but explicit async handling is more correct and efficient.


Common Pitfalls

Pitfall 1: Using nameof() for Introduced Members

// ❌ Wrong: nameof resolves at ASPECT compile time, not target compile time
[Introduce]
public event PropertyChangedEventHandler? PropertyChanged;

[Template]
public void OnPropertyChanged(string name)
{
PropertyChanged?.Invoke(meta.This,
new PropertyChangedEventArgs(nameof(PropertyChanged))); // Always "PropertyChanged"!
}

// ✅ Correct: Use string literals for introduced member names
PropertyChanged?.Invoke(meta.This,
new PropertyChangedEventArgs("PropertyChanged"));

Pitfall 2: Setting Breakpoints in Source Files

// ❌ Won't work: Breakpoint in your source code
[Log]
public void DoWork() // ← Breakpoint here won't hit (code is transformed)
{
// ...
}

// ✅ Works: Breakpoint in obj/.../metalama/MyClass.cs (transformed code)
// Or use meta.DebugBreak() in the template

Pitfall 3: Forgetting partial on Target Classes

// ❌ Compile error
[NotifyPropertyChanged]
public class ViewModel { } // Missing 'partial'!

// ✅ Correct
[NotifyPropertyChanged]
public partial class ViewModel { }

Pitfall 4: Using Debugger.Break() in Templates

// ❌ Wrong: This generates Debugger.Break() in EVERY target method
public override dynamic? OverrideMethod()
{
Debugger.Break(); // This is run-time code! Ships to production!
return meta.Proceed();
}

// ✅ Correct: meta.DebugBreak() only works during compilation
public override dynamic? OverrideMethod()
{
meta.DebugBreak(); // Only triggers when debugging the compiler
return meta.Proceed();
}

Pitfall 5: Heavy Logic in Templates

// ❌ Bad: Complex serialization in template (duplicated into every method)
public override dynamic? OverrideMethod()
{
var json = JsonSerializer.Serialize(new
{
Method = meta.Target.Method.Name,
Time = DateTime.UtcNow,
Params = /* complex parameter serialization */
});
File.AppendAllText("audit.log", json);
return meta.Proceed();
}

// ✅ Good: Delegate to a helper
public override dynamic? OverrideMethod()
{
AuditHelper.LogInvocation(meta.Target.Method.Name,
meta.Target.Parameters.ToValueArray());
return meta.Proceed();
}

Pitfall 6: Storing State in Aspect Properties

// ❌ Wrong: Aspect instances don't exist at runtime
public class CounterAspect : OverrideMethodAspect
{
private int _callCount = 0; // This is a compile-time field!

public override dynamic? OverrideMethod()
{
_callCount++; // Won't work — aspect doesn't exist at runtime
return meta.Proceed();
}
}

// ✅ Correct: Use a runtime static or instance mechanism
public class CounterAspect : OverrideMethodAspect
{
public override dynamic? OverrideMethod()
{
// Use a runtime counter (static ConcurrentDictionary, etc.)
CallCounter.Increment(meta.Target.Method.Name);
return meta.Proceed();
}
}

Pitfall 7: Filtering Types by Namespace in Fabrics

// ❌ Inefficient: Iterates all types, then filters
amender.SelectMany(p => p.Types)
.Where(t => t.Namespace == "MyApp.Services")
.SelectMany(t => t.Methods)
.AddAspectIfEligible<LogAttribute>();

// ✅ Better: Use GlobalNamespace.GetDescendant() or NamespaceFabric
amender.SelectMany(p => p.GlobalNamespace
.GetDescendant("MyApp.Services")?.Types ?? Enumerable.Empty<INamedType>())
.SelectMany(t => t.Methods)
.AddAspectIfEligible<LogAttribute>();

Pitfall 8: Ignoring Async Methods

// ❌ Bug: Thread.Sleep in async context blocks the thread pool
public override dynamic? OverrideMethod()
{
for (var i = 0; i < MaxAttempts; i++)
{
try { return meta.Proceed(); }
catch when (i < MaxAttempts - 1)
{
Thread.Sleep(DelayMs); // Blocks thread pool thread!
}
}
throw new Exception("Failed");
}

// ✅ Correct: Provide separate async template with Task.Delay
public override async Task<dynamic?> OverrideAsyncMethod()
{
for (var i = 0; i < MaxAttempts; i++)
{
try { return await meta.ProceedAsync(); }
catch when (i < MaxAttempts - 1)
{
await Task.Delay(DelayMs); // Non-blocking
}
}
throw new Exception("Failed");
}

Naming Conventions

Aspect Classes

ConventionExample
Suffix with AttributeLogAttribute, CacheAttribute
Name matches the concernRetryAttribute (not MethodWrapperAttribute)
Use as [Log] (C# allows dropping Attribute suffix)[Log], [Cache], [Retry]

Aspect Properties

ConventionExample
Configuration properties are publicpublic int MaxAttempts { get; set; }
Provide sensible defaults= 3
Use descriptive namesAbsoluteExpirationSeconds (not Exp)
Boolean properties start with Is/Should/LogIsExponentialBackoffEnabled, LogCacheActivity

Aspect Organization

MyCompany.Aspects/
├── Logging/
│ ├── LogAttribute.cs
│ ├── LogPerformanceAttribute.cs
│ └── LogExceptionAttribute.cs
├── Validation/
│ ├── NotNullAttribute.cs
│ ├── NotEmptyAttribute.cs
│ └── RangeAttribute.cs
├── Caching/
│ ├── CacheAttribute.cs
│ └── CacheInvalidateAttribute.cs
├── Internal/
│ └── AspectHelper.cs ← Shared runtime helpers
└── MyCompany.Aspects.csproj

Performance Considerations

1. Keep Templates Lean

Generated code multiplied by N target methods = N × template size. Minimize template code, delegate to helpers.

2. Cache Service Lookups

// ✅ Good: Cache service reference
public override dynamic? OverrideMethod()
{
var logger = AspectServiceLocator.GetLogger(); // Cached internally
logger?.Debug(/* ... */);
return meta.Proceed();
}

3. Avoid Excessive Parameter Logging

// Consider: Do you need to log ALL parameters?
[Log(LogParameters = false)] // Skip parameter logging for performance
public async Task ProcessBulkDataAsync(List<Record> records) { ... }

4. Be Careful with Compile-Time Foreach

// The foreach is unrolled — for methods with 10 parameters,
// this generates 10 Console.WriteLine calls
foreach (var param in meta.Target.Parameters)
{
Console.WriteLine($"{param.Name} = {param.Value}");
}
// Consider: Is this acceptable for your use case?

Testing Guidelines

1. Test Every Aspect

At minimum, each aspect should have:

  • A positive test (aspect works correctly)
  • A negative test (aspect rejects invalid targets via eligibility)
  • An async test (if the aspect supports async)

2. Test Aspect Interaction

Test common aspect combinations:

[Fact]
public void Log_And_Retry_WorkTogether()
{
// Test that [Log] + [Retry] don't interfere
}

3. Test Service Unavailability

[Fact]
public void Cache_WithNoCacheService_FallsBackGracefully()
{
// Don't register ICacheService
AspectServiceLocator.Initialize(new ServiceCollection().BuildServiceProvider());

var service = new TestService();
var result = service.GetData(); // Should work without caching
}

Checklist: Before Creating a New Aspect

  1. Is there an existing aspect? Check GST.Core.Aspects and official Metalama packages first
  2. Is AOP the right tool? Would a simple base class or utility method suffice?
  3. Define eligibility — What can this aspect be applied to?
  4. Handle async — Does the aspect need separate async handling?
  5. Fail-safe — What happens when services are unavailable?
  6. Template size — Is the generated code minimal? Delegate to helpers?
  7. Configuration — What should be configurable via properties?
  8. Documentation — Add XML comments to the aspect class and properties
  9. Tests — Write snapshot and run-time tests
  10. Review — Have another developer review the aspect

Quick Reference Card

// Aspect template
public class [Name]Attribute : OverrideMethodAspect
{
// Configuration
public int MyProperty { get; set; } = defaultValue;

// Eligibility
public override void BuildEligibility(IEligibilityBuilder builder)
{
base.BuildEligibility(builder);
builder.MustNotBeAbstract();
}

// Sync template
public override dynamic? OverrideMethod()
{
// Before
try
{
var result = meta.Proceed();
// After (success)
return result;
}
catch (Exception ex)
{
// After (failure)
throw;
}
finally
{
// Always
}
}

// Async template
public override async Task<dynamic?> OverrideAsyncMethod()
{
// Before
try
{
var result = await meta.ProceedAsync();
// After (success)
return result;
}
catch (Exception ex)
{
// After (failure)
throw;
}
finally
{
// Always
}
}
}

Further Reading

ResourceURL
Metalama Documentationdoc.metalama.net
Metalama GitHubgithub.com/metalama/Metalama
Metalama Samplesgithub.com/metalama/Metalama.Samples
GST Aspect Usage GuideInternal: GST Aspect Usage Guide
GST Framework GuideInternal: GST Framework Guide

End of Metalama Technical Guide

Version 1.0 — 2026-03-31