This guide covers adopting DoubleDown into an existing Elixir/Phoenix codebase alongside direct Ecto.Repo calls. You don't need to migrate everything at once — the two styles coexist cleanly.
Strategy: new code first
The highest-impact, lowest-risk approach is:
- Don't migrate existing tests. They work, they have value, leave them on the Ecto sandbox.
- Write new domain logic behind contracts. New contexts, new features, new orchestration functions.
- Migrate existing code opportunistically. When you're already changing a function, wrap it in a contract boundary.
This means your test suite gradually shifts from slow DB-backed tests to fast in-memory tests as new code accumulates — without any big-bang migration.
The two-contract pattern
Most domain logic interacts with the database in two ways:
- Generic Repo operations —
insert,update,delete,get,transact. These are the same across all features. - Domain-specific queries — "find active users with overdue invoices", "get the latest shift for this employee". These are unique to each feature.
DoubleDown handles these with two contracts:
DoubleDown.Repo— ships with DoubleDown, covers all generic Repo operations. One facade per app, shared by all features.- A per-feature Queries contract — you define this with
defcallbackfor each feature's domain-specific reads.
Example: wrapping a context function
Suppose you have a Billing.create_invoice/1 function that:
- Validates params and builds a changeset
- Looks up the customer's payment method
- Inserts the invoice
- Inserts line items
Step 1: Create a Repo facade (once per app)
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.EctoRepo
# config/test.exs
config :my_app, DoubleDown.Repo, impl: nilStep 2: Define a Queries contract for the domain reads
defmodule MyApp.Billing.Queries do
use DoubleDown.ContractFacade, otp_app: :my_app
defcallback get_payment_method(customer_id :: integer()) ::
{:ok, PaymentMethod.t()} | {:error, :not_found}
end# config/config.exs
config :my_app, MyApp.Billing.Queries, impl: MyApp.Billing.Queries.Ecto
# config/test.exs
config :my_app, MyApp.Billing.Queries, impl: nilStep 3: Implement the Ecto adapter for Queries
defmodule MyApp.Billing.Queries.Ecto do
@behaviour MyApp.Billing.Queries
@impl true
def get_payment_method(customer_id) do
case MyApp.EctoRepo.get_by(PaymentMethod, customer_id: customer_id) do
nil -> {:error, :not_found}
pm -> {:ok, pm}
end
end
endStep 4: Write the domain function using both contracts
defmodule MyApp.Billing do
alias MyApp.Repo
alias MyApp.Billing.Queries
def create_invoice(params) do
Repo.transact(fn ->
with {:ok, pm} <- Queries.get_payment_method(params.customer_id),
{:ok, invoice} <- Repo.insert(Invoice.changeset(params, pm)),
{:ok, _items} <- insert_line_items(invoice, params.items) do
{:ok, invoice}
end
end, [])
end
defp insert_line_items(invoice, items) do
Enum.reduce_while(items, {:ok, []}, fn item, {:ok, acc} ->
case Repo.insert(LineItem.changeset(invoice, item)) do
{:ok, li} -> {:cont, {:ok, [li | acc]}}
{:error, cs} -> {:halt, {:error, cs}}
end
end)
end
endStep 5: Test without a database
defmodule MyApp.BillingTest do
use ExUnit.Case, async: true
alias MyApp.Billing.Queries
setup do
# Queries — stub domain-specific reads
DoubleDown.Double.stub(Queries, :get_payment_method, fn [_customer_id] ->
{:ok, %PaymentMethod{id: 1, type: :card}}
end)
# Repo — stateless writes via Repo.Stub stub
DoubleDown.Double.stub(DoubleDown.Repo, DoubleDown.Repo.Stub)
:ok
end
test "create_invoice inserts invoice and line items" do
DoubleDown.Testing.enable_log(DoubleDown.Repo)
assert {:ok, %Invoice{}} =
MyApp.Billing.create_invoice(%{
customer_id: 1,
items: [%{description: "Widget", amount: 100}]
})
log = DoubleDown.Testing.get_log(DoubleDown.Repo)
operations = Enum.map(log, fn {_, op, _, _} -> op end)
assert :insert in operations
end
endThis test runs in < 1ms. No database, no sandbox, no factories.
Coexisting with direct Ecto.Repo calls
Code that hasn't been migrated continues to call MyApp.EctoRepo
directly. Code behind contract boundaries calls MyApp.Repo (the
facade). Both work in the same application — there's no conflict.
In tests:
- Migrated code uses
DoubleDown.Double(expect/stub/fake) — no DB needed,async: true - Unmigrated code uses
Ecto.Adapters.SQL.Sandboxas before
The two can even coexist in the same test if needed (e.g., an integration test that uses the real DB for some operations and stubs others).
The fail-fast pattern
Set impl: nil in config/test.exs for every contract:
# config/test.exs
config :my_app, DoubleDown.Repo, impl: nil
config :my_app, MyApp.Billing.Queries, impl: nilThis ensures any test that forgets to set up a double gets an
immediate error instead of silently hitting the real implementation.
For integration tests that intentionally use the real DB, use fake
with the production module:
DoubleDown.Double.fake(DoubleDown.Repo, MyApp.EctoRepo)Choosing a Repo fake
Repo.InMemory(recommended) — closed-world stateful fake. The state is the complete truth — all bare-schema reads work without a fallback. Use this for most tests, especially with ExMachina factories.Repo.OpenInMemory— open-world stateful fake. The state may be incomplete — reads for missing records fall through to a fallback function. Use when you need fine-grained control over which reads come from state vs fallback.Repo.Stub— stateless stub. Writes succeed but store nothing. No read-after-write. Fastest setup — use for simple command-style functions that just write and return.
What stays on the DB
Some things should remain as integration tests against a real database:
- Query correctness —
from u in User, where: u.age > 21can't be evaluated in memory. Test these via the Ecto adapter. - Constraint validation — unique indexes, foreign keys, check constraints.
- Transaction isolation — rollback behaviour, concurrent writes.
- Migration testing — schema changes against a real DB.
- End-to-end flows — API → context → DB → response.
The goal isn't to eliminate DB tests — it's to ensure that the tests which don't need a DB don't use a DB.