< Double API | Up: Guides | Index | Changelog >
DoubleDown ships a complete Ecto.Repo contract and three test doubles,
letting you replace the database with an in-memory store in tests while
keeping your existing ExMachina factories and Ecto.Multi transactions.
The contract
DoubleDown.Repo defines the full Ecto.Repo surface — writes, reads,
aggregates, associations, streaming, and transactions:
| Category | Operations |
|---|---|
| Writes | insert, update, delete, insert_or_update + bang variants |
| Bulk | insert_all, update_all, delete_all |
| PK reads | get, get! |
| Non-PK reads | get_by, get_by!, one, one!, all, all_by, exists?, aggregate |
| Associations | preload, load, reload, reload! |
| Streaming | stream |
| Transactions | transact, rollback, in_transaction? |
| Raw SQL | query, query! |
Creating a Repo facade
With ContractFacade (recommended)
defmodule MyApp.Repo do
use DoubleDown.ContractFacade, contract: DoubleDown.Repo, otp_app: :my_app
end# config/config.exs
config :my_app, DoubleDown.Repo, impl: MyApp.EctoRepoWith the default :static_dispatch? setting, production dispatch compiles
away entirely — MyApp.Repo.insert(changeset) produces identical bytecode
to MyApp.EctoRepo.insert(changeset).
With DynamicFacade
If your Repo module has custom functions beyond the standard API, or you don't want a facade module:
# test/test_helper.exs
DoubleDown.DynamicFacade.setup(MyApp.EctoRepo)
ExUnit.start()Then in tests, use your Ecto Repo module directly as the contract:
DoubleDown.Double.fallback(MyApp.EctoRepo, DoubleDown.Repo.InMemory)Test doubles
| Double | Type | Reads | Best for |
|---|---|---|---|
Repo.InMemory | Closed-world fake | All bare-schema reads | ExMachina, most tests |
Repo.OpenInMemory | Open-world fake | PK reads only | Fine-grained fallback control |
Repo.Stateless | Stateless stub | Fallback only | Fire-and-forget writes |
All three validate changesets (returning {:error, changeset} on invalid),
autogenerate primary keys and timestamps via Ecto schema metadata, accept
both changesets and bare structs, and support Ecto.Multi transactions.
Repo.InMemory (recommended)
Closed-world semantics — the in-memory store is the complete truth. If a
record isn't in the store, it doesn't exist. All bare-schema reads
(get, get_by, all, exists?, aggregate) work without a fallback.
setup do
DoubleDown.Double.fallback(DoubleDown.Repo, DoubleDown.Repo.InMemory)
:ok
end
test "insert then read back" do
{:ok, user} = MyApp.Repo.insert(User.changeset(%{name: "Alice"}))
assert ^user = MyApp.Repo.get(User, user.id)
assert [^user] = MyApp.Repo.all(User)
endSeed data can be passed as the third argument:
DoubleDown.Double.fallback(DoubleDown.Repo, DoubleDown.Repo.InMemory,
[%User{id: 1, name: "Alice"}])Ecto.Query operations that InMemory can't evaluate natively delegate to
an optional fallback_fn:
DoubleDown.Double.fallback(DoubleDown.Repo, DoubleDown.Repo.InMemory, [],
fallback_fn: fn
_contract, :all, [%Ecto.Query{}], _state -> []
end
)Repo.OpenInMemory
Open-world semantics — the store may be incomplete. PK reads hit the store first then fall through to a user-supplied fallback. All other reads always go through the fallback. Use when you need fine-grained control over which reads are served from state:
DoubleDown.Double.fallback(DoubleDown.Repo, DoubleDown.Repo.OpenInMemory, [],
fallback_fn: fn
_contract, :get_by, [User, [email: email]], _state -> %User{email: email}
_contract, :all, [User], state -> state |> Map.get(User, %{}) |> Map.values()
end
)Repo.Stateless
Writes succeed but store nothing. Reads raise unless you supply a fallback function. Best for simple command-style functions:
DoubleDown.Double.fallback(DoubleDown.Repo, DoubleDown.Repo.Stateless)
DoubleDown.Double.expect(DoubleDown.Repo, :insert, fn [changeset] ->
{:error, Ecto.Changeset.add_error(changeset, :email, "taken")}
end)ExMachina integration
Point ExMachina at your Repo facade (not your Ecto Repo - unless you used a DynamicFacade, in which case your Repo facade is your Ecto Repo):
defmodule MyApp.Factory do
use ExMachina.Ecto, repo: MyApp.Repo
def user_factory do
%User{name: sequence(:name, &"User #{&1}"), email: sequence(:email, &"user#{&1}@example.com")}
end
endWith Repo.InMemory installed, factory inserts land in the in-memory
store and all bare-schema reads find them:
setup do
DoubleDown.Double.fallback(DoubleDown.Repo, DoubleDown.Repo.InMemory)
:ok
end
test "factory-inserted records are readable" do
insert(:user, name: "Alice")
insert(:user, name: "Bob")
assert [_, _] = MyApp.Repo.all(User)
assert %User{name: "Alice"} = MyApp.Repo.get_by(User, name: "Alice")
assert 2 = MyApp.Repo.aggregate(User, :count, :id)
endTransactions and rollback
transact/2 mirrors Ecto.Repo.transact/2 — accepts a function or an
Ecto.Multi:
# Function
MyApp.Repo.transact(fn ->
{:ok, user} = MyApp.Repo.insert(user_changeset)
{:ok, profile} = MyApp.Repo.insert(profile_changeset(user))
{:ok, {user, profile}}
end, [])
# Ecto.Multi
Ecto.Multi.new()
|> Ecto.Multi.insert(:user, user_changeset)
|> Ecto.Multi.run(:profile, fn repo, %{user: user} ->
repo.insert(profile_changeset(user))
end)
|> MyApp.Repo.transact([])rollback/1 aborts the transaction and restores the pre-transaction state
in Repo.InMemory and Repo.OpenInMemory:
MyApp.Repo.transact(fn repo ->
{:ok, _} = repo.insert(user_changeset)
if problem?, do: repo.rollback(:constraint_violated)
{:ok, user}
end, [])
# Returns {:error, :constraint_violated}; insert is undoneOnly the Repo contract's state is restored on rollback — other contracts' state modified during the transaction is unaffected. The in-memory adapters do not provide full ACID isolation (sub-operations are individually atomic but not grouped). For true ACID, use the real Ecto adapter in integration tests.
What stays on the database
Tests that exercise database-specific behaviour should continue to run against a real database:
- Query correctness —
Ecto.Queryexpressions can't be fully evaluated in memory - Constraint validation — unique indexes, foreign keys, check constraints
- Transaction isolation — concurrent writes, rollback interaction
- Migrations — schema changes
The goal isn't to eliminate DB tests — it's to move the ~3/4 of tests that use the database merely as a slow data-fixture mechanism to run DB-free.