Replicas and dynamic repositories
This guide covers two of Ecto’s scaling patterns: routing reads to read replicas while writes go to a primary, and swapping a repository’s underlying database connection at runtime (dynamic repositories). Both are about answering one question at the point of each operation — which database does this run against? — and both land somewhere unexpected in lode, because the answer is already sitting in your hand.
In Elixir, a repo is a module: MyApp.Repo is registered in a supervision
tree, holds its connection in a named process, and Repo.all/2 reaches into the
process dictionary to find “the current repo.” Replicas and dynamic repos are
both refinements of that machinery — a second registered module, or a runtime
override of the process-dictionary entry. lode has none of that
machinery. As Getting Started shows, a
Repo is a plain value built from an adapter built from a connection:
import lode/adapters/postgres
import lode/repo
let r = repo.new(postgres.new(conn))
Every operation takes that value as an explicit, labelled argument
(repo.all(repo:, schema:, query:), repo.insert(repo:, schema:, changeset:),
and so on), so “which database” is never implicit — it is whichever Repo value
you pass. That single design choice subsumes both patterns in this guide. The
rationale lives in DESIGN.md in the repository (§1’s impedance-mismatch table
and §8 on repo & adapter); §11 lists the process-dictionary dynamic repo as an
intentional drop.
Part 1: Replicas
A read replica is a second database that streams changes from the primary. You
send writes to the primary and reads to a replica, spreading read load and
keeping the primary free for writes. In Ecto this means defining MyApp.Repo
for the primary and one or more MyApp.Repo.Replica1, MyApp.Repo.Replica2,
… modules for the replicas — separate use Ecto.Repo modules, each with its
own config and its own entry in the supervision tree — plus a replica/0
picker that chooses one at random for each read.
In lode there is no “replica repo” abstraction to introduce, because a
replica is just another connection, and another connection is just another
Repo value. You already know how to build one. Connect to each database and
wrap it:
// src/db.gleam
import gleam/erlang/process
import gleam/int
import gleam/list
import gleam/option.{Some}
import pog
fn connect(name: String, host: String) -> pog.Connection {
let assert Ok(started) =
pog.default_config(process.new_name(name))
|> pog.host(host)
|> pog.database("my_app")
|> pog.user("user")
|> pog.password(Some("pass"))
|> pog.rows_as_map(True)
|> pog.start
started.data
}
pub fn primary_conn() -> pog.Connection {
connect("my_app_primary", "primary.db.internal")
}
pub fn replica_conns() -> List(pog.Connection) {
["replica1.db.internal", "replica2.db.internal"]
|> list.index_map(fn(host, i) {
connect("my_app_replica_" <> int.to_string(i), host)
})
}
Then the repos. The primary is one value; the replicas are a list of values — exactly the shape of “one or more replicas,” with no per-replica module to declare:
// src/repos.gleam
import db
import lode/adapters/postgres
import lode/repo.{type Repo}
import gleam/list
pub fn primary() -> Repo {
repo.new(postgres.new(db.primary_conn()))
}
pub fn replicas() -> List(Repo) {
db.replica_conns()
|> list.map(fn(conn) { repo.new(postgres.new(conn)) })
}
The picker
Ecto’s replica/0 reads a list of replica modules and picks one with
Enum.random/1 per call. The translation is direct — pick a Repo from the
List(Repo):
import gleam/list
/// Pick a replica to read from. `list.sample` chooses uniformly at random.
pub fn replica() -> Repo {
let candidates = replicas()
case list.sample(candidates, 1) {
[chosen] -> chosen
// No replicas configured (or sampling returned nothing): read the primary.
_ -> primary()
}
}
Random selection needs no shared state, so it ports unchanged. Round-robin
is the one place the BEAM idiom doesn’t carry over: Ecto can lean on a process
or :persistent_term counter that mutates between calls, but Gleam has no
ambient mutable counter to bump. So the rotation index is explicit — you
thread it through, the same way you thread the repo. Either advance an index you
already hold:
/// Round-robin over the replicas using an explicit, caller-held index.
/// Returns the chosen repo and the next index to use.
pub fn replica_round_robin(index: Int) -> #(Repo, Int) {
let candidates = replicas()
case candidates {
[] -> #(primary(), 0)
_ -> {
let n = list.length(candidates)
let i = index % n
let assert Ok(chosen) = list_at(candidates, i)
#(chosen, { i + 1 } % n)
}
}
}
// Gleam's stdlib dropped `list.at`; index into a list with drop + first.
fn list_at(items: List(a), index: Int) -> Result(a, Nil) {
items |> list.drop(index) |> list.first
}
…or pick by a value you already have on the request — a user id, a shard key, a hash of the path — which is deterministic, needs no counter at all, and keeps a given reader pinned to a given replica:
/// Pick a replica deterministically from any integer key.
pub fn replica_for(key: Int) -> Repo {
let candidates = replicas()
case candidates {
[] -> primary()
_ -> {
let assert Ok(chosen) = list_at(candidates, key % list.length(candidates))
chosen
}
}
}
The explicit index is the same design that drops the process dictionary: state
Ecto keeps ambient is, here, a value you pass (DESIGN.md §11). If you do want a
shared rotating counter, a gleam_otp actor holding the index is the
BEAM-shaped answer — but for routing reads, random or key-based selection avoids
the extra process entirely.
Routing reads and writes
With a picker in place, your data-access functions take the repo they should run
against as an argument. Writes name the primary; reads name a replica. Nothing
about the operations changes — only which Repo flows in:
import lode/changeset.{type Changeset}
import lode/error.{type LodeError}
import lode/query.{type Query}
import lode/repo.{type Repo}
import lode/schema.{type Schema}
pub fn create_user(
write: Repo,
schema: Schema(user),
cs: Changeset(user),
) -> Result(user, LodeError) {
repo.insert(repo: write, schema: schema, changeset: cs)
}
pub fn list_users(
read: Repo,
schema: Schema(user),
q: Query,
) -> Result(List(user), LodeError) {
repo.all(repo: read, schema: schema, query: q)
}
At the call site, the routing is plain to read — and the compiler checks that every operation names a repo:
// A write must go to the primary:
let assert Ok(user) =
create_user(repos.primary(), user_schema(), new_user_changeset(params))
// A read can go to any replica:
let assert Ok(users) =
list_users(repos.replica(), user_schema(), active_users_query())
The design win is that there is no “replica repo” concept to learn, no second
module to register, and no supervision wiring per replica: a replica is more of
the same value you already use. Adding a third replica is appending a host to a
list. And because the parameter has type Repo, a read function physically
cannot be called without being told which database to read from — the thing
Ecto’s process dictionary leaves implicit is, here, in the type.
One caveat carries over from Ecto unchanged: replication is asynchronous, so a
read on a replica may not yet see a write that just succeeded on the primary
(“read-your-writes” staleness). When an operation needs the latest data, route
that particular read to repos.primary() — which, again, is just passing a
different value.
Part 2: Dynamic repositories
Ecto’s dynamic repositories let you point a repo at a different connection at
runtime. The mechanism is MyApp.Repo.put_dynamic_repo/1: you start a repo
process under some name, then store that name in the process dictionary so that
every subsequent MyApp.Repo.all/2 in the current process is routed to it
instead of the statically configured connection. The motivating use cases are
runtime-resolved databases — connecting to a tenant’s database chosen per
request, or running the same code against a database whose credentials are only
known at runtime.
lode drops put_dynamic_repo / get_dynamic_repo entirely — there is
no process-dictionary override, because there is no process-dictionary “current
repo” to override. This is the intentional drop recorded in DESIGN.md §11.
The capability the feature provides, though, is not just preserved but met more
directly. The whole point of put_dynamic_repo is to substitute a connection at
runtime; in lode a Repo is a first-class value you can construct at
runtime from any connection you can build at runtime. “Use a different database
for this operation” is therefore not a special mode you switch a global repo
into — it is the ordinary act of building the repo value and passing it:
import lode/adapters/postgres
import lode/repo.{type Repo}
import gleam/erlang/process
import gleam/option.{Some}
import pog
/// Build a repo for an arbitrary database, resolved at runtime.
pub fn repo_for_database(name: String) -> Repo {
let assert Ok(started) =
pog.default_config(process.new_name("dynamic_" <> name))
|> pog.host("localhost")
|> pog.database(name)
|> pog.user("user")
|> pog.password(Some("pass"))
|> pog.rows_as_map(True)
|> pog.start
repo.new(postgres.new(started.data))
}
That repo flows into the same query functions as any other — there is no second code path for “dynamic” operation. A per-tenant request handler resolves the tenant’s database, builds the repo, and runs its queries through it:
pub fn handle_request(tenant: String, q: Query) -> Result(List(account), LodeError) {
let tenant_repo = repo_for_database("tenant_" <> tenant)
repo.all(repo: tenant_repo, schema: account_schema(), query: q)
}
Compare the two shapes. Ecto wraps the dynamic-repo dance around the code:
# Elixir / Ecto — implicit, process-dictionary scoped
MyApp.Repo.put_dynamic_repo(tenant_repo)
MyApp.Repo.all(query) # routed via the process dictionary
lode has no wrapping step — the repo is simply the argument:
// lode — explicit, lexically scoped
repo.all(repo: tenant_repo, schema: account_schema(), query: q)
This re-expression is, if anything, stricter and safer:
- No implicit global.
put_dynamic_repomutates per-process state; forget to set it (or to restore it) and operations silently hit the wrong database. Here the repo is lexically scoped to where you pass it, so it cannot leak into unrelated code or outlive its intended scope. - The compiler enforces it. Every operation must name its repo — you cannot
call
repo.allwithout supplying one — so there is no “I forgot to callput_dynamic_repo” failure mode to debug. - No checkout/restore bookkeeping. Ecto pairs
put_dynamic_repo(x)with a laterput_dynamic_repo(default)to avoid contaminating the process; passing a value needs no teardown.
Because connections are pooled processes, building a repo per request is cheap
only if the connection is reused — pog.start creates a pool, so for a
tenant you talk to repeatedly, build the repo once (at startup, or memoize it)
and pass the cached value, rather than starting a fresh pool on every request.
The value model makes this trade-off visible and yours to make, where Ecto’s
named-process model decides it for you.
Within a transaction the same explicitness holds: repo.transaction hands your
closure a transaction-scoped Repo, and you must use that value for the work
inside, so the operations run on the transaction’s connection rather than a new
one — the lexical-scoping analogue of Ecto routing transaction work through the
checked-out connection.
This is a re-expression, not a gap: dynamic repositories’ purpose — runtime
choice of database — is fully present, and arguably cleaner, so there is no
[TASK:] to file. The one thing genuinely gone is the ambient implicitness
itself, which was the design’s cost, not its capability.
Where to go next
For per-tenant data isolation that shares a single database via schema prefixes rather than separate connections, see Multi tenancy with query prefixes — the per-tenant-repo approach above is the separate-database counterpart to that guide’s single-database approach. For how repos, adapters, and connections fit together from the ground up, revisit Getting Started. For the complete map of what differs from Elixir’s Ecto, see Divergences from Ecto.