Slickflow Ruleset Integration with NRules: From Design to Production Implementation
Background and Objectives
In workflow engines, the Business Rule Task requires extracting "business rules" from flowcharts: rules must be reusable, versionable, and independently testable, while BPMN handles only selecting which ruleset and passing process variables to the rules.
This implementation establishes a Ruleset feature within Slickflow and integrates the open-source rules engine NRules, supporting both Assembly Rules (strongly-typed C# Rules) and JSON DSL Rules (declarative when/outputs) simultaneously. Both forms unify under the database table wf_rule_set, executed by RuleSetExecutionManager according to pattern scheduling.
Overall Architecture: Three-Layer Decoupling of Data, Engine, and Modeling
| Layer | Responsibility |
|---|---|
Repository wf_rule_set | Ruleset code, name, mode, rule_content, enabled status |
Execution RuleSetExecutionManager | Parses rule_content by mode: NRules or JSON DSL |
| Process Modeling BPMN Extension | RuleTask binds only ruleSetCode; runtime loads complete definition from database |
This approach decouples the rule body from process definition XML: the same ruleset can be referenced by multiple processes/nodes; updating rules primarily modifies table data or publishes process versions rather than repeatedly changing rule content within BPMN.
Leave Approval Process: Rule Tasks and Exclusive Gateway
Let's clarify how "results computed by rulesets drive BPMN" using a leave approval business scenario. The process contains only one ruleset (such as binding LeaveApproveRule or equivalent DSL). The rules internally output ApprovalLevel according to three mutually exclusive scenarios. An exclusive gateway then follows three outgoing paths based on this variable, corresponding to three approval routes—consistent with Leader/Manager/HR tiers in LeaveApproveRule.
Participant and Variable Conventions
When an employee submits leave, the process already has (or receives via form) LeaveDays (number of days) and LeaveType (type, such as personal leave, sick leave, etc.). Before the rule task executes, these quantities serve as rule inputs. After execution, the rules write ApprovalLevel to process variables for gateway condition references.
Node Sequence (Top-to-Bottom, Consistent with Typical BPMN Swimlane Diagrams)
Start Event: Process instance creation enters the leave subprocess.
User Task: Fill/Submit Leave Application: The applicant confirms days and type; data falls into process variables (and if necessary, wf_variable-declared Input for rule task reference).
Business Rule Task: Binds the leave ruleset (ruleSetCode) from wf_rule_set. The engine calls RuleExecutor → RuleSetExecutionManager, executing NRules or JSON DSL according to mode. The sole output routing key is ApprovalLevel.
Exclusive Gateway: Uses ApprovalLevel as the sole criterion with three mutually exclusive outgoing paths—only one activates per instance:
- Branch One:
ApprovalLevel == "Leader"→ User Task "Direct Leader Approval" (short leave, routine scenarios handled by team lead/supervisor) - Branch Two:
ApprovalLevel == "Manager"→ User Task "Department Manager Approval" (e.g., days exceeding 3 without falling into HR tier) - Branch Three:
ApprovalLevel == "HR"→ User Task "HR Approval" (e.g., exceeding 7 days or sick leave requiring HR filing)
Convergence: After three approval tasks complete, they converge via exclusive gateway or parallel merge structure (choose one based on modeling conventions) to the same subsequent path (such as "Notify Applicant of Result" or "Archive").
End Event: Process completion.
Key Point: The three conditions on the gateway don't write three separate "business rules" again—they consume the same variable already written by the rule task. The "three rules" manifest as branching judgments within the ruleset itself on LeaveDays and LeaveType (three if/else if/else segments in NRules' ApplyApproval, or multiple when clauses in DSL). The flowchart side maintains one rule task plus one gateway with three branches—clear responsibilities, easy to test and modify.
Two Execution Modes for Rulesets
Mode One: NRules Assembly Rules
The rule_content is JSON containing a ruleTypes array with elements as assembly-qualified names (consistent with NRules Rule derived classes).
Runtime Process:
- RuleRepository loads types and compiles sessions
- Injects RuleInputFact (variable dictionary) and RuleOutputFact (output dictionary)
session.Fire()executes rules, retrieving results from output dictionary (such as ApprovalLevel)
Suitable For: Complex branching requiring strong typing and unit testing, reuse with existing .NET business rule classes.
Below is the complete NRules rule class example corresponding to "leave approval level" (the engine loads this class via the assembly-qualified name in ruleTypes; RuleInputFact/RuleOutputFact are injected by the engine, variables read from input.Vars, results written to output):
using NRules.Fluent.Dsl;
using Slickflow.Engine.Business.Entity;
using System.Globalization;
namespace Slickflow.Module.BusinessRule.Approval
{
public class LeaveApproveRule : Rule
{
public override void Define()
{
RuleInputFact input = null!;
RuleOutputFact output = null!;
When()
.Match(() => input)
.Match(() => output);
Then()
.Do(_ => ApplyApproval(input, output));
}
private static void ApplyApproval(RuleInputFact input, RuleOutputFact output)
{
var days = 0;
var leaveType = string.Empty;
if (input.Vars.TryGetValue("LeaveDays", out var d) && d != null)
{
var rawDays = Convert.ToString(d, CultureInfo.InvariantCulture);
if (!string.IsNullOrWhiteSpace(rawDays)
&& int.TryParse(rawDays, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedDays))
{
days = parsedDays;
}
}
if (input.Vars.TryGetValue("LeaveType", out var t) && t != null)
leaveType = t.ToString() ?? string.Empty;
if (days > 7 || leaveType.Equals("Sick", StringComparison.OrdinalIgnoreCase))
output.Set("ApprovalLevel", "HR");
else if (days > 3)
output.Set("ApprovalLevel", "Manager");
else
output.Set("ApprovalLevel", "Leader");
}
}
}Mode Two: JSON DSL Rules
Here mode = bindingsJson indicates rule_content contains JSON DSL, distinguished from the similarly-named "variable binding" concept previously appearing on BPMN. Task nodes no longer carry large binding JSON blocks, avoiding confusion with wf_rule_set.rule_content.
The rule_content takes the following form:
- Top-level
stopOnFirstMatchandrulesarray - Each rule contains name, when (condition expression), and outputs (output key-value pairs)
- Matches according to array order; when
stopOnFirstMatch: true, stops after first match
Suitable For: Scenarios with frequent rule changes, desire for fewer releases, direct JSON modification by operations/implementation teams.
DSL Mode Example
Stored in wf_rule_set.rule_content with mode as bindingsJson (variable names LeaveDays, LeaveType, ApprovalLevel consistent with process/rule input conventions):
{
"stopOnFirstMatch": true,
"rules": [
{
"name": "ShortSickLeave",
"when": "LeaveDays <= 3 || LeaveType == \"Sick\"",
"outputs": {
"ApprovalLevel": "Leader"
}
},
{
"name": "LongLeave",
"when": "LeaveDays > 3",
"outputs": {
"ApprovalLevel": "Manager"
}
}
]
}Explanation: The branching logic in this example need not completely match LeaveApproveRule.cs from the previous section—the former uses declarative conditions for rapid iteration, while the latter implements finer branching using C# (such as sick leave, exceeding 7 days routing to HR). Products can maintain both implementations separately for the same business domain or gradually align semantics.
Integration Path with Slickflow Product
Engine Side: RuleExecutor → RuleSetExecutionManager
When RuleExecutor (Slickflow.Engine) executes RuleTask:
- Retrieves entity from
wf_rule_setusing ruleSetCode - Assembles rule input dictionary, calls
RuleSetExecutionManager.Execute - Writes output back to
wf_process_variable(activity scope) for subsequent gateways and tasks
Besides ruleSetCode in BPMN, RuleConfigDetail can merge table fields (name, description, rule_content, mode, etc.) during process parsing for debugging and API display. Execution still prioritizes database tables, avoiding inconsistency between BPMN and database.
Variable Input: Process Variables Plus wf_variable Definitions
Rule inputs must align with process data:
- Process-level/activity-level
wf_process_variableprovides runtime values wf_variabledescribes variable definitions on current activity: direction, whether referencing preceding nodes, source_ref/source_variable_name, etc.
When RuleTask assembles inputs: first respect Input declared in wf_variable—reference-type inputs resolve from preceding activity instances' wf_process_variable according to references; if undeclared, fall back to merging activity variables (compatibility with old processes). When declared inputs temporarily lack values, write empty strings to avoid issues where JSON DSL conditions like LeaveType == "Sick" never trigger due to missing keys.
Mode Enumeration and API
Use RuleSetModeEnum (ruleTypes/bindingsJson) with RuleSetModeHelper for persistent string-to-enum conversion, avoiding scattered magic strings.
The sfdapi RuleSetController validates mode and rule_content shape during save, ensuring stored data can be executed by the engine.
Designer (sfd)
Enter Business Rule Settings via the Modeling menu to maintain ruleset lists and rule_content. Select ruleset code in RuleTask properties, consistent with the engine's read path.
Development Process Review: Key Decisions
Rules Separated from Processes: Rules concentrate in wf_rule_set; BPMN stores only ruleSetCode, reducing model volume and merge conflicts.
Dual-Mode Coexistence: NRules suits engineered rule classes; JSON DSL suits lightweight, rapid adjustments. Mode explicitly branches, avoiding implicit guessing.
Task Side No Longer Carries "Rule Binding JSON": Variable sources shift to process database tables and naming conventions, reducing duplicate configuration and ambiguity.
Null Values and DSL: Input dictionaries provide default empty strings for declared variables, reducing issues like "conditions appear correct but never trigger."
Operations Attention: If process definitions enable memory caching, monitor caching and instance versions after updating XML/rules, avoiding the illusion of "database changed, memory unchanged."
Summary
Slickflow's Ruleset feature unifies NRules and a self-developed JSON DSL executor under RuleSetExecutionManager, driven by wf_rule_set.mode + rule_content. RuleTask handles only ruleset binding and feeding process variables, forming clear layering: Product (Modeling/API) → Engine (Execution and Persistence) → Rule Runtime (NRules or DSL).
Future extensions can add rule versioning, audit logs, rule simulation interfaces, and more at the same extension points without modifying BPMN core structure.
This article organizes based on implementations of RuleSetExecutionManager, RuleExecutor, VariableManager, wf_rule_set, and related components in the Slickflow repository. Refer to current code and database scripts for any changes.
Key Takeaways for Practitioners
- Separation of Concerns: Keep business rules separate from workflow definitions to enable independent testing and versioning.
- Dual-Mode Flexibility: Support both strongly-typed code rules and declarative JSON rules to accommodate different team skill sets and change frequencies.
- Database-Driven Configuration: Store rules in database tables rather than embedding in BPMN XML for easier updates and better separation.
- Variable Management: Carefully handle input variable declarations and default values to prevent rule execution failures due to missing data.
- Caching Awareness: Be mindful of caching implications when updating rules in production environments.
The integration of NRules with Slickflow demonstrates how modern workflow engines can evolve to support sophisticated business rule management while maintaining clean architectural boundaries.