Embedding a Rules Engine

This tutorial walks through embedding Scriptling in a Go application to create a dynamic rules engine. You’ll set up an interpreter, exchange variables, register custom functions, and evaluate user-defined rules.

Prerequisites

  • Go 1.26 or later
  • Basic familiarity with Go modules

Step 1: Project Setup

Create a new Go project:

mkdir rules-engine
cd rules-engine
go mod init example.com/rules-engine
go get github.com/paularlott/scriptling

Create main.go:

package main

import (
    "fmt"
    "github.com/paularlott/scriptling"
    "github.com/paularlott/scriptling/stdlib"
)

func main() {
    p := scriptling.New()
    stdlib.RegisterAll(p)

    result, err := p.Eval(`print("Rules engine ready")`)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    _ = result
}

Run it:

go run main.go

Output:

Rules engine ready

Step 2: Pass Data to Scripts

Pass order data from Go into the script and evaluate a simple rule:

package main

import (
    "fmt"
    "github.com/paularlott/scriptling"
    "github.com/paularlott/scriptling/stdlib"
)

func main() {
    p := scriptling.New()
    stdlib.RegisterAll(p)

    // Pass order data from Go
    p.SetVar("order_amount", 1500.0)
    p.SetVar("customer_tier", "gold")
    p.SetVar("is_first_purchase", false)

    // Evaluate discount rule
    result, err := p.Eval(`
discount = 0

# Base discount by tier
if customer_tier == "gold":
    discount = 0.15
elif customer_tier == "silver":
    discount = 0.10
else:
    discount = 0.05

# First purchase bonus
if is_first_purchase:
    discount = discount + 0.05

# Bulk order bonus
if order_amount > 1000:
    discount = discount + 0.05

# Cap at 30%
if discount > 0.30:
    discount = 0.30

discount_amount = order_amount * discount
final_price = order_amount - discount_amount
`)

    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    _ = result

    // Read results back in Go
    discount, _ := p.GetVarAsFloat("discount")
    finalPrice, _ := p.GetVarAsFloat("final_price")

    fmt.Printf("Discount:    %.0f%%\n", discount*100)
    fmt.Printf("Final Price: $%.2f\n", finalPrice)
}

Output:

Discount:    20%
Final Price: $1200.00

Step 3: Register Custom Go Functions

Expose Go functions to the script for data lookups:

package main

import (
    "fmt"
    "github.com/paularlott/scriptling"
    "github.com/paularlott/scriptling/stdlib"
)

func main() {
    p := scriptling.New()
    stdlib.RegisterAll(p)

    // Mock customer database
    customers := map[string]map[string]interface{}{
        "alice": {"tier": "gold", "orders": 15, "total_spent": 12500.0},
        "bob":   {"tier": "silver", "orders": 3, "total_spent": 450.0},
        "carol": {"tier": "bronze", "orders": 1, "total_spent": 75.0},
    }

    // Register a lookup function
    p.RegisterFunction("lookup_customer", func(args []interface{}) (interface{}, error) {
        name, ok := args[0].(string)
        if !ok {
            return nil, fmt.Errorf("expected string argument")
        }
        customer, exists := customers[name]
        if !exists {
            return nil, fmt.Errorf("customer not found: %s", name)
        }
        return customer, nil
    })

    // Register a logging function
    p.RegisterFunction("log_action", func(args []interface{}) (interface{}, error) {
        message, _ := args[0].(string)
        fmt.Printf("[LOG] %s\n", message)
        return nil, nil
    })

    // Evaluate rule that uses custom functions
    result, err := p.Eval(`
# Look up customer
customer = lookup_customer("alice")

tier = customer["tier"]
spent = customer["total_spent"]

# Determine loyalty bonus
if tier == "gold" and spent > 10000:
    bonus = "platinum_upgrade"
elif tier == "silver" and spent > 500:
    bonus = "gold_upgrade"
else:
    bonus = "maintain"

log_action("Customer tier: " + tier + ", bonus: " + bonus)
`)

    if err != nil {
        fmt.Println("Error:", err)
        return
    }

    bonus, _ := p.GetVarAsString("bonus")
    fmt.Println("Result:", bonus)
    _ = result
}

