Custom functions
When the built-in operators don't cover a test, a rule calls a function your application registered, with the custom operator. Functions are bound when the rules load, so an unknown name fails there, never mid-request, and a call costs no lookup at run time.
Calling a function
{ "field": "Order.PlacedOn", "operator": "custom", "name": "IsBusinessDay" }
{ "field": "Customer.Age", "operator": "custom", "name": "IsBetweenInclusive", "value": [18, 65] }
The function receives two arguments: the resolved field value (or the whole fact if the leaf has no field), and the leaf's value, which can be a scalar, a string or an array.
The built-in functions
RegisterBuiltInFunctions(), from the RuleWright.Extensions.Functions package, registers fifteen:
| Function | value |
True when the field… |
|---|---|---|
IsNullOrEmpty, IsNullOrWhiteSpace |
none | is null or an empty (or whitespace) string |
EqualsIgnoreCase |
a string | equals the value, ignoring case (ordinal) |
IsEmail |
none | looks like an email address |
IsEven, IsOdd |
none | is an even or odd integer |
IsPositive, IsNegative |
none | is a number above or below zero |
DivisibleBy |
a number | is an integer divisible by the value |
IsBetweenInclusive |
[min, max] |
is within the range, inclusive |
IsWeekend, IsWeekday |
none | is a date on a Saturday or Sunday, or on another day |
IsInPast, IsInFuture |
none | is a date before or after now |
Every one is total: a value of an unexpected type gives false, never an exception.
{
"name": "Built-in functions",
"rules": [
{
"id": "valid-email",
"condition": { "field": "Customer.Email", "operator": "custom", "name": "IsEmail" }
},
{
"id": "working-age",
"condition": { "field": "Customer.Age", "operator": "custom", "name": "IsBetweenInclusive", "value": [18, 65] }
},
{
"id": "gold-any-case",
"condition": { "field": "Customer.Tier", "operator": "custom", "name": "EqualsIgnoreCase", "value": "GOLD" }
},
{
"id": "even-items",
"condition": { "field": "Order.ItemCount", "operator": "custom", "name": "IsEven" }
},
{
"id": "weekend-order",
"condition": { "field": "Order.PlacedOn", "operator": "custom", "name": "IsWeekend" }
},
{
"id": "placed-in-past",
"condition": { "field": "Order.PlacedOn", "operator": "custom", "name": "IsInPast" }
}
]
}
using System.Text.Json;
using Newtonsoft.Json.Linq;
using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Extensions.Functions;
using RuleWright.Json.NewtonsoftJson;
using RuleWright.Json.SystemText;
// The built-in clock is DateTime.UtcNow. Pass your own for tests, or to answer "as of" questions.
var asOf = new DateTime(2026, 9, 20, 0, 0, 0, DateTimeKind.Utc);
RuleWrightEngine engine = new RuleWrightBuilder()
.UseJsonReader(new SystemTextJsonReader())
.RegisterBuiltInFunctions(clock: () => asOf)
.Build();
LoadedRuleSet rules = engine.LoadRuleSet(File.ReadAllText("built-in-functions.json"));
foreach (Checkout checkout in new[] { Facts.Vip(), Facts.Newcomer() })
{
RuleEvaluationResult result = engine.Evaluate(rules, checkout);
string passed = result.FiredRules.Count == 0 ? "(none)" : string.Join(", ", result.FiredRules.Select(r => r.RuleId));
Console.WriteLine($"{checkout.Customer.Name,-6} {passed}");
}
Output
Aroha valid-email, working-age, gold-any-case, even-items, weekend-order, placed-in-past
Ben (none)
IsInPast and IsInFuture compare instants: a DateTimeOffset or a Local DateTime is converted to UTC first, and an Unspecified DateTime is read as UTC, so the answer doesn't depend on the server's time zone. IsWeekend and IsWeekday stay wall-clock: the Saturday where it happened. Pass a clock to make "now" testable. BuiltInFunctions.Create(clock) gives you the list itself.
Your own function
This rule dispatches today or on the next business day:
{
"id": "same-day-dispatch",
"condition": { "field": "Order.PlacedOn", "operator": "custom", "name": "IsBusinessDay" },
"actions": [
{ "type": "setOutput", "target": "Dispatch", "value": "same day" }
],
"else": [
{ "type": "setOutput", "target": "Dispatch", "value": "next business day" }
]
}
As a delegate
using System.Text.Json;
using Newtonsoft.Json.Linq;
using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Extensions.Functions;
using RuleWright.Json.NewtonsoftJson;
using RuleWright.Json.SystemText;
RuleWrightEngine engine = new RuleWrightBuilder()
.UseJsonReader(new SystemTextJsonReader())
.RegisterFunction("IsBusinessDay", (field, value) =>
field is DateTime d && d.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday))
.Build();
Output
Sat 19 Sep: next business day
Tue 22 Sep: same day
As a class
A class can carry a description and a value hint for a rule editor, by also implementing IRuleFunctionMetadata:
using RuleWright.Core;
/// <summary>True when the field is a date that falls on a weekday and is not in the holiday list.</summary>
public sealed class IsBusinessDayFunction : IRuleFunction, IRuleFunctionMetadata
{
private static readonly HashSet<DateTime> Holidays = new()
{
new DateTime(2026, 10, 26), // Labour Day (NZ)
new DateTime(2026, 12, 25),
};
public string Name => "IsBusinessDay";
public string? Description => "The date is a Monday to Friday that is not a public holiday.";
public RuleFunctionValueKind ValueKind => RuleFunctionValueKind.None;
// Called concurrently by every evaluation: keep it stateless (the set above is read-only).
public bool Evaluate(object? fieldValue, object? value) =>
fieldValue is DateTime date
&& date.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday)
&& !Holidays.Contains(date.Date);
}
using System.Text.Json;
using Newtonsoft.Json.Linq;
using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Extensions.Functions;
using RuleWright.Json.NewtonsoftJson;
using RuleWright.Json.SystemText;
RuleWrightEngine engine = new RuleWrightBuilder()
.UseJsonReader(new SystemTextJsonReader())
.RegisterFunction(new IsBusinessDayFunction())
.Build();
new NamedRuleFunction(name, predicate, description, valueKind) gives you the same without writing a class.
By scanning an assembly
RegisterFunctionsFrom(assembly) registers every public, non-abstract IRuleFunction in the assembly that has a public parameterless constructor. engine.FunctionCatalog lists what an engine ended up with, including descriptions:
using System.Text.Json;
using Newtonsoft.Json.Linq;
using RuleWright.Core;
using RuleWright.Execution;
using RuleWright.Extensions.Functions;
using RuleWright.Json.NewtonsoftJson;
using RuleWright.Json.SystemText;
RuleWrightEngine engine = new RuleWrightBuilder()
.UseJsonReader(new SystemTextJsonReader())
.RegisterBuiltInFunctions() // IsEmail, IsWeekend, …
.RegisterFunctionsFrom(typeof(IsBusinessDayFunction).Assembly) // every public IRuleFunction
.Build();
foreach (RuleFunctionDescriptor function in engine.FunctionCatalog)
{
Console.WriteLine($"{function.Name,-19} {function.ValueKind,-8} {function.Description}");
}
Output
DivisibleBy Scalar Field is an integer divisible by the value.
EqualsIgnoreCase Text Field equals the value, ignoring case (ordinal).
IsBetweenInclusive Array Field is within the [min, max] value array (inclusive).
IsBusinessDay None The date is a Monday to Friday that is not a public holiday.
IsEmail None Field looks like an email address.
IsEven None Field is an even integer.
IsInFuture None Field date is after now.
IsInPast None Field date is before now.
IsNegative None Field is a number less than zero.
IsNullOrEmpty None Field is null or an empty string.
IsNullOrWhiteSpace None Field is null, empty, or whitespace.
IsOdd None Field is an odd integer.
IsPositive None Field is a number greater than zero.
IsWeekday None Field date falls on a weekday.
IsWeekend None Field date falls on a Saturday or Sunday.
Rules for functions
- Names are case-sensitive, and registering the same name twice throws
ArgumentException. - Functions must be thread-safe. One instance serves every concurrent evaluation of every rule that calls it. Keep them stateless, or make their state immutable.
- Unknown names fail at load.
LoadRuleSetthrowsRuleCompilationExceptionnaming the rule and the function. - A null field still calls the function, with
null. That is howIsNullOrEmptyworks. - A function is your code. RuleWright calls it as it is: its cost, its safety and anything it reads are yours to control.
Next
A function that should compute a value rather than answer a condition is a value function. Then: JSON adapters, for reading rule documents with System.Text.Json or Newtonsoft.Json.