< Boundaries | Up: Guides | Index | Stateful Doubles >
Dispatch is the uniform call-resolution mechanism that sits between every
facade and its implementation. All three facade types (ContractFacade,
BehaviourFacade, DynamicFacade) use the same dispatch infrastructure —
there is one mechanism, with one set of handler types, and one logging system.
Dispatch paths
The dispatch path for each facade is selected at compile time via the
:test_dispatch? and :static_dispatch? options:
┌──────────────────┐
│ Function call │
│ facade.op(args) │
└────────┬─────────┘
│
┌─────────────┴─────────────┐
│ test_dispatch? true? │
│ (default: not prod) │
└─────────────┬─────────────┘
yes │ no
┌─────────────────┴───────────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ call/4 │ │ static_dispatch │
│ │ │ ? true? │
│ 1. NimbleOwner- │ │ (default: prod) │
│ ship lookup │ └────────┬─────────┘
│ 2. App.get_env │ │
│ 3. Raise │ yes │ no
└──────────────────┘ ┌─────────────┴───┐
▼ ▼
┌────────────┐ ┌──────────────┐
│ Inlined │ │ call_config │
│ direct │ │ /4 │
│ call to │ │ │
│ impl │ │ App.get_env │
│ (zero │ │ → apply/3 │
│ overhead) │ │ → Raise │
└────────────┘ └──────────────┘Static dispatch
When static_dispatch?: true (default in :prod) and the implementation is
available in config at compile time, the facade generates inlined direct
function calls to the implementation module. The call compiles to identical
bytecode to calling the impl directly. Falls back to call_config/4 if the
config isn't available at compile time.
Runtime config dispatch
DoubleDown.Dispatch.call_config/4 reads
Application.get_env(otp_app, contract)[:impl] and calls
apply(impl, operation, args). Used in production when static dispatch
isn't available, and when test_dispatch?: false.
Test handler dispatch
DoubleDown.Dispatch.call/4 checks NimbleOwnership for a process-scoped
test handler, falling back to config. This is the default in non-prod
environments and is what makes DoubleDown.Double work.
DynamicFacade dispatch
DynamicFacade.dispatch/3 is a parallel dispatch path — it checks
NimbleOwnership for a test handler, falling back to the original
(backed-up) module. There is no config-based resolution because there is
no contract module to configure. There is no impact outside of test,
because DynamicFacade shims are only installed in test.
Handler types
When call/4 resolves a test handler, it matches one of three handler
types stored in NimbleOwnership:
| Handler | Installed via | Dispatch logic | Returns |
|---|---|---|---|
| Module | Double.fallback(contract, module) | apply(impl, operation, args) | whatever the module's function returns; %Defer{} unwraps to deferred result |
| Stateless | Double.fallback(contract, fn) | fun.(contract, operation, args) | whatever the function returns; %Defer{} unwraps to deferred result |
| Stateful | Double.fallback(contract, fn, state) | fun.(contract, operation, args, state) — inside NimbleOwnership.get_and_update for atomicity | {result, new_state}; can wrap result in %Defer{} for re-entrant calls |
All three return {contract, operation, normalize_args(args)} from
key/3 and support Defer for re-entrant dispatch.
5-arity stateful handlers
Stateful handlers can also accept a 5th argument — a read-only snapshot of all contract states — for cross-contract state access:
# 4-arity (default)
fn contract, operation, args, state -> {result, new_state} end
# 5-arity — cross-contract state
fn contract, operation, args, state, all_states -> {result, new_state} endThe all_states map is keyed by contract module. Handlers can inspect
another contract's state but only update their own via the return value.
Defer — re-entrant dispatch
Stateful handler functions run inside NimbleOwnership.get_and_update,
which holds a lock on the ownership GenServer. If a handler calls another
facade directly, it deadlocks — the second call re-enters the same
GenServer.
DoubleDown.Dispatch.Defer solves this. Return a Defer from a handler
and the deferred function runs after the lock is released:
{DoubleDown.Dispatch.Defer.new(fn ->
# Runs outside the lock — safe to call other facades
{:ok, record} = MyApp.Repo.insert(changeset)
record
end), new_state}DoubleDown.Double.defer/1 is a convenience wrapper.
Public API
| Function | Purpose |
|---|---|
call/4 | Test-aware dispatch (NimbleOwnership → config) |
call_config/4 | Config-only dispatch |
key/3 | Canonical key for test stub matching (normalized args) |
get_state/1 | Read current stateful handler state for a contract |
restore_state/3 | Replace a contract's stateful handler state |
handler_active?/1 | Check if a test handler is installed for a contract |