DoubleDown
Hexagonal architecture ports for Elixir — typed contracts, async-safe stateful test doubles, and a built-in in-memory Repo that makes database-free testing practical.
The problem
Clean Architecture tells you to put domain logic behind port boundaries, but in practice a couple of things get in the way: maintaining contract behaviours and dispatch facades involves boilerplate that's tedious to keep in sync, and unit-testing with complex dependencies like Ecto is hard enough that most projects never do it - they just hit the database for every test and accept the speed penalty and the inability to adopt property-based testing.
What DoubleDown does
| Feature | Description |
|---|---|
| Typed contracts | defport declarations with full typespecs |
| Contract behaviour generation |
Standard @behaviour + @callback — Mox-compatible |
| Dispatch facades | DoubleDown.Facade generates config-dispatched caller functions |
| LSP-friendly docs | @doc tags on facade functions with types and parameter names |
| Async-safe test doubles | Process-scoped handlers via NimbleOwnership |
| Stateful test handlers | In-memory state with atomic updates and fallback dispatch |
| Dispatch logging | Record every call that crosses a port boundary |
| Built-in Repo contract | 15-operation Ecto Repo contract with stateless + in-memory impls |
Terminology
If you're coming from Mox or standard Elixir testing, here's how DoubleDown's terms map to what you already know:
| DoubleDown term | Familiar Elixir equivalent |
|---|---|
| Contract |
Behaviour (@callback specs) — the interface an implementation must satisfy |
| Facade |
The dispatch module (def foo(x), do: impl().foo(x)) — DoubleDown generates this |
| Test double | Mock/stub/fake — anything standing in for a real implementation in tests |
| Port | A contract + its facade — the boundary through which I/O operations pass |
See Getting Started for the expanded version with test double types (mocks, stubs, fakes).
Quick example
Define a port contract and facade in one module:
defmodule MyApp.Todos do
use DoubleDown.Facade, otp_app: :my_app
defport create_todo(params :: map()) ::
{:ok, Todo.t()} | {:error, Ecto.Changeset.t()}
defport get_todo(id :: String.t()) ::
{:ok, Todo.t()} | {:error, :not_found}
defport list_todos(tenant_id :: String.t()) :: [Todo.t()]
endImplement the behaviour:
defmodule MyApp.Todos.Ecto do
@behaviour MyApp.Todos
@impl true
def create_todo(params), do: MyApp.Repo.insert(Todo.changeset(params))
@impl true
def get_todo(id) do
case MyApp.Repo.get(Todo, id) do
nil -> {:error, :not_found}
todo -> {:ok, todo}
end
end
# ...
endWire it up:
# config/config.exs
config :my_app, MyApp.Todos, impl: MyApp.Todos.EctoTest with an in-memory test double — no database, full async isolation:
# test/test_helper.exs
DoubleDown.Testing.start()
# test/my_app/todos_test.exs
defmodule MyApp.TodosTest do
use ExUnit.Case, async: true
setup do
DoubleDown.Testing.set_stateful_handler(
MyApp.Todos,
fn
:create_todo, [params], todos ->
todo = struct!(Todo, Map.put(params, :id, System.unique_integer()))
{{:ok, todo}, Map.put(todos, todo.id, todo)}
:get_todo, [id], todos ->
case Map.get(todos, id) do
nil -> {{:error, :not_found}, todos}
todo -> {{:ok, todo}, todos}
end
:list_todos, [_tenant], todos ->
{Map.values(todos), todos}
end,
%{} # initial state — empty store
)
:ok
end
test "create then get" do
{:ok, todo} = MyApp.Todos.create_todo(%{title: "Ship it"})
assert {:ok, ^todo} = MyApp.Todos.get_todo(todo.id)
end
test "get non-existent returns error" do
assert {:error, :not_found} = MyApp.Todos.get_todo(-1)
end
endNo Mox modules, no database, no sandbox — just a function that maintains state. Each test process gets its own isolated state via NimbleOwnership.
For Ecto-heavy code, DoubleDown also ships Repo.InMemory — a
ready-made stateful test double for the built-in Repo contract with
read-after-write consistency, Ecto.Multi support, and speeds suitable
for property-based testing. See Repo.
Documentation
- Getting Started — contracts, facades, behaviours, config, dispatch resolution
- Testing — handler modes, dispatch logging, async safety, process sharing, Mox compatibility
- Repo — built-in Ecto Repo contract, production adapter, stateless and in-memory test doubles
- Migration — incremental adoption into existing codebases, the two-contract pattern, coexisting with direct Ecto.Repo calls
Installation
Add double_down to your dependencies in mix.exs:
def deps do
[
{:double_down, "~> x.y"}
]
endCheck hex.pm/packages/double_down for the latest version.
Ecto is an optional dependency. If you want the built-in Repo contract, add Ecto to your own deps.
Relationship to Skuld
DoubleDown extracts the port system from Skuld (algebraic effects for Elixir) into a standalone library. You get typed contracts, async-safe test doubles, and dispatch logging without needing Skuld's effect system. Skuld depends on DoubleDown and layers effectful dispatch on top.
License
MIT License - see LICENSE for details.