Skip to content

Gess tutorial

This tutorial shows the preferred Gess workflow:

  1. Write templates, seed facts, rules, and queries in .gess files.
  2. Compile those files with gessc during development or build generation.
  3. Use the generated Go code from your app.

The examples live under examples/. The primary example for this workflow is examples/gess-files/order_routing, which defines an order routing ruleset in rules.gess and generates rules_generated.go.

A small Gess-backed program usually has this shape:

order_routing/
main.go
rules.gess
rules_generated.go
main_test.go

rules.gess is the source of truth for rule-engine behavior. rules_generated.go is generated by gessc and should not be edited by hand. Application code imports the public rules, session, and dsl packages and calls the generated build function.

The order routing example starts by defining the fact templates the rules match and assert:

(deftemplate order
(slot id (type STRING) (required TRUE))
(slot customer (type STRING) (required TRUE))
(slot sku (type STRING) (required TRUE))
)
(deftemplate customer
(slot id (type STRING) (required TRUE))
(slot segment (type STRING) (required TRUE))
)
(deftemplate inventory
(slot sku (type STRING) (required TRUE))
(slot warehouse (type STRING) (required TRUE))
(slot available (type BOOLEAN) (required TRUE))
)
(deftemplate fulfillment-route
(declare
(duplicate-policy unique-key)
(duplicate-key order)
)
(slot order (type STRING) (required TRUE))
(slot lane (type STRING) (required TRUE))
(slot warehouse (type STRING) (required TRUE))
)

The duplicate-policy unique-key declaration keeps one current fulfillment-route fact per order, so repeated runs do not create duplicate routes for the same order. Re-asserting a route for an order with a different lane or warehouse replaces the previous route (retract old, assert new) rather than adding a second one.

Seed facts can live in the same file:

(deffacts seed-orders
(order (id "O-100") (customer "C-100") (sku "SKU-1"))
(order (id "O-200") (customer "C-200") (sku "SKU-1"))
(customer (id "C-100") (segment "vip"))
(customer (id "C-200") (segment "standard"))
(inventory (sku "SKU-1") (warehouse "W-1") (available TRUE))
)

Then add rules. This rule matches an order, joins it to the customer and inventory facts, and asserts a derived route:

(defrule route-vip-order
?order <- (order (customer ?customer) (sku ?sku))
(customer (id ?customer) (segment "vip"))
(inventory (sku ?sku) (available TRUE) (warehouse ?warehouse))
=>
(assert (fulfillment-route
(order ?order:id)
(lane "expedite")
(warehouse ?warehouse))
)
)

Queries give Go code a stable way to read results:

(defquery routes-by-lane
(declare (variables ?lane))
?route <- (fulfillment-route
(lane ?lane)
(order ?order)
(warehouse ?warehouse))
(return
(order ?order)
(warehouse ?warehouse)
)
)

The complete file is examples/gess-files/order_routing/rules.gess.

For a hands-on version with checkpoints, use tutorial/README.md.

Add a go:generate directive beside the Go code that uses the generated ruleset. In the example, the path to gessc is relative to examples/gess-files/order_routing:

//go:generate go run ../../../cmd/gessc -package main -func buildGeneratedRuleset -o rules_generated.go rules.gess

In another module, point the directive at the gessc command however your build provides it. The important parts are the generated package name, generated function name, output file, and input .gess file.

From the module root, regenerate with:

Terminal window
go generate ./examples/gess-files/order_routing

The same compile step can be run directly:

Terminal window
go run ./cmd/gessc \
-package main \
-func buildGeneratedRuleset \
-o examples/gess-files/order_routing/rules_generated.go \
examples/gess-files/order_routing/rules.gess

Format .gess source with gessfmt:

Terminal window
go run ./cmd/gessfmt -w examples/gess-files/order_routing/rules.gess

gessc parses the .gess file and emits a Go function with this shape:

func buildGeneratedRuleset(ctx context.Context, registry dsl.Registry) (*rules.Ruleset, []session.InitialFact, error)

The returned Ruleset is immutable compiled rule behavior. The returned initial facts are the facts declared by deffacts.

Application startup should call the generated build function, create a session, run rules, and query results:

func run(out io.Writer) error {
ctx := context.Background()
ruleset, initials, err := buildGeneratedRuleset(ctx, dsl.Registry{})
if err != nil {
return err
}
session, err := sess.New(ruleset, sess.WithInitialFacts(initials...))
if err != nil {
return err
}
defer session.Close()
if _, err := session.Run(ctx); err != nil {
return err
}
rows, err := session.QueryAll(ctx, "routes-by-lane", sess.QueryArgs{"lane": "expedite"})
if err != nil {
return err
}
for _, row := range rows {
order := stringValue(row, "order")
warehouse := stringValue(row, "warehouse")
fmt.Fprintf(out, "%s -> %s\n", order, warehouse)
}
return nil
}

The full example is examples/gess-files/order_routing/main.go. Its test expects:

O-100 -> W-1

The seed facts declare a second order, O-300, for the VIP customer C-300 and SKU SKU-2 — but SKU-2’s inventory fact has available FALSE, so route-vip-order’s inventory condition never matches it and O-300 doesn’t show up in the expedite query.

Change one seed fact in rules.gess to make SKU-2 available:

(inventory (sku "SKU-2") (warehouse "W-2") (available FALSE))
(inventory (sku "SKU-2") (warehouse "W-2") (available TRUE))

