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.
The data layer in one import
Section titled “The data layer in one import”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).
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:
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.
A migration for the post table
Section titled “A migration for the post table”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
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:
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.
Run Postgres
Section titled “Run Postgres”The workspace already ships a Postgres in compose.yml. Bring it up
once.
$ docker compose up -dpostgres ready on 127.0.0.1:5432
$ nestrs run db upapplied: m20260609_000000_create_postnestrs 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 method | Ambient executor |
|---|---|
GET / HEAD / OPTIONS / TRACE | Pool — read-only, no transaction |
POST / PATCH / PUT / DELETE | Transaction — 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.
Run the binary
Section titled “Run the binary”$ nestrs run dev blogDEBUG nest_rs_http::transport: http transport listening addr=0.0.0.0:3005INFO nest_rs::orm: connected to postgresNo bearer token — blog is public for the tutorial:
$ 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.
What you have now
Section titled “What you have now”- A
DatabaseModule::for_root(None)import in the app — the data layer is on. - A migration adding the
posttable, applied throughnestrs run db up. POST /postspersisting into Postgres inside an automatic transaction — no auth filter, no row-level policy, just business logic throughRepo.