Skip to content

Persist through Postgres

Import SeaOrmDatabaseModule, declare the migration, run the binary against Postgres — mutating routes commit in a transaction; failures roll back.

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 add in Test it end to end has a real database to point at.

Two imports activate the SeaORM data layer, one per shape: SeaOrmModule::for_root(None) opens the pool from NESTRS_SEAORM__URL, and SeaOrmDatabaseModule — a bare import beside it — registers the transaction interceptor and binds the worker context over that pool. 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::seaorm::{SeaOrmDatabaseModule, SeaOrmModule};
use features::authn::AuthnModule;
use features::authz::AuthzModule;
use features::posts::PostsHttpModule;
#[module(imports = [
SeaOrmModule::for_root(None),
SeaOrmDatabaseModule,
HttpModule::for_root(HttpConfig { port: 3005, ..Default::default() }),
AuthnModule,
AuthzModule,
PostsHttpModule,
])]
pub struct BlogModule;

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

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

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

nestrs new ships a migrations crate holding every SeaORM migration, plus the migrate binary the nestrs run db … verbs drive. Generate the file — the CLI registers it in both lib.rs and migrator.rs, which is the pair people forget by hand:

Terminal window
nestrs g migration create_post
  • Directorycrates/migrations/
    • Directorysrc/
      • lib.rs
      • migrator.rs
      • m20260609_000000_create_post.rs
      • Directorybin/
        • migrate.rs

Fill in the columns it stubbed:

crates/migrations/src/m20260609_000000_create_post.rs (from the demo)
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,
}

The generator already registered it. lib.rs is the mod list; migrator.rs is regenerated from that list, so the two can never disagree:

crates/migrations/src/lib.rs (from the demo)
mod m20260609_000000_create_post;
mod migrator;
pub use migrator::{Migrator, migrate};

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

nestrs new scaffolds a compose.yml with Postgres and Redis, and the committed .env already points NESTRS_SEAORM__URL at it. The credentials are the workspace name, not the app’s — a workspace scaffolded with nestrs new hello gets postgres://hello:hello@localhost:5432/hello, whatever its apps are called. Bring the services up once:

Terminal window
$ docker compose up -d
✔ Container hello-postgres-1 Started
✔ Container hello-redis-1 Started
$ nestrs run db up
INFO sea_orm_migration::migrator::exec: Applying migration 'm20260609_000000_create_post'
INFO sea_orm_migration::migrator::exec: Migration 'm20260609_000000_create_post' has been applied

nestrs run db up runs Migrator::up against NESTRS_SEAORM__URL. The binary resolves it through connect_from_env, which reads the same .env cascade the apps do — a tool and its app can never end up pointed at different databases.

Transactions wrap mutating routes automatically

Section titled “Transactions wrap mutating routes automatically”

SeaOrmDatabaseModule 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
INFO nest_rs::orm: connecting to database max_connections=None
DEBUG nest_rs::http: transport listening addr=0.0.0.0:3005 tls=false

Those lines come from .env.development, which sets NESTRS_LOG=debug. connecting to database is the only ORM boot line — the pool is lazy, so there is no “connected” confirmation to wait for; the first query is what proves the credentials.

Every route is behind the guards you bound on the previous page, so it needs a bearer token. nestrs g auth wrote one route that mints them — signed with the NESTRS_AUTHN__SECRET it put in .env, and refusing to boot anywhere but development and test:

Terminal window
$ TOKEN=$(curl -s -X POST http://localhost:3005/auth/dev-token \
-H 'Content-Type: application/json' -d '{}' | jq -r .access_token)

Pass {"roles": ["admin"]} for an admin token, or a sub to pin the subject. The real login is yours to write — what counts as a credential and where users live are domain decisions, and Add a login route is the pattern. Delete crates/features/src/authn/http/ the day you write it.

Terminal window
$ curl -s -X POST http://localhost:3005/posts -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"title":"Hello","body":"World"}'
{"id":"019f9bd5-725d-7ee2-8031-b45d3aa4cc7a","title":"Hello","body":"World"}
$ curl -s http://localhost:3005/posts -H "Authorization: Bearer $TOKEN"
[{"id":"019f9bd5-725d-7ee2-8031-b45d3aa4cc7a","title":"Hello","body":"World"}]

No row crosses the data layer without an ability. Repo ANDs Ability::condition_for into every read and every by-id write, and a request with no ability at all is filtered to nothing rather than granted everything — fail-closed, deliberately. Drop the ab.can(Action::Manage, post::Entity) you added on the previous page and the same two calls answer 403 with an empty table behind them.

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 the e2e suite in Test it end to end locks down when you add richer failure paths; for now, trust the interceptor contract above.

  • SeaOrmModule::for_root(None) + SeaOrmDatabaseModule 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, scoped by the caller’s ability.
  • Validate inputs — the next step: reject a bad body with a structured 400.
  • CRUD — the reference page for Repo, CrudService, and transactions.