Skip to main content

guides/engines.md

# Engines

All execution in `beancount_ex` flows through the `Beancount.Engine` behaviour.
This is the seam that lets the backend change without breaking the public API.

```elixir
defmodule Beancount.Engine do
  @callback render(term()) :: binary()
  @callback check(binary()) ::
              {:ok, Beancount.Result.t()} | {:error, Beancount.Result.t()}
  @callback query(binary(), binary()) ::
              {:ok, Beancount.Query.Result.t()} | {:error, Beancount.Result.t()}
end
```

## Selecting an engine

```elixir
config :beancount_ex, engine: Beancount.Engine.CLI
```

`Beancount.render/1` and `Beancount.check/1` dispatch to
`Beancount.Engine.configured/0`, so applications never call an engine directly.

## The CLI engine (default)

`Beancount.Engine.CLI` is the default engine. It:

- delegates `render/1` to `Beancount.Renderer`,
- delegates `check/1` to `Beancount.Checker`, which shells out to `bean-check`, and
- delegates `query/2` to `Beancount.Query`, which shells out to `bean-query`.

The binaries are configurable:

```elixir
config :beancount_ex,
  bean_check_path: "bean-check",
  bean_query_path: "bean-query"
```

If a binary cannot be found, the relevant wrapper raises
`Beancount.Checker.NotInstalledError` / `Beancount.Query.NotInstalledError`.
This is deliberately distinct from a ledger that *fails* validation or a query
that *fails* (which return `{:error, %Beancount.Result{}}`) so that environment
problems are never confused with accounting errors.

## Results are engine-independent

Every engine populates the same `Beancount.Result` and `Beancount.Query.Result`
structs, and `Beancount.Normalizer` produces a stable, backend-independent view
of the output. This normalization is what makes cross-engine comparison
possible.

Because `query/2` is part of the behaviour, native engines must implement it
too - keeping the oracle contract uniform across backends.

## The Elixir engine

`Beancount.Engine.Elixir` is a native engine with golden-fixture parity
against the CLI oracle for check and canned reports:

```elixir
config :beancount_ex, engine: Beancount.Engine.Elixir
```

| Callback | Behaviour |
|----------|-----------|
| `render/1` | delegates to `Beancount.Renderer` |
| `check/1` | booking, balance assertions, pad resolution, tolerance inference |
| `query/2` | canned reports (balances, balance_sheet, income_statement, holdings, journal) |

For BQL queries beyond the canned set, use `Engine.CLI` (`bean-query`).
For ad-hoc queries against stored directives, use `Beancount.Queries`
(Ecto.Query). See [Queries](queries.md) and [Storage](storage.md).

See [Booking](booking.md) and [Reconciliation](reconciliation.md).

`Beancount.check_file/1` routes through the configured engine. The CLI engine
passes the file path to `bean-check` (so `include` resolves relative to the
file). With `Beancount.Engine.Elixir` configured, the file is read into text
before `check/1` runs, so `include` directives are not resolved relative to
the original path.

## Future engines

Additional engines (e.g. native Rust via NIF/port) can implement the same
behaviour. Because they share the `Beancount.Result` shape, swapping engines
requires **no changes** to `Beancount.*` callers.