< 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, and are 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 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's @behaviour, 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.
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.