Skip to content

RayfoldOne protocol for your app's API

Describe the API once. Screens ask for exactly the data they show, stay current without fetching again, and the schema decides who can do what. For TypeScript, React, Kotlin, Java and Spring Boot.

What it looks like

A schema, a request for one screen, and what comes back. The same request works against a TypeScript, Kotlin, Java or Spring Boot server.

The schema

rayfold
entity Book {
  id: ID
  title: String
  stock: Int
  author: Author
}

query book(id: ID): Book?

command buy(bookId: ID, qty: Int = 1): Book
  throws OutOfStock
  @allow(write: viewer != null)

One request, two steps

json
{
  "rayfold": "0.1",
  "ops": [
    { "id": 1, "op": "buy",
      "args": { "bookId": "b1" } },
    { "id": 2, "op": "book",
      "args": { "id": { "$ref": "1.id" } },
      "shape": "{ title stock author { name } }",
      "live": true }
  ]
}

Frames back

json
{ "id": 1,
  "ok": { "$type": "Book", "id": "b1",
    "title": "A Wizard of Earthsea", "stock": 2 },
  "patch": [{ "set": "Book:b1", "value": {
    "$type": "Book", "id": "b1",
    "title": "A Wizard of Earthsea", "stock": 2 } }],
  "meta": { "cost": 1 }, "fin": true }
{ "id": 2,
  "data": { "$type": "Book",
    "title": "A Wizard of Earthsea", "stock": 2,
    "author": { "$type": "Author",
      "name": "Ursula K. Le Guin" } },
  "meta": { "cost": 2 } }
{ "id": 2,
  "patch": [{ "at": "", "value": { "stock": 7 } }] }

The last frame comes later, when a member of staff restocks the book: the query was sent with live: true, so the server keeps it open and sends only what changed.

Before and after

The advantage is not that the requests are smaller. It is that the server says what changed, so the code that keeps screens correct after a write stops existing. Here is the same feature — buy a book, show it, and leave every other open screen correct — written the usual way and then in Rayfold.

Before. Nothing here is wrong or unusual; this is what careful code looks like today.

ts
// 1. the write
await client.mutate({ mutation: BUY, variables: { bookId: "b3", qty: 2 } });

// 2. the read it implies: a second round trip, because the mutation's result
//    does not carry the fields this screen shows
const { data } = await client.query({
  query: BOOK,
  variables: { id: "b3" },
  fetchPolicy: "network-only",
});

// 3. every other open view holding that book is now stale. Either refetch them,
//    or write a cache update for each, and keep that list correct as screens are added
await client.refetchQueries({ include: [CATALOGUE, CART_BADGE, STOCK_BANNER] });

After.

ts
const batch = client.batch();
const bought = batch.command<Book>("buy", { bookId: "b3", qty: 2 }, { shape: "{ id }" });
const book = batch.query<Book>("book", { id: bought.ref("id") }, { shape: "{ title stock }" });
await batch.run();

const { title, stock } = await book.promise;
// step 3 is not shortened, it is gone: `buy` answered with patches for what it
// changed, and every cached view holding that book has already applied them

Two round trips become one, because step 2 travels in the same request and takes its argument from step 1's result. Step 3 disappears, because the command's answer carries the patch. Add live: true to the query and another customer's purchase arrives through the same mechanism, with no second system to run.

The cost of that: your API has to be described in a schema, and your team learns one more protocol. Should you use Rayfold? is the honest version of that trade, and the comparison says which rows are defaults rather than things the alternatives cannot do.

Pick your stack

Every guide builds the same small bookshop, and every example project runs its tests on each change to the repository, so the code you copy is code that works.

Released under the Apache-2.0 license.