Skip to content

Writing a database driver

A backend-agnostic scaffold — the shape of a driver for any store with a handle and an optional transaction, SQL or NoSQL, with the framework seams filled in.

The nest-rs-database crate is a seam: an object-safe Executor trait, two tokio::task_local!s, and the install helpers that thread “the request’s current handle, optionally inside a transaction” across every layer. The seam names no store and no query API — a handle is a handle. The same scaffold fits a SQL pool, a document-store client, a key-value session, a graph driver, an in-memory map. This page gives you the shape of a driver, not its implementation: the trait you implement, the methods you wire, with // your code here markers where the store-specific work lands. The reference impl is nest-rs-seaorm — read it once when a placeholder is unclear.

Terminal window
cargo add nest-rs --features database,worker

A driver implements the seam rather than consuming it, so it takes database without seaorm — the first-party integration is what you are replacing. worker carries the ambient job context, which is what makes the same handle reachable off a request.

  • An Executor enum holding your store’s handles, with the trait impl.
  • A request interceptor that wraps mutating handlers in a transaction.
  • A JobContext that installs a handle per job attempt and settles it — transactional by default, non-transactional on transactional = false.

A driver lives in a nest-rs-<technology> crate that depends on nest-rs-core, nest-rs-database, nest-rs-http, nest-rs-interceptors, nest-rs-worker, plus whatever your store needs. The app picks a driver by importing its module instead of nest-rs-seaorm.

An enum is the right shape because the interceptor branches on it to decide commit vs rollback, and a single query helper returns one type whichever mode the request is in. Two variants: one for ordinary work, one carrying a transaction. A store with no transaction primitive collapses to a single variant — the seam absorbs that too.

crates/yourdb/src/executor.rs
use std::any::Any;
/// Your driver's handle, threaded through the framework's task-local.
/// The framework only reaches it via the trait; the variants are yours.
#[derive(Clone)]
pub enum Executor {
/// Non-transactional handle — pool, client, connection.
/// Used on safe routes (GET/HEAD/OPTIONS) and on a job that opted out
/// with `transactional = false`.
Default(/* your handle, e.g. YourPool */),
/// Transactional handle — your store's unit of work.
/// Used on mutating routes and on a job attempt by default; committed
/// or rolled back at the boundary.
Transactional(/* your tx/session type, wrapped in Arc<Mutex<_>> */),
}
impl nest_rs::database::Executor for Executor {
fn as_any(&self) -> &dyn Any { self }
}

The Arc<Mutex<_>> around the transactional variant is the common pattern because most stores’ transaction handles take &mut on their query methods, and the interceptor needs to keep one reference while the handler holds another.

This is the centerpiece. The interceptor classifies the HTTP method, installs the right executor in the task-local, and on the way out commits or rolls back. The framework owns the task-local install, the Arc lifecycle, and the response branching. The five numbered comments mark the five places you write store-specific code.

