Packages
double_down
0.69.0
0.69.0
0.68.0
0.66.0
0.65.0
0.64.1
0.64.0
0.63.3
0.63.2
0.63.1
0.63.0
0.62.1
0.61.0
0.60.4
0.60.3
0.60.2
0.60.1
0.60.0
0.59.0
0.58.0
0.57.0
0.56.1
0.56.0
0.55.0
0.54.0
0.53.0
0.52.3
0.52.2
0.52.1
0.52.0
0.51.0
0.50.1
0.50.0
0.49.0
0.48.1
0.48.0
0.47.2
0.47.1
0.47.0
0.46.3
0.46.2
0.46.1
0.46.0
0.45.0
0.44.0
0.43.0
0.42.0
0.41.1
0.41.0
0.40.0
0.39.0
0.38.0
0.37.2
0.37.0
0.35.0
0.34.0
0.33.0
0.32.0
0.31.1
0.31.0
0.30.1
0.30.0
0.29.0
0.28.1
0.28.0
0.27.0
0.26.0
0.24.0
Builds on the Mox pattern — generates behaviours and dispatch facades from `defcallback` declarations — and adds stateful test doubles powerful enough to test Ecto.Repo operations without a database.
Current section
Files
Jump to
Current section
Files
docs/boundaries.md
# Boundaries
<!-- nav:header:start -->
[< DoubleDown](../README.md) | [Up: Guides](../README.md) | [Index](../README.md) | [Dispatch >](dispatch.md)
<!-- nav:header:end -->
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 module
```
## Layer 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.
```elixir
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.
```elixir
# 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()}
end
```
### BehaviourFacade
For vanilla `@behaviour` modules. The behaviour must be compiled before the
facade — they live in separate modules.
```elixir
defmodule MyApp.Todos.Facade do
use DoubleDown.BehaviourFacade, behaviour: MyApp.Todos.Behaviour, otp_app: :my_app
end
```
### DynamicFacade
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.
```elixir
# 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:
```elixir
# config/config.exs
config :my_app, MyApp.Todos, impl: MyApp.Todos.Ecto
```
If 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) or `fn 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.
<!-- nav:footer:start -->
---
[< DoubleDown](../README.md) | [Up: Guides](../README.md) | [Index](../README.md) | [Dispatch >](dispatch.md)
<!-- nav:footer:end -->