RoslynRules Architecture
How the engine turns C# expressions into compiled delegates.
Overview
Expression string
↓
[C# Syntax Tree] → Syntax validation
↓
[C# Compilation] → Roslyn.Emit → IL assembly
↓
[AssemblyLoadContext] → Load into collectible ALC
↓
[Delegate] → Typed Func<T1,...,T16,TReturn>
↓
[Execution] → Direct invocation (no reflection, no interpreters)
The Compilation Pipeline
1. Expression Parsing
RoslynRules parses your expression into a proper C# syntax tree using Microsoft.CodeAnalysis.CSharp:
var tree = CSharpSyntaxTree.ParseText("customer.Age >= 18");
This catches syntax errors immediately — missing semicolons, unmatched parentheses, invalid operators — before any compilation cost is incurred.
2. Semantic Validation (Optional)
Rule.ValidateSemantics() runs a lightweight semantic analysis using Roslyn’s CSharpCompilation without emitting IL:
// Validates that "customer" resolves to a known type
Rule.ValidateSemantics("customer.Age >= 18", typeof(Customer), "customer");
This catches:
- Undefined variables
- Missing type members
- Type mismatch errors
3. Full Compilation
When you call Workflow.Compile(), the workflow compiles each of its rules. For every rule, RoslynRules:
- Builds a delegate signature from your parameter types:
- 1-16 parameters →
Func<T1,...,T16,TReturn>orAction<T1,...,T16> - Async expressions →
Func<Task<TReturn>>orFunc<Task>
- 1-16 parameters →
- Generates a complete C# source file wrapping your expression:
public class CompiledRule { public bool Evaluate(Customer customer) { return customer.Age >= 18; } } - Compiles to IL using
CSharpCompilationwith:- Whitelist-controlled assembly references (
AssemblyReferenceProvider) - Optimized release build
- In-memory assembly (no disk output)
- Whitelist-controlled assembly references (
- Loads into collectible ALC:
var alc = new ExpressionAssemblyLoadContext(); var assembly = alc.LoadFromStream(ilStream); - Extracts the delegate via reflection (once, at compile time only):
var method = assembly.GetType("CompiledRule").GetMethod("Evaluate"); var del = method.CreateDelegate(typeof(Func<Customer, bool>));
4. Caching
Compiled delegates are cached in a ConcurrentDictionary<CacheKey, Delegate>:
- Key: Expression string + parameter types + additional namespaces
- Value: Compiled delegate
- Lifetime: Application lifetime (or until
compiler.Unload())
Subsequent calls with the same signature return the cached delegate instantly.
Execution Model
The compiled delegate held by each rule is an internal implementation detail (the CompiledDelegate hierarchy is internal). Single-parameter rules use a direct typed invoke; multi-parameter rules dispatch via DynamicInvoke. The snippets below show the public execution surface that drives that machinery.
Sequential
foreach (var result in workflow.Execute(parameters))
{
// Results are yielded in dependency order.
}
Parallel
RuleResult[] results = workflow.ExecuteParallel(parameters);
Async
await foreach (var result in workflow.ExecuteAsync(parameters))
{
// Awaitable rules run asynchronously.
}
AssemblyLoadContext Lifecycle
Collectible ALCs prevent memory leaks in long-running apps:
Compilation #1 → ALC #1
Compilation #2 → ALC #1 (same)
...
Compilation #1000 → ALC #1
Compilation #1001 → ALC #2 (new, ALC #1 marked for GC)
Tuning:
var compiler = new ExpressionCompiler(maxCompilesBeforeRecycle: 1000);
- Lower = more frequent unloading, higher GC pressure
- Higher = less overhead, more memory stable
0= never auto-recycle (manualUnload()only)
Dependency Resolution
DependsOnRuleId creates a DAG (directed acyclic graph):
Rule A (validate) ─┐
├──→ Rule C (process)
Rule B (check) ────┘
- Topological sort (Kahn’s algorithm) determines execution order
- Dependencies execute before dependent rules
- Results are stored in
RuleContextfor downstream access - Cycles are detected at
Validate()time
Result Caching (Memoization)
Per-rule opt-in caching:
var rule = new Rule {
CacheDuration = TimeSpan.FromMinutes(5)
};
- Cache key = rule ID + all parameter values (deep hash)
- Thread-safe via
ConcurrentDictionary - Lazy expiration on read (no background timer)
ClearCache()forces eviction
Thread Safety
| Component | Strategy |
|---|---|
ExpressionCompiler | ConcurrentDictionary cache, lock-free reads |
Workflow.Execute() | Rule-level parallel, result collection synchronized |
Rule.Execute() | Immutable after Compile(), no locks needed |
RuleCache | ConcurrentDictionary + Interlocked metrics |
RuleContext | ConcurrentDictionary for result storage |
Performance Characteristics
| Phase | Time | Notes |
|---|---|---|
| First compile | ~50ms | Roslyn compilation + ALC creation |
| Cached compile | ~1μs | Dictionary lookup |
| Execution | ~10ns | Direct delegate call |
| Parallel execution | ~10ns × rules / cores | Near-linear scaling |
Related
- Performance Tuning — Configure for your workload
- Security — Assembly sandboxing and threat model
- API Reference — Class-level documentation