crates/yourdb/src/interceptor.rs
use std::sync::Arc;
use nest_rs::core::async_trait;
use nest_rs::core::{Layer, injectable};
use nest_rs::interceptors::{Interceptor, Next};
use nest_rs::http::poem::http::{Method, StatusCode};
use nest_rs::http::poem::{Error, Request, Response, Result};
use tokio::sync::Mutex;
#[injectable]
pub struct YourDbContext {
// #[inject] your pool / client here
}
#[async_trait]
// `Interceptor` has `Layer` as a supertrait — an empty impl is enough
// unless the layer needs a `priority`.
impl Layer for YourDbContext {}
impl Interceptor for YourDbContext {
async fn intercept(&self, req: Request, next: Next<'_>) -> Result<Response> {
let mutating = !matches!(
*req.method(),
Method::GET | Method::HEAD | Method::OPTIONS | Method::TRACE,
);
if !mutating {
// (1) Safe route — install the non-transactional handle.
// Your code: clone your pool/client into Executor::Default(...).
return nest_rs::database::with_request_executor(
Arc::new(Executor::Default(/* your handle clone */)),
next.run(req),
).await;
}
// (2) Mutating route — open a transaction in your store's terms.
// Your code: `self.pool.begin().await?`, `client.start_session().await?`, ...
let tx = /* your store's transaction handle */;
let tx_arc = Arc::new(Mutex::new(tx));
// (3) Install it as the request executor and run the handler.
let result = nest_rs::database::with_request_executor(
Arc::new(Executor::Transactional(tx_arc.clone())),
next.run(req),
).await;
// (4) Reclaim sole ownership. Failure means the executor escaped
// into a spawned task — fail closed.
let tx = match Arc::try_unwrap(tx_arc) {
Ok(mutex) => mutex.into_inner(),
Err(_) => return Err(Error::from_status(StatusCode::INTERNAL_SERVER_ERROR)),
};
// (5) Commit on 2xx/3xx, roll back otherwise.
// Your code: tx.commit().await? / tx.rollback().await?
// (or commit_transaction / abort_transaction, etc.)
match &result {
Ok(resp) if resp.status().is_success() || resp.status().is_redirection() => {
// commit using your store's API
}
_ => {
// roll back using your store's API
}
}
result
}
}

The escape check at (4) is not paranoia — a handler that tokio::spawns a task carrying the executor lets the spawned task outlive the request. Failing closed beats reporting committed data the spawned task may still write.

Cron jobs and queue processors run outside any HTTP request — no method to classify, no response to inspect. The JobContext seam installs your handle around each job and then settles what it opened. No caller means no ambient ability, which means a job runs unscoped: correct for system work.

scope is handed the job’s declared JobTransaction and an inner that yields whether the job succeeded, and answers with a JobSettlement:

crates/yourdb/src/worker/context.rs
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use nest_rs::core::injectable;
use nest_rs::worker::{JobContext, JobSettlement, JobTransaction, Unhonoured};
#[injectable]
pub struct YourWorkerContext {
// #[inject] your pool / client here
}
impl JobContext for YourWorkerContext {
fn scope<'a>(
&'a self,
transaction: JobTransaction,
inner: Pin<Box<dyn Future<Output = bool> + Send + 'a>>,
) -> Pin<Box<dyn Future<Output = JobSettlement> + Send + 'a>> {
Box::pin(async move {
match transaction {
// The opt-out: every statement stands alone, nothing to settle.
JobTransaction::Pool => {
nest_rs::database::with_job_executor(/* pool handle */, inner).await;
JobSettlement::Settled
}
// The default: one transaction per attempt.
JobTransaction::PerAttempt => {
let txn = Arc::new(/* txn handle */);
let succeeded =
nest_rs::database::with_job_executor(txn.clone(), inner).await;
if !succeeded {
// The job failed on its own terms. Roll back using your
// store's API and settle: the job's own error is what
// the transport reports, and adding one would say the
// same failure twice.
return JobSettlement::Settled;
}
// It succeeded, so the attempt now stands or falls on the
// commit. When a *successful* job cannot be honoured, say so
// — and say whether repeating the attempt could end
// differently.
match /* commit using your store's API */ {
Ok(()) => JobSettlement::Settled,
// A serialization conflict or a deadlock is the one
// shape another attempt could win.
Err(err) if err.is_conflict() => {
JobSettlement::Unhonoured(Unhonoured::retryable(
"the job's transaction could not be committed",
))
}
Err(_) => JobSettlement::Unhonoured(Unhonoured::deterministic(
"the job's transaction could not be committed",
)),
}
}
}
})
}
}

