Repo and executor
Two pieces carry the data layer’s transparency: the ambient Executor
installed per request (or per job), and the Repo<E> handle every service
uses to reach it. They are built on
SeaORM — Executor implements SeaORM’s
ConnectionTrait, so any SeaORM query runs through it unchanged — and on
the seam from nest-rs-database,
which is what keeps the same seam open to a non-SeaORM driver.
The invariant: every data access goes through a service, and a service
reaches the DB only through Repo. That is the single audited choke
point per entity.
Repo’s surface
Section titled “Repo’s surface”From a handler’s point of view there is nothing to set up: you call the
service, the service calls Repo, and Repo picks up the ambient
executor the framework installed before the handler ran — the pool on a
safe route, a transaction on a mutation. No connection parameter, no
transaction object to thread through the call stack.
Repo<E> is generic over the entity. Every method runs against the ambient
executor, every read carries the ambient ability filter:
Repo::<Items>::conn()?; // raw handle for a custom queryRepo::<Items>::all().await?; // every readable rowRepo::<Items>::find_by_id(id).await?; // None if absent OR out of scopeRepo::<Items>::scoped(Action::Read); // Select<E> pre-filtered, chain freelyRepo::<Items>::update(active).await?; // DbErr::RecordNotUpdated if out of scopeRepo::<Items>::delete(model).await?; // 0 rows if out of scopeRepo::<Items>::page(first, after, Condition::all()).await?; // keyset page — see PaginationA by-id write (update, delete) ANDs condition_for(Update|Delete) with
the primary key, so a caller cannot mutate a row outside its scope even
with a leaked id. The macro-generated CrudService::access(action, id)
loads unscoped so a denied-but-existing row surfaces as Access::Denied
(distinct from Access::Missing) — the gateway route-model binding goes
through.
Under the hood: the ambient executor
Section titled “Under the hood: the ambient executor”Executor is a SeaORM connection — the pool or the request’s transaction —
installed in a tokio::task_local! for the duration of the handler. Two
variants:
pub enum Executor { Pool(DatabaseConnection), Txn(Arc<DatabaseTransaction>),}Repo::<E>::conn() reads the task-local back. Outside the scope it returns
an error naming the DbContext interceptor — a service called with no
ambient executor is a wiring bug, not a silent miss.
let conn = Repo::<Items>::conn()?;let row = Items::find().filter(Column::Name.eq(name)).one(&conn).await?;Three scopes coexist:
| Scope | Installed by | What it carries |
|---|---|---|
| Request | DbContext interceptor | Pool on safe routes, txn on mutations |
| Job | WorkerDbContext (workers) | Pool, no transaction |
| Unscoped | nothing | Repo::conn() errors |
current_executor_scope() distinguishes Request from Job — Repo uses it
to fail closed when a request runs with no ambient ability, while a worker
runs unscoped (no caller ⇒ no scope to apply). See
Security for the ability half.
The DbContext interceptor
Section titled “The DbContext interceptor”DbContext is auto-registered when you import DatabaseModule. It wraps
every HTTP request outside the route’s guards so guards and handlers
resolve the same ambient executor through Repo:
HTTP request ↓DbContext ──→ installs Executor::Pool (GET/HEAD/OPTIONS/TRACE) ──→ installs Executor::Txn (POST/PUT/PATCH/DELETE) ↓guards (AuthGuard, AbilityGuard, ...) ↓handler ↓DbContext (response) ──→ commit on 2xx/3xx, rollback otherwiseA safe method gets the pool, no transaction overhead. A mutating method
opens a DatabaseTransaction, runs the handler against it, and commits
or rolls back based on the response status:
| Outcome | Decision |
|---|---|
Ok(2xx) or Ok(3xx) | commit |
Ok(4xx) or Ok(5xx) | rollback |
Err(_) from the handler | rollback |
A failed mutation never half-persists. See Transactions for the conflict-retry knobs and the honest caveats around them.
Workers — same Repo, no transaction
Section titled “Workers — same Repo, no transaction”Queue processors and cron jobs run outside any HTTP request — no method
to classify, no response to inspect. DatabaseModule binds a
WorkerDbContext to dyn JobContext, which the worker transport
(#[scheduled], #[processor]) runs around every job:
job ↓WorkerDbContext ──→ installs Executor::Pool, no transaction ↓processor bodyA job runs on the pool. No caller means no ambient ability, which means
Repo reads and writes are unscoped — correct for system work with no
principal to scope to. A job that needs scoping wraps its body in
with_ability(...) explicitly.
A mutating job that wants atomicity opens its own transaction through
SeaORM’s TransactionTrait — same as any programmatic transaction in a
service — and runs retry_on_conflict around it if needed
(see Transactions).
The contextless-path exception
Section titled “The contextless-path exception”A truly contextless path — a shutdown hook that runs after every transport
has closed, a one-shot CLI command bootstrapped outside App::run — has
no task-local to read. That is the one documented Repo bypass: inject
an Arc<DatabaseConnection> and call SeaORM directly.
#[hooks]impl UsersService { #[on_application_shutdown] async fn report(&self) -> anyhow::Result<()> { let count = Users::find().count(self.db.as_ref()).await?; tracing::info!(count, "users present at shutdown"); Ok(()) }}self.db is an Arc<DatabaseConnection> injected on the service —
unscoped, no ability filter applies, no transaction. This is the only
documented bypass; if you find yourself reaching for it in a request
handler, something else is wrong.
Going further
Section titled “Going further”- Database — the 60-line slice that imports
DatabaseModule. - Dataloaders —
#[dataloader]methods are Repo-backed too; theLoaderScopere-installs the executor around each batch. - Transactions — auto-commit details, conflict retry primitives.
- Security —
Ability,condition_for, how the row-level filter is built. - Writing a driver — implement
Executorfor a non-SeaORM store on the same seam.