Skip to content

Repository files navigation

Excanon

Build Status Hex.pm Documentation

A flexible and powerful rule engine library for Elixir that enables complex business logic evaluation using JSON-based rule definitions. Perfect for applications requiring dynamic rule-based decision making, such as pricing engines, policy evaluators, workflow systems, and more.

Features

  • JSON-based Rules: Define rules in human-readable JSON format
  • Rich Operations: Support for logical, arithmetic, and data manipulation operations
  • Stateful Evaluation: Maintain rules in memory across evaluations using agents
  • JSON Pointer Support: Access nested data structures easily
  • Type Safety: Full Elixir type specifications and validation

Installation

Add excanon to your list of dependencies in mix.exs:

def deps do
  [
    {:excanon, "~> 0.1.0"}
  ]
end

Then run:

mix deps.get

Quick Start

Here's a complete example of setting up and using the rule engine:

# 1. Start a rule engine agent
{:ok, _pid} = StatefulRuleEngine.start_link(:order_engine, [])

# Alternatively, add StatefulRuleEngine to your application's supervision tree for automatic startup:
# In your application.ex:
# def start(_type, _args) do
#   children = [
#     {Excanon.StatefulRuleEngine, :order_engine}
#   ]
#   Supervisor.start_link(children, strategy: :one_for_one)
# end

# 2. Define rules in JSON
rules_json = ~s([
  {
    "name": "bulk_discount",
    "description": "Apply 5% discount for orders with 5+ items",
    "conditions": {"gte": [{"obj": "order.quantity"}, 5]},
    "actions": [
      {"set": ["order.discount_percent", 5]},
      {"set": ["order.discount_amount", {"mult": [{"obj": "order.subtotal"}, 0.05]}]}
    ]
  },
  {
    "name": "loyalty_bonus",
    "description": "Add loyalty points for premium customers",
    "conditions": {"and": [
      {"eq": [{"obj": "customer.tier"}, "premium"]},
      {"gt": [{"obj": "order.total"}, 50]}
    ]},
    "actions": [
      {"set": ["customer.loyalty_points", {"plus": [{"obj": "customer.loyalty_points"}, 10]}]}
    ]
  }
])

# 3. Load the rules
:ok = StatefulRuleEngine.load_rules(:order_engine, rules_json)

# 4. Evaluate facts
facts = %{
  "order" => %{"quantity" => 6, "subtotal" => 120.0, "total" => 120.0},
  "customer" => %{"tier" => "premium", "loyalty_points" => 50}
}

{:ok, result} = StatefulRuleEngine.evaluate(:order_engine, facts)

# Result will include:
# - order.discount_percent: 5
# - order.discount_amount: 6.0
# - customer.loyalty_points: 60

Rule Dependencies