Three things your impl owes:

  • Drive inner to completion. It is the job. Returning without awaiting it fails that one job loudly rather than silently skipping it.
  • Honour JobTransaction::Pool. It is the developer’s transactional = false, and ignoring it silently gives up the atomicity the default promises.
  • Classify an Unhonoured from what your backend said, not from a guess. A retry replays the whole job body, side effects included, so Unhonoured::retryable is for a failure that could genuinely end differently — a serialization conflict, a deadlock. A constraint violation, or a commit whose outcome you do not know, is Unhonoured::deterministic.
  • A failed statement and a failed commit are not the same question, and answering them alike dead-letters a job over an outage. Inside an open transaction nothing is durable yet, so a connection your store could not acquire, or lost mid-attempt, left nothing behind: retryable. At commit the same error means the commit may already have landed: deterministic. nest-rs-seaorm splits them as retry::is_transient_failure (statements) against CommitError::is_retryable_conflict (the commit), and yours should split them too.

A worker transport (#[scheduled], #[processor]) resolves Arc<dyn JobContext> from the container before each job; binding your context as that dyn target makes your driver’s handle the ambient executor for every job in the binary.

The activation seam an app imports. A DynamicModule that seeds the connection at the factory phase, registers the interceptor, and binds the worker context to dyn JobContext. Every field of the config is settable from both the environment (NESTRS_<NAMESPACE>__<KEY>) and a pinned struct — the framework-wide dual-path rule.

crates/yourdb/src/module.rs
use std::sync::Arc;
use nest_rs::config::ConfigModule;
use nest_rs::core::{ContainerBuilder, DynamicModule};
pub struct YourDbModule;
impl YourDbModule {
pub fn for_root(config: impl Into<Option<YourConfig>>) -> YourDbSetup {
YourDbSetup { pinned: config.into() }
}
}
pub struct YourDbSetup {
pinned: Option<YourConfig>,
}
impl DynamicModule for YourDbSetup {
fn register(self, builder: ContainerBuilder) -> ContainerBuilder {
let builder = <YourDbContext as nest_rs::core::Discoverable>::register(builder);
let snapshot = builder.snapshot();
let worker = YourWorkerContext::from_container(&snapshot);
builder.provide_dyn::<dyn nest_rs::worker::JobContext>(Arc::new(worker))
}
fn collect(&self, builder: ContainerBuilder) -> ContainerBuilder {
let builder = ConfigModule::provide_feature(self.pinned.clone(), builder);
builder.provide_factory::</* YourHandle */, _, _>(|container| async move {
// Your code: build your pool / client from container.get::<YourConfig>().
})
}
}

Services don’t touch the task-local directly — they call a small helper your driver ships. The helper reads the current executor, downcasts to your enum, and hands the caller a store-native handle.

crates/yourdb/src/executor.rs
pub fn current_executor() -> Option<Executor> {
nest_rs::database::current_executor()?
.as_any()
.downcast_ref::<Executor>()
.cloned()
}

A service then writes let exec = current_executor().ok_or(...)?; and issues queries against whichever variant came back — your problem from here, against types your users already recognise.

What the seam carries, and what stays store-native

Section titled “What the seam carries, and what stays store-native”

The SeaORM-typed primitives — Repo<E>’s ambient row-level filter via Ability::condition_for, the #[expose] response mask, Bind<A, S>, CrudService — all read SeaORM’s typed Column / EntityTrait model layer, so a driver ships their equivalents in store-native terms (e.g. Postgres RLS, an ability-derived filter document, a per-collection helper). What every driver does keep: transparent transactions on mutating requests, the ambient executor across HTTP and workers, the access graph and module wiring, structured observability under nest_rs::orm.

  • crates/nest-rs-seaorm/ — the first-party driver; read its executor.rs, interceptor.rs, worker.rs, and module.rs whenever a placeholder above needs a concrete shape.
  • crates/nest-rs-database/README.md — the trait contract, the task-local seam, the install helpers in detail.
  • crates/nest-rs-redis/ — an analogous worker-side integration on the queue runtime, for a second read on the JobContext pattern.
  • Repo and executor — the executor and task-local your driver installs.
  • Transactions — the commit/rollback boundary the interceptor drives.
  • Database — the module an app imports to pick a driver.