jonnifyelixir
F6 / F6.03 / repo

F6.03 · part 3 of 3

Queries & the repo

Ecto splits describing a question from asking it. A query is data — a value built with the from DSL that you can pass around and compose, adding a where or an order_by to an existing query without running anything. The repo is the one module that executes: Repo.all runs a query, Repo.get fetches one row by its Snowflake id, and Repo.insert persists a changeset. The architectural point is where the repo sits: behind the engine’s port. The domain core from F5 reads and appends through the Portal.EventStore behaviour, and the Postgres adapter is the only module that calls Repo — so swapping in-memory for Postgres is a config change, and the Portal logic never imports Ecto.

See the three repo operations, then the boundary that keeps Ecto on the far side of the port, then a composable query and the adapter that implements the F5.09 behaviour.

Three repo operations

Most data access is three calls. Repo.get takes a schema and an id and returns one struct or nil; Repo.all runs a query and returns a list; Repo.insert takes a changeset and writes it, returning the tagged tuple from F6.03.2. Select an operation.

Repo operations · select one

Repo.get Repo.get(Course, "CRS0KHTOWnGLuC") — one row by id, or nil by Snowflake Repo.all Repo.all(published()) — run a query, return a list a list Repo.insert Repo.insert(changeset) — persist; {:ok, _} | {:error, cs} tagged tuple

operation: Repo.get

returns: one row by id

A query is a value; the repo is the only thing that runs it. That split is what lets you build a query in one function, test it without a database, and execute it somewhere else entirely.

Ecto sits behind the port

The Portal’s layering does not change for the database. The domain core calls the Portal.EventStore behaviour — the driven port from F5.09 — which has two adapters chosen by config: an in-memory one for tests and a Postgres one for production. Only that Postgres adapter touches Repo and Ecto. The core, and everything above it, names no database.

domain → port → adapter → database

DOMAIN CORE no Ecto PORT EventStore behaviour the seam POSTGRES ADAPTER Ecto + Repo here DATABASE Postgres Ecto lives only to the right of the seam; config picks the adapter, and the core never changes

This is the F5.09 port paying off. The database is the most replaceable detail in the system, and the port is why — the engine asked for “append” and “read,” not for Postgres.

A query and the adapter

A query function returns a composable value; callers add to it before the repo runs it. The Postgres adapter is the one place Repo and Ecto.Query appear: it implements append/2 and read_stream/1 from the F5.09 behaviour, so the engine’s init/1 replay and command append run against Postgres unchanged.

import Ecto.Query

# a composable query — data, not execution
def published(query \\ Course), do: from c in query, where: c.published == true

Repo.all(published())                                  # run the query
Repo.all(from c in published(), order_by: [desc: c.inserted_at])  # compose
Repo.get(Course, "CRS0KHTOWnGLuC")                      # one row by Snowflake id

# Ecto lives behind the engine's port — the F5.09 Postgres adapter
defmodule Portal.EventStore.Postgres do
  @behaviour Portal.EventStore
  import Ecto.Query

  @impl true
  def append(stream, events), do: Repo.insert_all(Event, rows_for(stream, events))

  @impl true
  def read_stream(stream) do
    {:ok, Repo.all(from e in Event, where: e.stream == ^stream, order_by: e.seq)}
  end
end

a query is data

Built with from, composed freely, and run only by the repo.

the repo is the adapter

Repo appears only in the Postgres adapter behind the F5.09 port.

That completes F6.03. Ecto is wired as an adapter, not a dependency of the core. The next module, F6.04 — Contexts & domain design, draws the boundary between Phoenix contexts and the F5 facade. Back to the module overview or the F6 chapter.