View Source Contract Inheritance for Protocols
A protocol is a promise about a family of implementations. Bond.Protocol lets you declare
@pre/@post contracts on a defprotocol's functions and have them enforced across every
implementation — present and future — without the implementations knowing anything about Bond.
This is the protocol analogue of Contract Inheritance for Behaviours: Design by Contract meeting the Liskov Substitution Principle, where the contract rides on the protocol's dispatch rather than on each callback.
At a glance
defprotocol Sized do
use Bond.Protocol
@post non_negative: result >= 0
@spec size(t) :: non_neg_integer()
def size(data)
end
# Implementations stay completely ordinary — no `use Bond`, no opt-in:
defimpl Sized, for: BoundedStack do
def size(%BoundedStack{items: items}), do: length(items)
end
defimpl Sized, for: List do
def size(list), do: length(list)
endEvery call through Sized.size/1 now checks result >= 0, whichever implementation runs. A
violation reads:
** (Bond.PostconditionError) postcondition (from protocol Sized, impl Sized.List) failed in Sized.size/1Declaring contracts
A @pre/@post precedes the def it attaches to, exactly as a contract precedes the def it
attaches to in use Bond. Contract expressions reference the protocol function's declared
argument names and, in a @post, result (the return value):
defprotocol Account do
use Bond.Protocol
@pre sufficient: amount <= balance(data)
@post non_negative: result >= 0
def withdraw(data, amount)
endBoth the bare form (@post result >= 0) and the labelled keyword form
(@post non_negative: result >= 0) are supported, matching use Bond.
A contract may reference only the function's named arguments (plus result in a @post).
Referencing any other name is a compile error reported against the protocol, where the contract
is declared — name your protocol arguments (def size(data), not def size(t)).
How it works — dispatch-layer wrapping (Option B)
defprotocol generates a dispatch function: Sized.size(data) calls
Sized.impl_for!(data).size(data). Bond.Protocol wraps that one dispatch function, once, in
the protocol module — it marks the function defoverridable and redefines it to evaluate the
precondition, call super/… (the original dispatch), then evaluate the postcondition.
Because the wrap is on dispatch, the contract:
- applies uniformly to every implementation, including ones written later or by third parties — they need zero Bond awareness;
- needs no positional rebinding — the wrapper's parameter is the declared argument name;
- survives protocol consolidation (the consolidated build preserves the wrapper).
Diagnostics — which implementation failed?
The wrap is central, but a failure still names the implementation the call resolved to: the
error carries :source_protocol (the protocol) and :impl (the resolved implementation
module), both in the message and in the [:bond, :assertion, :failure] telemetry metadata. The
implementation is resolved only on the failure path, so a passing contract pays nothing for it.
Runtime configuration
Protocol contracts honour the same runtime controls as ordinary contracts: config :bond, …
and the Bond.Config runtime API toggle them, and they obey the contract-checking chain
(preconditions ≤ postconditions ≤ invariants).
Scope and non-goals (v1)
- Immutable inheritance. Implementations enforce the protocol's contracts verbatim and cannot weaken, strengthen, or refine them. (Per-implementation refinement is reserved for a future Eiffel-style refinement feature.)
- Only dispatch is checked. A direct call to a concrete implementation module
(
Sized.List.size/1) bypasses dispatch and is therefore not checked. Call through the protocol (Sized.size/1). old/1is not supported in a protocol@post— the dispatch wrapper does not snapshot entry state. Using it is a compile error.- Compile-time
:purgeof protocol contracts is not supported; use runtime configuration to disable them.