Rule Engine Guide
The Muonroi Rule Engine is a dependency-aware execution pipeline that evaluates and executes rules in topologically sorted order, sharing a single fact bag across all rules. It separates condition evaluation (Phase 1) from side effects (Phase 2), enabling safe composition, auditability, and compensation strategies.
What is the Rule Engine?
The Rule Engine orchestrates the execution of multiple rules (IRule<TContext>), automatically resolving dependencies via depth-first topological sort (Kahn's algorithm). For each rule that matches execution criteria:
- Quota Check — validates tenant concurrency and evaluation limits
- EvaluateAsync (Phase 1) — evaluate conditions, compute output facts (pure, side-effect-free)
- ExecuteAsync (Phase 2) — run side effects (DB writes, API calls) only if evaluation passed
- Telemetry — record rule metrics via OpenTelemetry
The shared FactBag dictionary flows through the entire pipeline, allowing rules to read facts set by prior rules and write new facts for downstream rules.
Core Architecture
RuleOrchestrator Pipeline
RuleOrchestrator<TContext>
│
├─ ExecuteAsync(context, filterPoint?, cancellationToken)
│ │
│ ├─ Create FactBag (single instance)
│ ├─ Order rules via DFS topological sort
│ │
│ └─ FOR EACH rule (in dependency order):
│ │
│ ├─ Check quota (concurrent executions, evals/sec)
│ ├─ Fire IHookHandler.BeforeRule
│ ├─ Call rule.EvaluateAsync(context, factBag, ct) [Phase 1]
│ │ ├─ Evaluate condition
│ │ └─ Write facts (facts.Set<T>(key, value))
│ │
│ ├─ [if EvaluateAsync succeeded]
│ │ └─ Call rule.ExecuteAsync(context, ct) [Phase 2]
│ │ └─ Side effects run here
│ │
│ ├─ Fire IHookHandler.AfterRule / Error
│ └─ Record telemetry
│
└─ Return FactBag
FactBag: Shared State
The FactBag is a thread-safe Dictionary<string, object?> wrapper passed to every rule. It holds:
- Input context facts — copied from the execution context
- Output facts — computed by rules during Phase 1
- Graph keys — special
__graph.node.{nodeId}.*keys for flow-graph tracking
Key methods:
public class FactBag
{
public T? Get<T>(string key); // Retrieve + auto-coerce
public bool TryGet<T>(string key, out T? value); // Safe retrieval
public void Set<T>(string key, T value); // Write fact
public bool Remove(string key); // Remove fact
public IReadOnlyDictionary<string, object?> AsReadOnly(); // Snapshot
}
Auto-coercion: Get<T>() handles JsonElement conversion (useful when external engines like Microsoft RulesEngine feed facts as JSON).
Two-Phase Execution (Critical Design)
Every rule follows a strict two-phase pattern to separate pure evaluation from side effects:
Phase 1: EvaluateAsync — Pure Condition + Output
Executed unconditionally for every rule. Must not perform side effects (DB writes, API calls).
public Task<RuleResult> EvaluateAsync(TContext ctx, FactBag facts, CancellationToken ct)
{
// Phase 1: Pure evaluation only
bool isValid = ctx.Amount > 0 && ctx.Items.Count > 0;
facts.Set("order.valid", isValid);
if (!isValid)
return Task.FromResult(RuleResult.Fail("Order amount must be > 0"));
return Task.FromResult(RuleResult.Pass());
}
Phase 2: ExecuteAsync — Side Effects Only
Runs only if Phase 1 succeeded (RuleResult.Pass()). This is where you write to databases, call external APIs, send notifications.
public Task ExecuteAsync(TContext context, CancellationToken cancellationToken = default)
{
// Phase 2: Side effects only (runs only if EvaluateAsync passed)
return _orderRepository.SaveAsync(context.Order, cancellationToken);
}
Why separate?
- Auditability — you can trace which conditions would have fired without committing side effects
- Dry-run testing — run Phase 1 only to preview results
- Compensation — revert Phase 2 actions (via
ICompensatableRule<TContext>) without re-evaluating - Safety — prevent partial writes from failed conditions
Execution Modes
Control how the orchestrator handles rule failures via the ExecutionMode enum:
AllOrNothing (default)
Stops on the first failure. No compensation. Fastest for fail-fast scenarios.
services.AddMRuleEngine(options => {
options.ExecutionMode = ExecutionMode.AllOrNothing;
});
Behavior:
- Rule A succeeds → Rule B fails → stop, return failure
- No Phase 2 for rules after the failure
BestEffort
Continues executing all rules despite failures. Aggregates all errors. Best for batch processing.
services.AddMRuleEngine(options => {
options.ExecutionMode = ExecutionMode.BestEffort;
});
Behavior:
- Rule A fails → continue to Rule B → Rule B succeeds → continue
- Returns all facts + combined errors
CompensateOnFailure
Stops on failure, then reverses Phase 2 actions of previously executed rules in LIFO order (reverse dependency order).
Requires rules to implement ICompensatableRule<TContext>:
public interface ICompensatableRule<in TContext> : IRule<TContext>
{
Task CompensateAsync(TContext context, CancellationToken cancellationToken);
}
Example:
public class CreateOrderRule : ICompensatableRule<OrderContext>
{
public Task ExecuteAsync(OrderContext ctx, CancellationToken ct)
{
return _db.InsertOrderAsync(ctx.Order, ct); // Phase 2
}
public Task CompensateAsync(OrderContext ctx, CancellationToken ct)
{
return _db.DeleteOrderAsync(ctx.Order.Id, ct); // Undo Phase 2
}
}
Dependency Resolution
Rules declare dependencies via IRule<TContext> properties:
public interface IRule<in TContext>
{
string Code { get; } // Unique code
int Order { get; } => 0; // Execution order (same level)
IReadOnlyList<string> DependsOn { get; } // Rule codes this depends on
IEnumerable<Type> Dependencies { get; } // Types this depends on
HookPoint HookPoint { get; } => HookPoint.BeforeRule;
}
Orchestration flow:
- Collect all rules + their dependencies
- Build dependency graph
- Run DFS topological sort (Kahn's algorithm)
- Detect cycles → throw
InvalidOperationException - Execute in topologically sorted order
Example:
public class ValidateOrderRule : IRule<OrderContext>
{
public string Code => "validate-order";
public int Order => 1;
public IReadOnlyList<string> DependsOn => [];
}
public class CreateOrderRule : IRule<OrderContext>
{
public string Code => "create-order";
public int Order => 2;
public IReadOnlyList<string> DependsOn => ["validate-order"]; // Must run after validate-order
}
Code Examples
Minimal Rule
using Muonroi.RuleEngine.Abstractions;
public class OrderValidationRule : IRule<OrderContext>
{
public string Code => "order-validation";
public Task<RuleResult> EvaluateAsync(OrderContext ctx, FactBag facts, CancellationToken ct)
{
if (ctx.Amount <= 0)
return Task.FromResult(RuleResult.Fail("Amount must be positive"));
facts.Set("order.valid", true);
return Task.FromResult(RuleResult.Pass());
}
}
Rule with Side Effects
public class CreateOrderRule : IRule<OrderContext>
{
private readonly IOrderRepository _repo;
public CreateOrderRule(IOrderRepository repo) => _repo = repo;
public string Code => "create-order";
public IReadOnlyList<string> DependsOn => ["order-validation"];
public Task<RuleResult> EvaluateAsync(OrderContext ctx, FactBag facts, CancellationToken ct)
{
if (!facts.TryGet<bool>("order.valid", out var valid) || !valid)
return Task.FromResult(RuleResult.Fail("Order not valid"));
return Task.FromResult(RuleResult.Pass());
}
public async Task ExecuteAsync(OrderContext context, CancellationToken ct)
{
// Phase 2: This runs only if Phase 1 passed
var orderId = await _repo.InsertOrderAsync(context.Order, ct);
// Optionally write result facts for downstream rules
}
}
Rule with Compensation
public class SendNotificationRule : ICompensatableRule<OrderContext>
{
private readonly INotificationService _notificationService;
public SendNotificationRule(INotificationService service) => _notificationService = service;
public string Code => "send-notification";
public Task<RuleResult> EvaluateAsync(OrderContext ctx, FactBag facts, CancellationToken ct)
{
return Task.FromResult(RuleResult.Pass());
}
public Task ExecuteAsync(OrderContext context, CancellationToken ct)
{
return _notificationService.SendOrderConfirmationAsync(context.Order.Id, ct);
}
public Task CompensateAsync(OrderContext context, CancellationToken ct)
{
// Called if a later rule fails (CompensateOnFailure mode)
return _notificationService.SendOrderCancelledAsync(context.Order.Id, ct);
}
}
Dependency Injection & Registration
Basic Setup
var builder = WebApplicationBuilder.CreateBuilder(args);
// Rule engine with PostgreSQL storage (for runtime rulesets)
builder.Services.AddMRuleEngineWithPostgres(
connectionString: configuration.GetConnectionString("Default"),
options =>
{
options.RequireApproval = true;
options.NotifyOnStateChange = true;
options.ExecutionMode = ExecutionMode.BestEffort;
});
// Optional: Enable Redis-backed hot reload
builder.Services.AddMRuleEngineWithRedisHotReload(
configuration.GetConnectionString("Redis"));
// Optional: FEEL expression support
builder.Services.AddFeelWeb();
Code-First Rules
Use the RuleGen CLI tool with [MExtractAsRule(...)] attributes:
[MExtractAsRule(Namespace = "MyApp.Rules", ClassName = "GeneratedOrderRules")]
public class OrderService
{
[MRule(Code = "calc-discount", Order = 10)]
public decimal CalculateDiscount(decimal amount)
{
return amount > 1000 ? amount * 0.1m : 0;
}
}
// After RuleGen runs, a class is generated:
// GeneratedOrderRules : IRule<RuleContext> { ... }
Register Rules in DI
builder.Services.AddScoped<ValidateOrderRule>();
builder.Services.AddScoped<CreateOrderRule>();
builder.Services.AddScoped<SendNotificationRule>();
Usage: Executing Rules
Using RulesEngineService (Runtime Rulesets)
public class OrderService
{
private readonly RulesEngineService _rulesEngine;
public OrderService(RulesEngineService rulesEngine)
=> _rulesEngine = rulesEngine;
public async Task<OrderResult> ProcessOrderAsync(Order order)
{
var context = new OrderContext { Order = order };
// Dry-run: evaluate without side effects
var dryRun = await _rulesEngine.DryRunAsync("order-workflow", context);
if (!dryRun.IsSuccess)
throw new InvalidOperationException(dryRun.ErrorSummary);
// Execute: both Phase 1 + Phase 2
var result = await _rulesEngine.ExecuteAsync("order-workflow", context);
return new OrderResult
{
Success = result.IsSuccess,
OrderId = dryRun.OutputFacts.Get<string>("order.id"),
Traces = result.Traces
};
}
}
Using RuleOrchestrator<TContext> (Code-First)
public class OrderProcessor
{
private readonly RuleOrchestrator<OrderContext> _orchestrator;
public OrderProcessor(RuleOrchestrator<OrderContext> orchestrator)
=> _orchestrator = orchestrator;
public async Task<FactBag> ProcessAsync(Order order, CancellationToken ct)
{
var context = new OrderContext { Order = order };
// Execute all rules in dependency order
var facts = await _orchestrator.ExecuteAsync(context, cancellationToken: ct);
return facts;
}
}
Quota Enforcement
Multi-tenant quota is automatically enforced during execution:
| Quota Type | Description | Default Limit |
|---|---|---|
ConcurrentExecutions | Max parallel rule executions per tenant | 10 |
RuleEvaluationsPerSecond | Max evals per second per tenant | 100 |
RuleExecutionsPerDay | Max total evals per day per tenant | 100,000 |
If quota is exceeded, QuotaExceededException is thrown and Phase 2 is not executed.
// Example: Inject ITenantQuotaTracker to check quotas manually
public class MyService(ITenantQuotaTracker quotaTracker)
{
public async Task CheckQuotaAsync(string tenantId)
{
bool allowed = await quotaTracker.CheckQuotaAsync(
tenantId,
QuotaType.RuleEvaluationsPerSecond,
5, // Request 5 evals
CancellationToken.None);
if (!allowed)
throw new QuotaExceededException();
}
}
Hooks & Events
Rules support hook points for cross-cutting concerns (logging, metrics, validation):
public enum HookPoint
{
BeforeRule, // Before EvaluateAsync
AfterRule, // After ExecuteAsync
Error // If exception occurs
}
public interface IHookHandler<in TContext>
{
Task HandleAsync(
HookPoint point,
IRule<TContext> rule,
RuleResult result,
FactBag facts,
TContext context,
TimeSpan? duration,
CancellationToken token);
}
Example:
public class TelemetryHook : IHookHandler<OrderContext>
{
private readonly ITelemetryService _telemetry;
public async Task HandleAsync(HookPoint point, IRule<OrderContext> rule, RuleResult result,
FactBag facts, OrderContext context, TimeSpan? duration, CancellationToken token)
{
if (point == HookPoint.AfterRule && result.IsSuccess)
{
await _telemetry.RecordAsync(new RuleMetric
{
RuleName = rule.Name,
DurationMs = duration?.TotalMilliseconds ?? 0,
Success = true
});
}
}
}
Telemetry & Tracing
Rules emit OpenTelemetry metrics and distributed traces automatically:
- Meter:
RuleEngineTelemetry.RulesMatched(counter) - Meter:
RuleEngineTelemetry.RuleEvalDuration(histogram) - Activity: Named after rule
Name, tagged withrule.id,rule.set.version,tenant.id
Configure exporter in Program.cs:
builder.Services
.AddOpenTelemetry()
.WithTracing(b => b
.AddSource(RuleEngineTelemetry.ActivitySourceName)
.AddConsoleExporter()
.AddJaegerExporter())
.WithMetrics(b => b
.AddMeter(RuleEngineTelemetry.MeterName)
.AddPrometheusExporter());
Best Practices
- Keep EvaluateAsync Pure — no DB writes, API calls, or mutations
- Write Facts Consistently — use kebab-case keys like
order.valid,customer.tier - Declare Dependencies Explicitly — do not rely on rule ordering alone
- Compensate Stateful Operations — implement
ICompensatableRule<TContext>for CreateOrderRule, SendNotificationRule - Test Phase 1 & 2 Separately — verify conditions work independently from side effects
- Use Dry-Run Before Activation — validate rule logic via
/api/v1/rulesets/{workflow}/dry-run - Monitor Quota Usage — alert when tenants approach limits
See Also
- Decision Table Guide — runtime-managed rules with FEEL
- Rule Engine Advanced Patterns — custom evaluators, flow graphs
- FEEL Reference — expression syntax for decision tables
- Control Plane API — ruleset CRUD + dry-run endpoints