Regenerate and rerun:

Terminal window
go generate ./examples/gess-files/order_routing
go run ./examples/gess-files/order_routing

The expedite output now includes the newly matched order:

O-300 -> W-2
O-100 -> W-1

Nothing about route-vip-order or routes-by-lane changed: the rule was already declarative over “VIP customer, available inventory,” so making more inventory available surfaced a new match automatically. Revert the fact before moving on, or compare against the checked-in rules.gess.

deffacts is useful for fixed seed data and examples. Real applications often assert request or database facts after creating the session:

_, err := session.Assert(ctx, rules.TemplateKey("order"), rules.MustFields(
"id", "O-400",
"customer", "C-100",
"sku", "SKU-1",
))
if err != nil {
return err
}
_, err = session.Run(ctx)

Use templates declared in .gess for both generated seed facts and runtime assertions. That keeps validation and matching behavior in one ruleset.

Rules can change facts and compute values without leaving .gess. modify updates a matched fact in place — keeping its identity — while bind names a value for later actions in the same rule, and emit writes output:

(defrule finalize-route
?route <- (fulfillment-route (order ?order) (subtotal ?subtotal) (tax ?tax) (status "pending"))
=>
(bind ?total (+ ?subtotal ?tax))
(modify ?route (set (surcharge ?total) (status "priced")))
(emit "route " ?order " total " ?total))
  • (bind ?total (+ ?subtotal ?tax)) evaluates once and is visible only to later actions in this rule; it never becomes a fact. (+ ?subtotal ?tax) is a built-in function.
  • (modify ?route (set ...)) matches the (status "pending") guard, then changes status, so the rule fires once instead of looping. Matching a slot the modify changes is the idiomatic way to keep a self-modifying rule from re-activating.
  • (retract ?binding) removes a matched fact; (emit ...) writes to the session output writer set with session.WithOutputWriter.

Built-in functions — arithmetic, string, and type predicates (+ - * / mod, str-cat, upcase, numberp, …) — are available without registering host code. They work anywhere an expression is allowed, including directly as action values ((modify ?f (set (total (+ ?a ?b))))), and identically whether the ruleset is loaded at runtime or compiled with gessc. See the language reference for the full action and built-in function list. The gess-files/order_lifecycle example compiles these verbs with gessc.

Prefer plain .gess actions such as (assert ...) and (modify ...) when the rule can derive or update facts directly. When a rule needs a side effect or app-specific logic, register host functions and call them from .gess:

(defrule notify-seen-item
?item <- (item (id ?id))
=>
(call record ?item:id "seen"))

Pass the implementation through dsl.Registry when building the generated ruleset:

ruleset, initials, err := buildGeneratedRuleset(ctx, dsl.Registry{
Calls: map[string]dsl.CallFunc{
"record": func(ctx rules.ActionContext, args []rules.Value) error {
// Application side effect goes here.
return nil
},
},
})

Generated code checks that required registered calls are present when the ruleset is built, so missing host integrations fail at startup instead of after a rule fires.

The examples show the behavior to model in .gess files:

  • forward-chaining/order_routing: derive route facts from order, customer, and inventory facts. The .gess tutorial example is the compiled-file version of this pattern.
  • queries/account_lookup: expose rule-engine state through named queries instead of inspecting snapshots directly.
  • negation/customer_screening: use not to require that a blocking fact is absent.
  • aggregates/fraud_velocity: use accumulate, count, and sum for grouped decisions.
  • logical-support/remediation_tickets: use logical assertions when derived facts should cascade away with their supporting facts.
  • backward-chaining: use backchain-reactive templates and demand queries for query-driven proof.
  • modules-focus: organize rules into modules and control agenda focus.

When starting new work, keep the rules in .gess, compile with gessc, and let Go code handle session lifecycle, runtime facts, integrations, and output.

Beyond the patterns above, a few .gess and session features help while developing:

  • (defglobal *max-amount* (type INT) (default 1000)) declares a typed constant; reference it as *max-amount* in conditions, test expressions, query returns, and RHS (assert ...) slots. Supply per-session values with session.WithGlobals(map[string]any{"max-amount": 5000}) — the same compiled ruleset can run with different thresholds per session.
  • (deffunction discounted (param ?price INT) (return INT) (- ?price 100)) defines a pure function in the file itself; call it as (discounted ?price) inside test and comparison expressions without registering Go code. Bodies are single expressions and may call only functions defined earlier in the file, so recursion is impossible.
  • session.Agenda(ctx) lists what will fire next and in what order; session.Run(ctx, session.WithMaxFirings(1)) steps one activation at a time. Together they answer “why didn’t my rule fire?” interactively.
  • session.WithEventListener(session.NewTraceListener(os.Stderr)) streams a readable line per fact change, activation, and firing. Rules compiled from .gess include their file:line in rule events and runtime errors.
  • go run ./cmd/gess repl opens an interactive shell: load, assert, run [n], facts, explain <fact-id> [dot], whynot <rule>, agenda, query, watch on, and help. The shell enables an explain log by default so explain shows each fact’s producing firing and mutation lineage; whynot diagnoses why a rule has no pending activation.

For the tutorial example:

Terminal window
go generate ./examples/gess-files/order_routing
go test ./examples/gess-files/order_routing

For all examples:

Terminal window
go test ./examples/...