Output:

[LOG] Customer tier: gold, bonus: platinum_upgrade
Result: platinum_upgrade

Step 4: Load Rules from Files

Load rule definitions from external files so rules can be updated without recompiling:

Create rules/discount.py:

# Discount rule: calculates the discount for an order
# Inputs: order_amount (float), customer_tier (string), is_first_purchase (bool)

discount = 0

if customer_tier == "gold":
    discount = 0.15
elif customer_tier == "silver":
    discount = 0.10
else:
    discount = 0.05

if is_first_purchase:
    discount = discount + 0.05

if order_amount > 1000:
    discount = discount + 0.05

if discount > 0.30:
    discount = 0.30

discount_amount = order_amount * discount
final_price = order_amount - discount_amount

Update main.go to load rules from files:

package main

import (
    "fmt"
    "os"
    "github.com/paularlott/scriptling"
    "github.com/paularlott/scriptling/stdlib"
)

func evalRule(p *scriptling.Interpreter, ruleFile string) error {
    script, err := os.ReadFile(ruleFile)
    if err != nil {
        return fmt.Errorf("loading rule: %w", err)
    }

    _, err = p.Eval(string(script))
    return err
}

func main() {
    p := scriptling.New()
    stdlib.RegisterAll(p)

    // Set input variables
    p.SetVar("order_amount", 750.0)
    p.SetVar("customer_tier", "silver")
    p.SetVar("is_first_purchase", true)

    // Load and evaluate the rule file
    err := evalRule(p, "rules/discount.py")
    if err != nil {
        fmt.Println("Error:", err)
        return
    }

    // Read results
    discount, _ := p.GetVarAsFloat("discount")
    finalPrice, _ := p.GetVarAsFloat("final_price")

    fmt.Printf("Discount:    %.0f%%\n", discount*100)
    fmt.Printf("Final Price: $%.2f\n", finalPrice)
}

Step 5: Use the Builder API for Type Safety

For production code, use the Builder API for type-safe function registration:

package main

import (
    "fmt"
    "github.com/paularlott/scriptling"
    "github.com/paularlott/scriptling/stdlib"
    "github.com/paularlott/scriptling/builder"
)

func main() {
    p := scriptling.New()
    stdlib.RegisterAll(p)

    // Build a library with type-safe functions
    lib := builder.NewLibrary("inventory")

    lib.Function("check_stock", func(ctx *builder.Context, sku string) (int, error) {
        // Simulate stock check
        stock := map[string]int{
            "widget-001": 42,
            "widget-002": 0,
            "widget-003": 7,
        }
        return stock[sku], nil
    }).Help("Check stock level for a product SKU")

    lib.Function("calculate_shipping", func(ctx *builder.Context, weight float64, zone string) (float64, error) {
        rates := map[string]float64{
            "domestic":   5.99,
            "international": 24.99,
        }
        base := rates[zone]
        return base + (weight * 0.50), nil
    }).Help("Calculate shipping cost based on weight and zone")

    // Register the library
    p.RegisterLibrary("inventory", lib.Build())

    // Use it in a script
    _, err := p.Eval(`
import inventory

sku = "widget-001"
stock = inventory.check_stock(sku)
shipping = inventory.calculate_shipping(2.5, "domestic")

print(f"Stock for {sku}: {stock}")
print(f"Shipping: ${shipping:.2f}")
`)

    if err != nil {
        fmt.Println("Error:", err)
    }
}

What You Learned

  • Creating a Scriptling interpreter in Go
  • Exchanging variables between Go and Scriptling
  • Registering custom Go functions for scripts to call
  • Loading rules from external files
  • Using the Builder API for type-safe function registration

See Also