Bond

CI

Design by Contract for Elixir.

A contract is a plain Elixir expression attached to a function and checked at runtime. Here is one that says money is neither created nor destroyed:

defmodule Account do
defstruct [:owner, :balance]
end
defmodule Ledger do
use Bond
@pre sufficient_funds: amount <= from.balance
@post conserved: result.from.balance + result.to.balance == from.balance + to.balance
def transfer(%Account{} = from, %Account{} = to, amount) do
%{
from: %{from | balance: from.balance - amount},
to: %{to | balance: to.balance + amount}
}
end
end

@pre declares a precondition — what the caller must satisfy for the call to be valid. @post declares a postcondition — what the function promises in return, provided the precondition held. A postcondition can mention result, the function's return value, which a when guard cannot do at all. (Guards keep the jobs only they can do — dispatch, and standing in for types — see Should I remove guards when I add contracts?.)

Note what conserved is not: a restatement of the body. The body moves money; the contract states the law the movement must obey. So when someone later adds a transfer fee and takes it out of the sender only, the arithmetic still looks plausible — and the contract fails:

# Ledger.transfer(ana, bo, 200) # after a 1% fee is deducted from the sender
** (Bond.PostconditionError) postcondition failed in Ledger.transfer/3
| at: lib/ledger.ex:9
| label: :conserved
| assertion: result.from.balance + result.to.balance == from.balance + to.balance
| binding: [
amount: 200,
from: %Account{owner: "ana", balance: 1000},
result: %{
to: %Account{owner: "bo", balance: 250},
from: %Account{owner: "ana", balance: 798}
},
to: %Account{owner: "bo", balance: 50}
]

The failure names the property that broke, and hands you both states to compare.

Beyond preconditions and postconditions

@pre and @post constrain one call at a time. The rest of Bond is about getting more out of the same statements — enforcing them across a whole module, across every implementation of a behaviour, and against inputs you never wrote down.

Checked at every entrance and exit of a module. An @invariant is a property of the struct rather than of any one call, so Bond checks it around every public function — including the ones a colleague adds next year:

defmodule Cart do
use Bond
defstruct items: [], total_cents: 0
@invariant total_matches_items:
subject.total_cents == Enum.sum(Enum.map(subject.items, & &1.cents))
@post added_one: length(result.items) == length(cart.items) + 1
def add_item(%Cart{} = cart, item) do
%{cart | items: [item | cart.items]}
end
end

add_item/2 forgot to update total_cents. Nothing in the function is wrong on its own terms, and no guard, typespec, or pattern would object — but the invariant is checked on the way out:

# Cart.add_item(%Cart{}, %{sku: "A", cents: 500})
** (Bond.InvariantError) invariant violated around Cart.add_item/2
| at: lib/cart.ex:6
| label: :total_matches_items
| assertion: subject.total_cents == Enum.sum(Enum.map(subject.items, & &1.cents))
| binding: [subject: %Cart{items: [%{cents: 500, sku: "A"}], total_cents: 0}]

Checked in every implementation of a behaviour. Declare the contract once, on the callback, and every implementing module enforces it — without a line of contract code in any of them:

defmodule Paginator do
use Bond.Behaviour
@post never_over_limit: length(result) <= limit
@callback fetch(page :: pos_integer(), limit :: pos_integer()) :: list()
end
defmodule LegacyPages do
use Bond, behaviours: [Paginator]
@impl true
def fetch(page, _limit), do: Enum.map(1..50, &{page, &1}) # ignores limit
end
** (Bond.PostconditionError) postcondition (inherited from Paginator) failed in LegacyPages.fetch/2
| at: lib/paginator.ex:4
| label: :never_over_limit

The violation is attributed to the behaviour that declared it, and points at the line in that file. A promise made by an interface, kept by everything that implements it.

Checked against inputs you never thought of. The contracts you have already written are a specification, so they can serve as the oracle for property-based testing. You supply generators; Bond supplies the expected behaviour:

defmodule Roots do
use Bond
@pre non_negative: x >= 0.0
@post never_negative: result >= 0.0
@post shrinks_above_one: (x > 1.0) ~> (result < x)
def sqrt(x), do: :math.sqrt(x)
end
# in test/roots_test.exs
use Bond.PropertyTest
contract_holds &Roots.sqrt/1, args: [StreamData.float(min: 0.0)]

That runs sqrt/1 against a stream of generated floats and fails if any precondition, postcondition, or check is violated, with StreamData shrinking to a minimal counterexample. Those two postconditions are the whole oracle — there is no separate model of "expected output" to write or keep in step, because the contract already said it. (~> is implication: shrinks_above_one asserts nothing unless x > 1.0.) See Testing Contracts for probe_contract/2, which reads the boundaries out of your @pre and aims generators at them, and invariants_hold/2, which throws random sequences of operations at a struct.

You choose what they cost

Contracts are checked at runtime, so they are not free — the Overhead guide publishes measured per-call figures. What makes that affordable is that the decision is yours, per environment and per contract kind:

# config/prod.exs
config :bond, preconditions: :purge, postconditions: :purge, invariants: :purge

Purged contracts are not compiled in at all — there is no check to skip, and the function runs exactly as if the contract had never been written. Your published documentation still shows it, because mix docs runs in :dev where contracts are enabled: the contract stays part of the module's stated interface even in builds that do not check it.

Between "on" and "purged" there is a third setting that compiles the check in but leaves it switched off, so a release can run with contracts inert and have them enabled from a remote console while a problem is being diagnosed. See Configuring Contracts.

Contracts are normally on in dev and test. What you keep in production is a separate decision — often nothing, often just the preconditions, which are the cheapest kind and the only one that tells you a caller is at fault. Either way, that is why it is worth writing the expensive, interesting ones.

Installation

Add bond to your dependencies in mix.exs:

def deps do
[
{:bond, "~> 1.14"}
]
end

Then run mix deps.get. Bond's only dependency is :telemetry; :stream_data is optional and needed solely for the property-testing macros. Bond starts no processes and adds nothing to your supervision tree — use Bond is compile-time machinery, and the runtime side is plain function calls.

Where to go from here

Bond does more than the examples above. The rest of the documentation is organised around the questions that tend to arrive in this order:

Documentation

Documentation is generated with ExDoc and published on HexDocs at https://hexdocs.pm/bond.