Rubric is a .NET library providing synchronous and asynchronous rule engines.
Features include:
- parallel rule and item processing (for asynchronous engines)
- rule dependency resolution
- three independent stages of rules (preprocessing, processing, and postprocessing)
- convenient dependency injection options and a fluent builder interface
- short-circuiting exceptions to halt the processing of an item or the entire engine
- user-injected exception handling
A rule engine is essentially a version of the strategy pattern where the strategies and their selections are combined into composable rules. It provides a framework for consistently implementing complex code in SOLID architectures, while avoiding traditional problems of what class should "own" functionality. This excellent series of blog posts by Eric Lippert lays out the sorts of complex domain problems that can be solved by a rule engine.
Engines are created from rules and apply them to one or several input objects and potentially an output object. Synchronous engines run their rules sequentially as determined by their dependency ordering. Asynchronous engines can execute in parallel, automatically determining what rules can be run in parallel given their dependencies, and process items in parallel as well, and allow for asynchronous rule operations.
Engine contexts act as a holder of convenient properties about the engine current executing and as a per-execution temporary object stash where rules can loosely communicate with each other. When using parallelized processing in asynchronous engines, the rule execution order is not guaranteed unless dependency relationships specify them: be aware of possible race conditions when dealing with the context.
Rules come in three type, in both synchronous and asynchronous varieties. Engines will execute the rules in this order, and all rules of one type are executed before the next type is run:
- Preprocessing rules are conditionally applied to an input object.
- Processing rules conditionally apply an input object to the output object.
- Postprocessing rules conditionally apply to the output object.
All rules are constructed from two implemented (or fluently provided) methods:
DoesApplyis the predicate function that dynamically determines whether the rule runs.Applyis the processing method that applies the rule.
Rules can be either implemented as classes with declarative dependencies, or built via fluent builders with explicit dependencies. Engine constructors are also provided for convenient usage with dependency injection libraries, and are highly recommended for most scenarios. A companion library is provided for integration with the Microsoft.Extensions.DependencyInjection library.
public class MyDefaultPreprocessingRule<MyInputType> : DefaultPreRule<MyInputType>
{
public void Apply(IEngineContext context, MyInputType input)
{
//Unconditionally normalize or preprocess each input object with this rule
}
}
public class MyRule<MyInputType, MyOutputType> : DefaultRule<MyInputType, MyOutputType>
{
public void DoesApply(IEngineContext, MyInputType input, MyOutputType output)
{
//Determine whether to execute this rule
}
public void Apply(IEngineContext context, MyInputType input, MyOutputType output)
{
//Apply this rule if does apply returns true for the input and output
}
}
public class MyDefaultPostRule<MyOutputType> : DefaultRule<MyOutputType> {
public void Apply(IEngineContext context, OutputType output) {
//Unconditionally apply this rule to the output for postprocessing
}
}
//Ensure this always executes after MyDefaultPostRule
[DependsOn(typeof(MyDefaultPostRule))]
public class MyDefaultPostRule2<MyOutputType> : DefaultRule<MyOutputType> {
public void Apply(IEngineContext context, OutputType output) {
//Unconditionally apply this rule to the output
}
}
public MyOutputType ProcessInputsWithDirectConstruction(IEnumerable<MyInputType> inputs) {
var engine = new RulesEngine<MyInputType, MyOutputType>(
new [] { new MyDefaultPreprocessingRule() },
new [] { new MyProcessingRule() },
new [] {
new MyDefaultPostProcessingRule2(),
new MyDefaultPostPreprocessingRule()
}
)
var output = new MyOutputType(...);
var context = new EngineContext();
//Inject any global flags or parameters into context
rulesEngine.Apply(inputs, output, context);
}
public MyOutputType ProcessInputsWithFluentConstruction(IEnumerable<MyInputType> inputs) {
var engine = EngineBuilder.ForInputAndOutput<MyInputType, MyOutputType>()
.WithPreRule(new MyDefaultPreprocessingRule())
.WithRule(new MyDefaultProcessingRule())
//Even though we add the 2nd rule first, it will run second
//due to the dependency attribute
.WithPostRule(new MyDefaultPostPreprocessingRule2()}
.WithPostRule(new MyDefaultPostPreprocessingRule()}
.Build();
var output = new MyOutputType(...);
var context = new EngineContext();
//Inject any global flags or parameters into context
rulesEngine.Apply(inputs, output, context);
}public MyOutputType ProcessInputsWithFluentConstruction(IEnumerable<MyInputType> inputs) {
var engine = EngineBuilder.ForInputAndOutput<MyInputType, MyOutputType>()
.WithPreRule("mydefaultprerule")
.WithPredicate((context, input) => true)
.WithAction((context, input) => {...})
.EndRule()
//Executes after MyDefaultRule, since it depends
//on a defined dependency it provides
.WithRule("MyDefaultRule2")
.WithPredicate((context, input) => true)
.WithAction((context, input, output) => {...})
.DependsOn("MyDependency");
.EndRule()
//Executes after both other rules, since it depends
//on the first rule by name, and is added after the second rule
.WithRule("MyDefaultRule3")
.WithPredicate((context, input) => true)
.WithAction((context, input, output) => {...})
.DependsOn("MyDefaultRule");
.EndRule()
.WithRule("MyDefaultRule")
.WithPredicate((context, input) => true)
.WithAction((context, input, output) => {...})
.Provides("MyDependency");
.EndRule()
.WithPostRule("MyDefaultPostRule")
.WithPredicate((context, output) => true)
.WithAction((context, output) => {...})
.EndRule()
.Build();
var output = new MyOutputType(...);
var context = new EngineContext();
//Inject any global flags or parameters into context
rulesEngine.Apply(inputs, output, context);
}Asynchronous engine composition follows analogously.
The library provides two special exceptions: ItemHaltException and EngineHaltException. When thrown from any rule, the engine will handle the exception by either halting further rule execution on the current item,
or halting the engine's execution entirely. The exception will be decorated and placed in the engine context's LastEngineException property before the next item is executed, or the engine exits.
When an non-engine exception is thrown during execution, the exception is wrapped and passed to an optionally provided exception handler. Users may throw one of the engine exceptions above and they will be handled appropriately. If not handled, the exception will escape the engine to be handled by the user.
In asynchronous engines, all the above statements apply, except that if one is executing either rules (for ItemHaltException) or inputs (for EngineHaltException) in parallel, the engine will attempt to cancel all other tasks being executed in parallel. Long running rules can check the cancellation token (or pass it to other async functions) to allow graceful exiting. The engine guarantees that:
- As soon as any
*HaltExceptionis encountered, any parallelized tasks have their cancellation requested through their token. - All internal parallelized tasks in the engine check the cancellation token before executing each piece of user code, and will exit as soon as possible.
TaskCancelledExceptions caused by user-cancellation of the provided token will not be processed by the exception handling mechanism. The user should handle this exception appropriately. Uncaught TaskCancellationExceptions not arising from the same token provided by the user will be processed as any other normal exception.
The engines accept an optional Microsoft.Extensions.Logging.Abstractions.ILogger instance and will output trace statements as they execute user code. Engines will set context information in the logger about the execution status, including information about executions, rules, and objects being processed. This logger can be accessed via the context in rule implementations.
This project is licensed under the MIT license. See the LICENSE file for more info.