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
| Convention | Example |
|---|---|
Suffix with Attribute | LogAttribute, CacheAttribute |
| Name matches the concern | RetryAttribute (not MethodWrapperAttribute) |
Use as [Log] (C# allows dropping Attribute suffix) | [Log], [Cache], [Retry] |
Aspect Properties
| Convention | Example |
|---|---|
| Configuration properties are public | public int MaxAttempts { get; set; } |
| Provide sensible defaults | = 3 |
| Use descriptive names | AbsoluteExpirationSeconds (not Exp) |
Boolean properties start with Is/Should/Log | IsExponentialBackoffEnabled, 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
- Is there an existing aspect? Check GST.Core.Aspects and official Metalama packages first
- Is AOP the right tool? Would a simple base class or utility method suffice?
- Define eligibility — What can this aspect be applied to?
- Handle async — Does the aspect need separate async handling?
- Fail-safe — What happens when services are unavailable?
- Template size — Is the generated code minimal? Delegate to helpers?
- Configuration — What should be configurable via properties?
- Documentation — Add XML comments to the aspect class and properties
- Tests — Write snapshot and run-time tests
- 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
| Resource | URL |
|---|---|
| Metalama Documentation | doc.metalama.net |
| Metalama GitHub | github.com/metalama/Metalama |
| Metalama Samples | github.com/metalama/Metalama.Samples |
| GST Aspect Usage Guide | Internal: GST Aspect Usage Guide |
| GST Framework Guide | Internal: GST Framework Guide |
End of Metalama Technical Guide
Version 1.0 — 2026-03-31