Gess tutorial
This tutorial shows the preferred Gess workflow:
- Write templates, seed facts, rules, and queries in
.gessfiles. - Compile those files with
gesscduring development or build generation. - 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.
Project shape
Section titled “Project shape”A small Gess-backed program usually has this shape:
order_routing/ main.go rules.gess rules_generated.go main_test.gorules.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.
Write the .gess file
Section titled “Write the .gess file”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.
Compile with gessc
Section titled “Compile with gessc”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.gessIn 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:
go generate ./examples/gess-files/order_routingThe same compile step can be run directly:
go run ./cmd/gessc \ -package main \ -func buildGeneratedRuleset \ -o examples/gess-files/order_routing/rules_generated.go \ examples/gess-files/order_routing/rules.gessFormat .gess source with gessfmt:
go run ./cmd/gessfmt -w examples/gess-files/order_routing/rules.gessgessc 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.
Use the generated ruleset
Section titled “Use the generated ruleset”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-1Try a change
Section titled “Try a change”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:
go generate ./examples/gess-files/order_routinggo run ./examples/gess-files/order_routingThe expedite output now includes the newly matched order:
O-300 -> W-2O-100 -> W-1Nothing 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.
Adding runtime facts
Section titled “Adding runtime facts”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.
Mutate and compute in place
Section titled “Mutate and compute in place”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 changesstatus, 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 withsession.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.
Call host code
Section titled “Call host code”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.
Patterns from the examples
Section titled “Patterns from the examples”The examples show the behavior to model in .gess files:
forward-chaining/order_routing: derive route facts from order, customer, and inventory facts. The.gesstutorial 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: usenotto require that a blocking fact is absent.aggregates/fraud_velocity: useaccumulate,count, andsumfor 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.
Debugging and tuning
Section titled “Debugging and tuning”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,testexpressions, query returns, and RHS(assert ...)slots. Supply per-session values withsession.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)insidetestand 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.gessinclude theirfile:linein rule events and runtime errors.go run ./cmd/gess replopens an interactive shell:load,assert,run [n],facts,explain <fact-id> [dot],whynot <rule>,agenda,query,watch on, andhelp. The shell enables an explain log by default soexplainshows each fact’s producing firing and mutation lineage;whynotdiagnoses why a rule has no pending activation.
Verify changes
Section titled “Verify changes”For the tutorial example:
go generate ./examples/gess-files/order_routinggo test ./examples/gess-files/order_routingFor all examples:
go test ./examples/...Next steps
Section titled “Next steps”- Core concepts for the vocabulary behind what you just built.
- The
.gesslanguage reference for every form the parser accepts. - Examples map for more
.gessand Go-API examples, organized by feature.