Table of Contents

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. LoadRuleSet throws RuleCompilationException naming the rule and the function.
  • A null field still calls the function, with null. That is how IsNullOrEmpty works.
  • 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.