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.
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_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:
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”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:
nestrs g migration create_postDirectorycrates/migrations/
Directorysrc/
- lib.rs
- migrator.rs
- m20260609_000000_create_post.rs
Directorybin/
- migrate.rs
Fill in the columns it stubbed:
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:
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.
Run Postgres
Section titled “Run Postgres”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:
$ docker compose up -d✔ Container hello-postgres-1 Started✔ Container hello-redis-1 Started
$ nestrs run db upINFO sea_orm_migration::migrator::exec: Applying migration 'm20260609_000000_create_post'INFO sea_orm_migration::migrator::exec: Migration 'm20260609_000000_create_post' has been appliednestrs 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 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 blogINFO nest_rs::orm: connecting to database max_connections=NoneDEBUG nest_rs::http: transport listening addr=0.0.0.0:3005 tls=falseThose 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:
TOKEN=$(python3 - <<'PY'import base64, hmac, hashlib, json, timeb = lambda x: base64.urlsafe_b64encode(x).rstrip(b"=").decode()secret = b"dev-only-insecure-secret-change-me-32b" # NESTRS_AUTHN__SECRET, from .envh = 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)$ 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.
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, scoped by the caller’s ability.
Going further
Section titled “Going further”- 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