Skip to content

Persist through Postgres

You add one module to the app root and one migration to the workspace, and the data layer comes alive. By the end of this page, POST /posts persists into Postgres inside a transaction, a failing handler rolls back automatically, and the e2e baseline you’re about to add (next page) has a real database to point at.

DatabaseModule::for_root(None) activates the SeaORM data layer: seeds the connection from NESTRS_DATABASE__URL, registers the transaction interceptor, binds the worker context. The configuration uses the framework’s dual-path rule — pin a value here or leave it None and let env vars supply it.

The data layer crates are nest-rs-database (the store-agnostic seam) and nest-rs-seaorm (the SeaORM implementation, on top of SeaORM).

apps/blog/src/module.rs
use nest_rs_core::module;
use nest_rs_http::{HttpConfig, HttpModule};
use nest_rs_opentelemetry::OpenTelemetryModule;
use nest_rs_seaorm::DatabaseModule;
use features::posts::PostsHttpModule;
#[module(imports = [
OpenTelemetryModule,
DatabaseModule::for_root(None),
HttpModule::for_root(HttpConfig { port: 3005, ..Default::default() }),
PostsHttpModule,
])]
pub struct BlogModule;

Add the crate to the app’s Cargo.toml:

apps/blog/Cargo.toml
nest-rs-seaorm = { workspace = true, features = ["http"] }

The http feature flag turns on the ORM hooks the HTTP data layer needs when a controller reaches the DB through Repo.

The workspace ships a single migrations crate that holds every SeaORM migration. Add the post table.

  • Directorycrates/migrations/
    • Directorysrc/
      • lib.rs
      • m20260609_000000_create_post.rs
crates/migrations/src/m20260609_000000_create_post.rs
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Post::Table)
.if_not_exists()
.col(ColumnDef::new(Post::Id).uuid().not_null().primary_key())
.col(ColumnDef::new(Post::Title).string().not_null())
.col(ColumnDef::new(Post::Body).text().not_null())
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Post::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum Post {
Table,
Id,
Title,
Body,
}

Register it in the migrator:

crates/migrations/src/lib.rs
mod m20260609_000000_create_post;
pub use sea_orm_migration::prelude::*;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![Box::new(m20260609_000000_create_post::Migration)]
}
}

The reference workspace lists other migrations (org, user, …) for api. Your tutorial migrator only needs post until you extend the workspace.

The workspace already ships a Postgres in compose.yml. Bring it up once.

Terminal window
$ docker compose up -d
postgres ready on 127.0.0.1:5432
$ nestrs run db up
applied: m20260609_000000_create_post

nestrs run db up runs Migrator::up against NESTRS_DATABASE__URL — the connection string comes from the .env cascade.

Transactions wrap mutating routes automatically

Section titled “Transactions wrap mutating routes automatically”

DatabaseModule registers a request interceptor that classifies the HTTP method and installs the right executor before the handler runs:

HTTP methodAmbient executor
GET / HEAD / OPTIONS / TRACEPool — read-only, no transaction
POST / PATCH / PUT / DELETETransaction — committed on 2xx/3xx, rolled back otherwise

The contract you write code against: every successful mutation commits, every error response unwinds. No tx.begin() / tx.commit() in the service.

Terminal window
$ nestrs run dev blog
DEBUG nest_rs_http::transport: http transport listening addr=0.0.0.0:3005
INFO nest_rs::orm: connected to postgres

No bearer token — blog is public for the tutorial:

Terminal window
$ curl -s -X POST http://localhost:3005/posts \
-H 'Content-Type: application/json' \
-d '{"title":"Hello","body":"World"}'
{"id":"018f…","title":"Hello","body":"World"}
$ curl -s http://localhost:3005/posts/018f…
{"id":"018f…","title":"Hello","body":"World"}
$ curl -s http://localhost:3005/posts
[{"id":"018f…","title":"Hello","body":"World"}]

Create a second post with the same title — there is no unique constraint on title, so both rows persist. Transaction rollback on a 5xx is what page 8 locks down in e2e when you add richer failure paths; for now, trust the interceptor contract above.

  • A DatabaseModule::for_root(None) import in the app — the data layer is on.
  • A migration adding the post table, applied through nestrs run db up.
  • POST /posts persisting into Postgres inside an automatic transaction — no auth filter, no row-level policy, just business logic through Repo.

Next: lock it down with an e2e test →