☝️🤓 Factos

Factos is a context-first Event Sourcing library for Gleam. It lets a business decision read and protect the exact facts that can change its answer, even when those facts span several entities or would traditionally live in different event streams.

command + relevant accepted facts -> new facts or a domain error

Applications own their domain language, decisions, event codecs, projections, and effects. Factos provides:

Factos is not a DDD framework, command bus, projection framework, or generic application architecture.

Why it exists

Business invariants often cross fixed storage boundaries. A course subscription, for example, may depend on the course’s capacity, the student’s existing subscriptions, and whether that student already joined that course.

A traditional stream-per-aggregate design can protect either stream with an expected revision, but protecting the combined decision requires extra coordination. Factos instead makes the relevant set of facts explicit for each command:

read matching facts
      -> fold temporary decision state
      -> apply a pure decision
      -> append only if no matching fact appeared meanwhile

This is Dynamic Consistency Boundaries (DCB): consistency follows the business rule rather than a permanent stream boundary. Unrelated commands can still use different, narrower contexts.

Start with the concepts

The documentation is ordered for readers who are new to the ideas:

  1. Start here: why Factos exists
  2. Domain-Driven Design: a practical primer
  3. Event Sourcing and command dispatch
  4. Dynamic Consistency Boundaries
  5. The Factos core model

Core model

A Model combines pure state transition and decision functions with the application’s event codec:

let model =
  factos.model(
    initial: initial_state,
    try: try_command,
    evolve: evolve,
    encode: encode_event,
    decode: decode_event,
  )

See The Factos core model for complete definitions of all five model values.

Every dispatch also supplies a DecisionContext:

Items are OR-combined. Types inside an item are OR-combined; tags are AND-combined.

Simulate domain scenarios

The core simulator exercises the real model and codecs without a database:

let simulation =
  simulate.new(model, with: [
    TicketSold(buyer: "renata"),
  ])
  |> simulate.dispatch(
    decision_context: sale_context(),
    command: BuyTicket(buyer: "lucy"),
  )
  |> simulate.tap(fn(simulation) {
    assert simulate.events(simulation)
      == Ok([
        TicketSold(buyer: "renata"),
        TicketSold(buyer: "lucy"),
      ])
  })

assert simulate.errors(simulation) == []

Simulation proves domain folding, decisions, errors, and codec behavior. It does not prove an event store’s transaction isolation or concurrency guarantee; use backend dispatch integration tests for those.

PostgreSQL and SQLite projection simulators can run the application’s real subscriptions against a projection database while keeping event history in memory. Each accepted batch gets its own backend transaction, and a failed subscription rolls back that batch’s projection writes.

Dispatch through a backend

Configuration and dispatch are backend-specific:

let configuration =
  factos_pog.configure(model, connection: connection)

configuration
|> factos_pog.dispatch(
  command,
  decision_context: decision_context(command),
  event_id: new_event_id,
)

factos_pog runs dispatches in serializable PostgreSQL transactions and retries serialization or deadlock conflicts. SQLite and Cloudflare D1 expose the same core model through storage-appropriate transaction APIs.

Packages

PackageRole
factosStore-independent model, event envelope, contexts, and simulator
factos_pogPostgreSQL event store and transactional subscriptions
factos_sqlightSQLite event store through Sqlight
factos_cfCloudflare D1 event store and immutable transaction plans

Runnable DCB examples live under examples/. They cover course subscriptions, unique usernames, invoice numbers, opt-in tokens, dynamic product prices, and record deduplication.

Development

trellis run check
trellis run test

PostgreSQL-backed tests and the benchmark use the root Compose service:

docker compose up --wait -d

See the changelog for release history.

Search Document