Database
Entity + service + module — a CRUD-ready slice in under 60 lines, with row-level security and transactions transparent.
A new entity becomes a fully wired CRUD slice — REST and GraphQL, paginated,
scoped by the caller’s ability, masked on the way out — in under 60 lines
across three files. The data layer is built on
SeaORM (entities, queries, migrations) and
sea-query (the conflict / filter
DSL); NestRS wraps both so every access flows through a service and a
request-scoped Repo, never the ORM directly.
This page walks the three files end to end. The deeper concepts — the ambient executor, dataloaders, pagination, transactions, health — live on their own pages, linked at the bottom.
Install
Section titled “Install”cargo add nest-rs --features seaorm,httpThat is everything the entity below needs, in full.
#[expose] generates the wire model, the create/update inputs and the
ActiveModel plumbing — and carries their derives, each pointed back at the
framework’s own copy. The ORM, uuid and chrono come through the same route:
an entity crate declares none of them. nestrs g resource writes the file.
The entity
Section titled “The entity”use nest_rs::resource::expose;use nest_rs::seaorm::sea_orm;use nest_rs::seaorm::sea_orm::entity::prelude::*;
#[expose(name = "Post", service = super::service::PostsService)]#[sea_orm::model]#[derive(Clone, Debug, DeriveEntityModel)]#[sea_orm(table_name = "post")]pub struct Model { #[sea_orm(primary_key, auto_increment = false)] #[expose] pub id: Uuid,
#[expose(input(create, update), validate(length(min = 1)))] pub title: String,
#[expose(input(create, update), validate(length(min = 1)))] pub body: String,}
impl ActiveModelBehavior for ActiveModel {}From this one declaration, #[expose] generates the wire DTO returned by
handlers (Post), the CreatePost and UpdatePost inputs filtered by
input(create) / input(update), the GraphQL SimpleObject, the OpenAPI
schema, the PK dataloader (PostsServiceById) for relations, and the
From<&Model> / IntoActiveModel plumbing every handler needs. The
entity-derived inputs are bare (CreatePost, not CreatePostDto) — one
struct is at once the service’s create type, the REST body, and the GraphQL
input, so no boundary suffix fits.
Exposure is opt-in: a column crosses the wire only when its field carries
#[expose]. A bare #[expose] is read-only; #[expose(input(...))] adds write
and implies read. A field with no #[expose] is hidden from every transport, so
a column added by a later migration never reaches a client until you expose it.
service = … is required as soon as the entity declares an exposed relation —
the macro emits the PK loader on it.
Relations declared with #[sea_orm(belongs_to, …)] or #[sea_orm(has_many)]
auto-resolve through dataloaders when the relation field carries #[expose]
(and the entity has the graphql flag) — see Dataloaders.
HTTP-only resources
Section titled “HTTP-only resources”REST/OpenAPI adopters can compile without pulling in GraphQL:
cargo add nest-rs --features http,seaorm,authz#[expose(name = "Post", service = super::service::PostsService)]// no `graphql` flag — wire DTO + CRUD inputs onlypub struct Model { #[expose] pub org_id: Uuid, // scalar FK, not HasOne<Org> // …}GraphQL surface when you need it:
cargo add nest-rs --features seaorm,graphql#[expose(name = "Post", service = super::service::PostsService, graphql)]The service
Section titled “The service”use nest_rs::authz::Action;use nest_rs::core::injectable;use nest_rs::seaorm::{Creatable, CrudService, Deletable, Repo, ServiceError, Updatable};use sea_orm::{ColumnTrait, QueryFilter};
use super::entity::{self, CreatePost, Entity as Posts, Post, UpdatePost};
#[injectable]#[derive(Default)]pub struct PostsService;
impl CrudService for PostsService { type Entity = Posts;}
impl Creatable for PostsService { type Create = CreatePost;}
impl Updatable for PostsService { type Update = UpdatePost;}
impl Deletable for PostsService {}
impl PostsService { pub async fn find_by_title(&self, title: &str) -> Result<Option<Post>, ServiceError> { let row = Repo::<Posts>::scoped(Action::Read) .filter(entity::Column::Title.eq(title.to_owned())) .one(&Repo::<Posts>::conn()?) .await?; Ok(row.as_ref().map(Post::from)) }}CrudService is the read half — list, page, access — and every
resource implements it. The write half is opt-in: implement Creatable,
Updatable, Deletable only for the operations the resource genuinely
offers, and each names its bare entity-derived input (type Create = CreatePost). A read-only resource implements just CrudService and declares
none of them; a write op wired without its trait is a build break, never a
silent no-op. Every method routes through Repo and is filtered by the
caller’s Ability. Custom queries (here: find_by_title) sit
beside the inherited ones and stay ability-scoped by going through
Repo::<Posts>::scoped(Action::Read) — an unscoped raw query is the
documented exception, covered on Repo and executor.
The module
Section titled “The module”use nest_rs::core::module;use super::service::PostsService;
#[module(providers = [PostsService])]pub struct PostsModule;The HTTP adapter sits in its own folder and imports the port:
#[module(imports = [PostsModule], providers = [PostsController])]pub struct PostsHttpModule;The app root activates the database and lists the slice:
#[module( imports = [ SeaOrmModule::for_root(None), SeaOrmDatabaseModule, PostsModule, PostsHttpModule, ],)]pub struct AppModule;Two lines, two shapes. SeaOrmModule::for_root(None) opens the one pool: it
reads NESTRS_SEAORM__URL and the other NESTRS_SEAORM__* keys from the
environment, or a SeaOrmConfig you pass to set those values in code —
still overridable per field by the environment (see the dual-path
rule). SeaOrmDatabaseModule, a bare
import, then binds the data layer over it. The
variable is named for the crate that parses it, exactly as the type is: from
NESTRS_SEAORM__URL you find SeaOrmConfig, and from SeaOrmModule you find
the variable.
What the data layer hands you for free
Section titled “What the data layer hands you for free”Importing SeaOrmDatabaseModule activates four invariants every handler in the
slice inherits, without writing any of them:
- Row-level filtering. Every
Reporead runs with the caller’sAbility::condition_forANDed in. A row outside scope is invisible. - Transparent transactions. Mutating routes run inside a transaction committed on 2xx/3xx, rolled back otherwise.
- Same code path for safe routes and workers. Pool executor under the hood, no method change.
- Audited choke point. Controllers, resolvers, dataloader code call
the service; the service touches the DB only through
Repo.
In this section
Section titled “In this section”Basics
Section titled “Basics”- Entities —
#[expose]on a SeaORM model, and the DTO, inputs, schema and loaders it emits. - Entity checklist — every decision one entity asks of you, one line each.
- Repo and executor — the ambient executor,
the
DbContextinterceptor, the contextless-path escape hatch. - Migrations — write one, register it, run it with
nestrs run db. - CRUD —
#[crud(...)]on a controller or a resolver. - Pagination — the keyset
Page<M>cursor, REST and GraphQL.
All options
Section titled “All options”- Dataloaders —
#[dataloader]batches, and the PK and FK loaders#[expose]emits. - Transactions — auto-commit, conflict
observation, the
retry_on_conflictprimitive. - Seeding — idempotent demo data through the seed crate.
- Database health —
SeaOrmHealthModuleand the readiness probe. - Writing a driver — plugging a non-SeaORM store into the same seams.
Going further
Section titled “Going further”- Authorization — the ability every
Reporead is filtered by, and the mask every response goes through. - GraphQL — the other consumer of the same entity, relations and loaders.
- Testing — driving this layer against a live Postgres rather than a mock.