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.

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

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:

The binaries are configurable:

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:

config :beancount_ex, engine: Beancount.Engine.Elixir
CallbackBehaviour
render/1delegates to Beancount.Renderer
check/1booking, balance assertions, pad resolution, tolerance inference
query/2canned 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 and Storage.

See Booking and Reconciliation.

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.