By default, rules execute in the order they appear in the loaded JSON array. Two optional fields let you declare ordering and prerequisite relationships between rules instead of relying on array position:

  • after — ordering only. The referenced rule(s) run first; the dependent rule then evaluates its own conditions and runs (or doesn't) exactly as it normally would, regardless of whether the referenced rule's conditions matched.
  • requires — ordering plus a gate. The referenced rule(s) run first, and the dependent rule is only evaluated if every rule it requires actually fired (its conditions matched) in the same evaluation. If a required rule didn't fire, the dependent is skipped — and that skip propagates transitively to any rule that in turn requires it.
[
  {
    "name": "apply_discount",
    "description": "Apply a discount if eligible",
    "conditions": {"gt": [{"obj": "order.total"}, 100]},
    "actions": [{"set": ["order.discount", 10]}]
  },
  {
    "name": "log_discount",
    "description": "Runs after apply_discount regardless of whether it matched",
    "conditions": {"eq": [1, 1]},
    "actions": [{"set": ["order.discount_checked", true]}],
    "after": ["apply_discount"]
  },
  {
    "name": "notify_customer",
    "description": "Only runs if apply_discount actually fired",
    "conditions": {"eq": [1, 1]},
    "actions": [{"set": ["order.discount_notified", true]}],
    "requires": ["apply_discount"]
  }
]

Rule name values must be unique within a loaded rule set, every after/requires reference must point to a rule that exists in the set, and the dependency graph must not contain cycles. Any of these problems causes load_rules/2 to return {:error, reason} instead of loading the rules. Cyclic/iterative rule execution is not supported yet.

Operations

Excanon supports a wide range of operations for building complex rules.

Logical Operations

Keyword Arguments Description Example
eq 2+ All values are equal {"eq": [1, 1, 1]}
neq 2+ Not all values are equal {"neq": [1, 2]}
and 1+ Logical AND of conditions {"and": [{"gt": [x, 0]}, {"lt": [x, 10]}]}
or 1+ Logical OR of conditions {"or": [{"eq": [x, 1]}, {"eq": [x, 2]}]}
gt 2 Greater than {"gt": [x, 10]}
gte 2 Greater than or equal {"gte": [x, 10]}
lt 2 Less than {"lt": [x, 10]}
lte 2 Less than or equal {"lte": [x, 10]}

Arithmetic Operations

Keyword Arguments Description Example
plus 2+ Sum of values {"plus": [1, 2, 3]}
minus 2+ Subtraction: val1 - val2 - ... {"minus": [10, 4, 1]}
mult 2+ Product of values {"mult": [2, 3, 4]}
div 2 Division {"div": [10, 2]}
mod 2 Modulo {"mod": [10, 3]}

Data Operations

Keyword Arguments Description Example
obj 1 Access nested data using JSON pointer syntax {"obj": "user.profile.name"}
set 2 Set a value in facts at a given path {"set": ["order.total", 100]}
call 1 Execute an external Elixir script {"call": "path/to/script.exs"}

Logging Operations

Keyword Arguments Description Example
log 1 Logs an evaluated value at debug level via Logger and returns it unchanged (usable in conditions or actions) {"gt": [{"log": {"obj": "order.total"}}, 100]}

Note: log returns its argument's value unchanged after logging it, so it can be wrapped around any part of a condition or action — including the entire conditions expression — purely for debugging, without changing what the rule decides.

log can reference arbitrary fact paths. Avoid logging sensitive fact values (PII, credentials, etc.) — excanon has no way to know which fields are sensitive, and log output inherits whatever Logger backends/retention the host application has configured.

Constants

Numbers, strings, booleans, lists, and nil are supported as literal values.

JSON Pointer Syntax

Use JSON Pointer to access nested data structures:

facts = %{
  "user" => %{
    "profile" => %{"name" => "John", "age" => 30},
    "orders" => [%{"total" => 100}, %{"total" => 200}]
  }
}

# Access user name
{"obj": "user.profile.name"}  # => "John"

# Access first order total
{"obj": "user.orders[0].total"}  # => 100

# Access array elements
{"obj": "user.orders[1]"}  # => %{"total" => 200}

Advanced Usage

Script Execution

Use the call operation to execute Elixir scripts:

# script.exs
result = some_complex_calculation()
result

# In rules
{"call": "path/to/script.exs"}

Complex Conditions

Build sophisticated conditions using nested operations:

{
  "conditions": {
    "and": [
      {"gte": [{"obj": "user.age"}, 18]},
      {"or": [
        {"eq": [{"obj": "user.membership"}, "premium"]},
        {"gt": [{"obj": "user.spending"}, 1000]}
      ]}
    ]
  }
}

Architecture

Excanon consists of several key modules:

  • Excanon: Main module with library overview
  • StatefulRuleEngine: Agent-based rule engine with JSON loading

Stateful Rule Engine Caveats

The StatefulRuleEngine uses Elixir agents for state management, which provides simple and efficient state persistence. However, agents are not designed for high-concurrency write operations and can lead to race conditions if multiple processes attempt to modify the rule state simultaneously. For example, loading new rules concurrently for the same rule engine id may result in inconsistent state.

Usage Recommendations: The stateful rule engine is best suited for scenarios where rules are long-lasting with few or no changes during their lifetime. Load rules once at startup or during configuration, then perform multiple evaluations without frequent updates. For applications requiring frequent rule modifications or high-concurrency updates, consider using a stateless rule engine.

Error Handling

The library provides clear error messages for common issues:

  • Invalid JSON in rule definitions
  • Missing required rule fields
  • Duplicate rule names within a loaded rule set
  • Unknown after/requires references
  • Dependency cycles between rules
  • Invalid operation arguments
  • JSON Pointer resolution failures

Performance Considerations

  • Rules are parsed once during loading
  • Operations are evaluated efficiently
  • Agent-based state management for concurrent access
  • Minimal memory footprint for rule storage

Contributing

  1. Fork the repository
  2. Create a feature branch
  3. Add tests for new functionality
  4. Ensure all tests pass
  5. Submit a pull request

Testing

Run the test suite:

mix test

Run with coverage:

mix test --cover

License

This project is licensed under the MIT License - see the LICENSE file for details.

Documentation

Full API documentation is available on HexDocs.

Support


Built with ❤️ using Elixir

About

Elixir Rule Engine

Resources

Contributing

Stars

5 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages