< DoubleDown | Up: Guides | Index | Dispatch >
Testing components with dependencies usually requires boundaries — a seam
where the real implementation can be swapped for a test double, isolating
the component under test. DoubleDown provides several ways to add such
boundaries: explicit Mox-style contracts (defcallback, @behaviour) and
implicit Mimic-style interception (DynamicFacade). All three produce the
same dispatch mechanism and support the same test double API.
DoubleDown separates what you call from what handles the call. A function call passes through four layers (note: these are conceptual layers, optimised away in production paths):
┌──────────────────────────────────────────────────────┐
│ FUNCTION CALL │
│ MyApp.Repo.insert(changeset) │
└──────────────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ 1. CONTRACT │
│ (type-level interface) │
│ │
│ ┌────────────────┐ ┌──────────────┐ ┌─────────────┐ │
│ │ defcallback │ │ @behaviour │ │ any module │ │
│ │ (explicit, │ │ (explicit, │ │ via Dynamic │ │
│ │ typed, rich) │ │ existing │ │ Facade │ │
│ │ │ │ module) │ │ (implicit) │ │
│ └────────┬───────┘ └──────┬───────┘ └──────┬──────┘ │
└───────────┼────────────────┼────────────────┼────────┘
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────────────────┐
│ 2. FACADE │
│ (generated dispatch functions) │
│ │
│ ┌────────────────┐ ┌───────────────┐ ┌─────────────┐ │
│ │ContractFacade │ │BehaviourFacade│ │DynamicFacade│ │
│ │use │ │use │ │setup(Mod) │ │
│ │ ContractFacade │ │BehaviourFacade│ │shim │ │
│ │defcallback... │ │ │ │ │ │
│ └────────┬───────┘ └──────┬────────┘ └──────┬──────┘ │
└──────────┼────────────────┼─────────────────┼────────┘
└────────────────┼─────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ 3. DISPATCH │
│ (call resolution) │
│ │
│ ┌────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Static │ │ Runtime │ │ Test │ │
│ │ │ │ Config │ │ Handler │ │
│ │ compile- │ │ │ │ │ │
│ │ time │ │ call_config │ │ call/4 │ │
│ │ inlined │ │ /4 │ │ │ │
│ │ direct │ │ │ │ NimbleOwner- │ │
│ │ call │ │ App.get_env │ │ ship lookup │ │
│ │ │ │ → apply/3 │ │ → handler │ │
│ │ (zero │ │ │ │ │ │
│ │ overhead) │ │ │ │ │ │
│ └─────┬──────┘ └───────┬──────┘ └───────┬──────┘ │
└────────┼─────────────────┼─────────────────┼─────────┘
└─────────────────┼─────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ 4. IMPLEMENTATION │
│ (actual execution) │
│ │
│ ┌──────────────────────────┐ ┌───────────────────┐ │
│ │ Production Module │ │ Test Double │ │
│ │ │ │ │ │
│ │ @behaviour Contract │ │ Module handler │ │
│ │ def operation(...) │ │ Stateless fn │ │
│ │ │ │ Stateful fn │ │
│ │ e.g. MyApp.EctoRepo │ │ Double.expect/ │ │
│ │ │ │ fallback/stub │ │
│ └──────────────────────────┘ └───────────────────┘ │
└──────────────────────────────────────────────────────┘
── Static path (prod, compile-time config): Facade → Static → Production
── Config path (prod, no compile config): Facade → Config → Production
── Test path (test env): Facade → Test → Test Double
── DynamicFacade test path: Facade → Test → Test Double
── DynamicFacade no-handler (passthrough): Facade → (handler not found)
→ original moduleLayer 1: Contract
The contract is the type-level interface — it defines what operations exist and their type signatures, but not how they're implemented.
defcallback (explicit contract)
Use defcallback from DoubleDown.Contract when you control the interface.
It generates @behaviour + @callback + introspection metadata
(__callbacks__/0) from a single macro call. defcallback takes the same parameters as @callback, but unlike @callback requires named
parameters (id :: String.t()) — and these appear in generated @spec
and @doc on the facade, giving LSP-friendly hover docs at every call site.
defmodule MyApp.Todos do
use DoubleDown.Contract
defcallback get_todo(tenant_id :: String.t(), id :: String.t()) ::
{:ok, Todo.t()} | {:error, term()}
defcallback list_todos(tenant_id :: String.t()) :: [Todo.t()]
end@behaviour (explicit contract - existing @behaviour module)
Any existing Elixir @behaviour module works as a contract — see
DoubleDown.BehaviourFacade. Use this for behaviours you don't control:
third-party libraries, existing codebase behaviours, or any module with
@callback declarations.
Implicit (DynamicFacade)
With DoubleDown.DynamicFacade, no separate contract module exists at
all — the target module's public API implicitly becomes the contract.
The module is shimmed at test time via bytecode replacement, so any call
can be intercepted.
Layer 2: Facade
The facade is what callers actually invoke. It generates wrapper functions for each contract operation that delegate to the dispatch layer.
ContractFacade
For defcallback contracts. Supports combined contract + facade
(one module - the default) or separate modules. Options control
dispatch behaviour at compile time.
# Combined contract + facade
defmodule MyApp.Todos do
use DoubleDown.ContractFacade, otp_app: :my_app
defcallback get_todo(id :: String.t()) :: {:ok, Todo.t()} | {:error, term()}
endBehaviourFacade
For vanilla @behaviour modules. The behaviour must be compiled before the
facade — they live in separate modules.
defmodule MyApp.Todos.Facade do
use DoubleDown.BehaviourFacade, behaviour: MyApp.Todos.Behaviour, otp_app: :my_app
endDynamicFacade
Mimic-style bytecode interception. Call setup/1 in test_helper.exs before
ExUnit.start(). The module is backed up and replaced with a dispatch shim.
Tests that don't install a handler get the original module's behaviour.
# test/test_helper.exs
DoubleDown.DynamicFacade.setup(MyApp.EctoRepo)
ExUnit.start()Layer 3: Dispatch
Dispatch resolves which implementation handles a given call. The resolution strategy is selected at compile time and/or call time per-facade via options.
Static dispatch
When static_dispatch?: true and the implementation is available in config at
compile time, the facade function calls the implementation module directly.
No Application.get_env, no NimbleOwnership — the call inlines to identical
bytecode as calling the impl directly. This is the default in :prod.
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 in non-prod when test_dispatch?: false.
Test handler dispatch
DoubleDown.Dispatch.call/4 checks NimbleOwnership for a process-scoped test
handler before falling back to config. This is the default in non-prod
environments — it's what makes DoubleDown.Double.fallback/2, expect/3, etc.
work in tests.
DynamicFacade dispatch
DynamicFacade.dispatch/3 checks NimbleOwnership for a test handler, falling
back to the original (backed-up) module. This is always the path for
a DynamicFacade (which is only ever set up under test) — there's no config-based resolution because there's no contract
module to configure.
Layer 4: Implementation
The implementation is the module or function that actually executes the operation.
Production
A module implementing the contract, wired via config:
# config/config.exs
config :my_app, MyApp.Todos, impl: MyApp.Todos.EctoIf this config is available at compile-time, and static_dispatch?: true
(the default) then ContractFacade and BehaviourFacade will use the
compile-time target and inline calls - there will be zero
runtime overhead for the contract boundary. With DynamicFacade there is
never any production overhead, because the bytecode shims are only
generated under test.
Test doubles
Installed via DoubleDown.Double (recommended) or DoubleDown.Testing:
- Module handler — delegates to a module via
@behaviour - Stateless handler —
fn contract, operation, args -> result end - Stateful handler —
fn contract, operation, args, state -> {result, new_state} end(4-arity) orfn contract, operation, args, state, all_states -> {result, new_state} end(5-arity, with cross-contract state access)
See DoubleDown.Double for the recommended API and
DoubleDown.Dispatch.StatefulHandler for the behaviour.