Incremental Migration

Copy Markdown View Source

< Repo Testing | Up: README

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:

  1. Don't migrate existing tests. They work, they have value, leave them on the Ecto sandbox.
  2. Write new domain logic behind contracts. New contexts, new features, new orchestration functions.
  3. 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:

  1. Generic Repo operationsinsert, update, delete, get, transact. These are the same across all features.
  2. 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 defcallback for each feature's domain-specific reads.

Example: wrapping a context function

Suppose you have a Billing.create_invoice/1 function that:

  1. Validates params and builds a changeset
  2. Looks up the customer's payment method
  3. Inserts the invoice
  4. 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: nil

Step 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: nil

Step 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
end

Step 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
end

Step 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
end

This 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.Sandbox as 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: nil

This 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 correctnessfrom u in User, where: u.age > 21 can'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.


< Repo Testing | Up: README