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:

  1. Builds a delegate signature from your parameter types:
    • 1-16 parameters → Func<T1,...,T16,TReturn> or Action<T1,...,T16>
    • Async expressions → Func<Task<TReturn>> or Func<Task>
  2. Generates a complete C# source file wrapping your expression:
    public class CompiledRule {
        public bool Evaluate(Customer customer) {
            return customer.Age >= 18;
        }
    }
    
  3. Compiles to IL using CSharpCompilation with:
    • Whitelist-controlled assembly references (AssemblyReferenceProvider)
    • Optimized release build
    • In-memory assembly (no disk output)
  4. Loads into collectible ALC:
    var alc = new ExpressionAssemblyLoadContext();
    var assembly = alc.LoadFromStream(ilStream);
    
  5. 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 (manual Unload() only)

Dependency Resolution

DependsOnRuleId creates a DAG (directed acyclic graph):

Rule A (validate) ─┐
                   ├──→ Rule C (process)
Rule B (check) ────┘
  1. Topological sort (Kahn’s algorithm) determines execution order
  2. Dependencies execute before dependent rules
  3. Results are stored in RuleContext for downstream access
  4. 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


Back to top

MIT License. Built with Roslyn + Typed Delegates.