From a function to a record
nobody can edit.

Tutorial · 2026-08-22 · Three stops on one road: a bare function served as-is, a declared service with a table and a contract, and finally a journal — the kind of sales record a law can rely on. Every listing runs; total time, about twenty minutes.

Stop 1 — a function, served

Start where every idea starts — a function in a file:

# till.ring
func total qty, price
    return qty * price
$ ringserv serve till.ring
$ curl -X POST localhost:8080/api/v1 \
    -d '{"service":"till","action":"total",
         "payload":{"qty":2,"price":180}}'
{"code":0,"message":"OK","data":360}

No declarations, no routes. The payload's keys were matched to the parameters by name. When you wonder what exactly is exposed, ringserv serve --explain till.ring prints the whole mapping and exits.

Stop 2 — a declared service, with a table and a door

A real till needs to remember. Graduate the file — same binary, same call shape, more said:

RingServ([
    :port = 8080,
    :database = "till.db",

    :data = [ :sales = [ :item = :text, :amount = :number ] ],

    :services = [
        :sales = [ :table = "sales" ]     # list/add/edit/delete, one line
    ]
])

Contract(:sales, [
    :create = [ :in = [
        :item   = [ :type = :string, :required = true ],
        :amount = [ :type = :number, :min = 0 ]
    ] ]
])

Two things happened. The table became a service — five actions from one line, with SQLite (in a file you can copy for backup) underneath. And the contract put validation at the door: a request missing item is refused with every problem named at once, before your code runs. An action that checks its own payload is an action written twice.

Stop 3 — the record that refuses to be quietly changed

Now the part most stacks make you build yourself, badly, under deadline. Sales are money, and in several countries the law says a sales record must be inalterable. A table cannot promise that — anyone with the file can edit a row. A journal can:

Journal([
    :name  = "sales",
    :apply = func aEvent {
        # fold each event into today's totals
        return 1
    }
])

# inside your action:
JournalAppend("sales", [ :type = "sold",
    :item = "espresso", :amount = 360 ])

Three properties, each one testable. Append-only: the journal's own service exposes read and verify — an "edit" action does not exist to be called. Sealed: every entry carries a fingerprint of the one before it, so if anyone edits the file with a database tool, ringserv journal verify answers broken, at entry N, and why — and exits with a failure code, so a nightly cron job tells you instead of an auditor. Replayed: your running totals live in memory, rebuilt from the journal at startup — kill the server mid-sale and nothing is lost, because there is nothing outside the record to lose.

And when a customer cancels? You append a cancelled event with a reason. The sale stays in history with its cancellation beside it — which is exactly the difference between a record and a table.

Where to go from here

The full tutorial arc continues: a JavaScript service, offline sync, and placement. And the reference application is this article grown all the way up — a café counter using every form at once, with 38 tests driving it.

Start here More articles