Factos

Factos provides primitives and storage backends for building event-sourced systems in Gleam.

The library helps with the repetitive part of event-sourced applications:

  1. read previously stored events;
  2. fold them into the state needed for a decision;
  3. run your domain decision function;
  4. persist the newly accepted events;
  5. return the committed records so your application can update views or trigger effects.

Factos is not a large framework. Your application still defines the commands, events, state, errors, codecs, read models, and side effects. Factos gives those pieces a standard shape and gives backends a standard way to run the read-decide-append flow safely.

What gets stored?

Backends store events.

Materialized views are not stored by Factos itself. A factos.View is an in-memory fold over events. If you want a durable read model, your application stores the result wherever it wants: PostgreSQL tables, Redis, SQLite, files, or something else.

Reactors are also not stored or executed by Factos. A factos.Reactor maps committed event records to application-owned effect values. Your application chooses whether to run those effects immediately, persist them in an outbox, retry them, or ignore them during replay.

So the durable state provided by the backend is:

Everything else is application state built from that log.

What does factos provide?

The core package is store-independent. It provides the types and pure functions used by backends and applications:

A decider has this shape:

factos.decider(
  initial: TicketWindow(capacity: 100, sold: 0),
  decide: decide,
  evolve: evolve,
)

evolve folds accepted events into state:

fn evolve(state: State, event: Event) -> State {
  let TicketWindow(capacity, sold) = state
  case event {
    TicketSold(_) -> TicketWindow(capacity: capacity, sold: sold + 1)
  }
}

decide takes a command and the folded state, then either rejects the command or returns new events:

fn decide(state: State, command: Command) -> Result(List(Event), DomainError) {
  let TicketWindow(capacity, sold) = state
  case command {
    BuyTicket(buyer) ->
      case sold < capacity {
        True -> Ok([TicketSold(buyer)])
        False -> Error(SoldOut(capacity))
      }
  }
}

You can test this without any database:

factos.compute_events(
  decider: ticket_decider(),
  events: [TicketSold("renata")],
  command: BuyTicket("lucy"),
)

Events, commands, and command sourcing

Factos stores events: facts that were accepted by the application. A backend row is an event record, not a command record.

The core package also provides command-handling helpers (Decider, Context, and dispatch functions in the backends). Those helpers are an opinionated way to build command processing on top of an event log:

command + relevant previous events -> accepted new events or domain error

If you want lower-level event sourcing, you can use the same stored event log, codecs, views, and reads without treating Factos as a complete command framework. The command-dispatch path is a convenience for applications that want that standard shape.

Queries and tags

Backends do not understand your event payload bytes. If a command needs to find facts by a payload value, write that value as a tag.

For a ticket-sale capacity rule:

fn sale_query() -> factos.Query {
  factos.query([
    factos.query_item(
      types: [factos.event_type("TicketSold")],
      tags: [factos.tag("event:gleamconf-2026")],
    ),
  ])
}

This tells the backend: “read the accepted ticket-sale facts for this event and protect that same context before appending more ticket sales”.

Query semantics are small:

How are views computed?

A view is an in-memory fold over events:

let sold_count =
  factos.view(initial: 0, evolve: fn(count, event) {
    case event {
      TicketSold(_) -> count + 1
    }
  })

You can run it over events you already have:

factos.project(view: sold_count, events: events)

Or your application can read events from a backend and store the projected value itself. Factos does not maintain a projection table automatically.

Views can always be recomputed if the original events are still decodable. That is why event codec compatibility matters.

How are effects handled?

Reactors turn committed event records into effect values:

pub type Effect {
  AnnounceTicketSale(buyer: String, position: factos.SequencePosition)
}

fn ticket_reactor() -> factos.Reactor(Event, Effect) {
  factos.reactor(fn(recorded) {
    case recorded.event {
      TicketSold(buyer) -> [
        AnnounceTicketSale(buyer: buyer, position: recorded.position),
      ]
    }
  })
}

After dispatch:

let effects = factos.react_all(ticket_reactor(), dispatch.events)

Factos does not send the email, publish the webhook, or mark the effect as done. It keeps that work explicit so your application can choose the durability and retry strategy.

Repository packages

This repository contains:

  1. factos: core primitives and pure computations.
  2. factos_kurrentdb_erlang: KurrentDB backend for Erlang.
  3. factos_cf: Cloudflare D1 backend.

The core concepts are shared. Storage behaviour and scaling tradeoffs are backend specific.

Search Document