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 add in Test it end to end 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_seaorm::DatabaseModule;
use features::authn::AuthnModule;
use features::authz::AuthzHttpModule;
use features::posts::PostsHttpModule;
#[module(imports = [
DatabaseModule::for_root(None),
HttpModule::for_root(HttpConfig { port: 3005, ..Default::default() }),
AuthnModule,
AuthzHttpModule,
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.

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
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
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_DATABASE__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_DATABASE__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”

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
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 signed with the NESTRS_AUTHN__SECRET that nestrs g auth wrote to .env. Authenticate and authorize builds the login route that mints one; until then, sign a development token yourself:

Terminal window
TOKEN=$(python3 - <<'PY'
import base64, hmac, hashlib, json, time
b = lambda x: base64.urlsafe_b64encode(x).rstrip(b"=").decode()
secret = b"dev-only-insecure-secret-change-me-32b" # NESTRS_AUTHN__SECRET, from .env
h = b(b'{"alg":"HS256","typ":"JWT"}')
p = b(json.dumps({"roles": ["user"], "exp": int(time.time()) + 3600}).encode())
print(f"{h}.{p}." + b(hmac.new(secret, f"{h}.{p}".encode(), hashlib.sha256).digest()))
PY
)
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.

  • 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, 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.

Built by YV17labs